XML External Entity (XXE) là một lỗ hổng của trình phân tích XML: XML cho phép định nghĩa "thực thể" (entity) — một dạng biến — và một thực thể ngoài có thể trỏ tới một tệp trên máy chủ hay một URL. Nếu parser giải thực thể đó, một tài liệu XML người dùng gửi lên có thể đọc tệp của máy chủ. Bài này chứng minh XXE đọc /etc/passwd thật, rồi đo một điều đáng mừng: các thư viện XML hiện đại mặc định đã chặn nó.
Toàn bộ chạy trong container tự dựng, tự dọn. Chỉ tấn công tiến trình do tôi tạo. Không nhắm vào hệ thống của ai.
Payload XXE
Một tài liệu XXE trông như thế này:
<?xml version="1.0"?>
<!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<root>&xxe;</root>
Dòng <!ENTITY xxe SYSTEM "file:///etc/passwd"> định nghĩa một thực thể tên xxe, giá trị là nội dung của tệp /etc/passwd. Khi parser gặp &xxe; trong nội dung, nếu nó giải thực thể ngoài, nó đọc tệp đó và chèn vào kết quả — mà kết quả thường được trả về cho người dùng.
Số đo
Tôi thử cùng payload qua sáu cấu hình parser Python. Ba lần chạy, kết quả giống hệt:
| Parser / cấu hình | Kết quả |
|---|---|
lxml etree.fromstring (mặc định) |
chặn — entity không được định nghĩa |
lxml resolve_entities=True, load_dtd |
THỦNG: đọc được bí mật |
lxml cấu hình sai, đọc /etc/passwd |
THỦNG: root:x:0:0:root:/root:/bin/bash... |
lxml resolve_entities=False (an toàn) |
an toàn — không giải |
| defusedxml (thư viện chống XXE) | chặn — EntitiesForbidden |
| ElementTree chuẩn Python | chặn — entity không định nghĩa |
Dòng thứ ba là XXE thật: parser cấu hình sai đọc /etc/passwd của máy chủ và trả về toàn bộ nội dung — danh sách người dùng hệ thống — cho kẻ gửi XML lên.
Điều đáng nhớ
Mặc định của thư viện Python hiện đại đã an toàn — và đây là kết quả tôi không dự đoán. Bốn trong sáu cấu hình chặn XXE, gồm cả lxml.etree.fromstring gọi trần và xml.etree.ElementTree chuẩn. Chúng đơn giản không giải thực thể ngoài theo mặc định — báo "entity không được định nghĩa" thay vì đọc tệp. Đây là một thay đổi lớn so với vài năm trước, khi XXE là lỗ mặc định của nhiều parser. Giống như cái bẫy Referer đã được trình duyệt vá, XXE ở Python đã được thư viện vá ở tầng mặc định. Nếu bạn dùng parser chuẩn và không đụng vào cấu hình, bạn nhiều khả năng đã an toàn.
Nhưng XXE thủng ngay khi ai đó bật các cờ nguy hiểm. Dòng hai và ba cho thấy: chỉ cần resolve_entities=True và load_dtd=True là XXE hoạt động đầy đủ — đọc được cả /etc/passwd. Người ta bật những cờ này vì lý do chính đáng nghe hợp lý: "tài liệu của tôi cần thực thể nội bộ", "tôi cần validate theo DTD". Bật để giải quyết một nhu cầu, và vô tình mở lại cửa XXE. Đây là lý do XXE ngày nay chủ yếu là lỗi cấu hình sai chủ động, không phải lỗi mặc định thụ động — nguy hiểm hơn theo một nghĩa, vì nó ẩn sau một cờ trông vô hại.
Cách chắc chắn nhất là dùng defusedxml — nó chặn tường minh, không dựa vào mặc định. defusedxml không im lặng bỏ qua thực thể mà ném lỗi EntitiesForbidden — biến một lỗ hổng tiềm ẩn thành một lỗi rõ ràng buộc lập trình viên xử lý. Với dữ liệu XML không tin cậy, đây là lựa chọn an toàn nhất: nó không thể bị "bật nhầm cờ" thành thủng, vì nó cấm thực thể ở tầng thư viện.
XML ẩn ở nhiều nơi hơn bạn nghĩ
Một điều khiến XXE dai dẳng: rất nhiều định dạng là XML bên trong mà bạn không để ý. Tệp .docx, .xlsx, .pptx của Office là các tệp nén chứa XML. Tệp .svg là XML. Nhiều cấu hình, feed RSS/Atom, SOAP API, và định dạng trao đổi dữ liệu doanh nghiệp đều là XML. Mỗi chỗ ứng dụng của bạn mở một trong những định dạng đó là một chỗ XXE có thể ẩn.
Kịch bản thực tế: một trang cho tải lên "ảnh đại diện" chấp nhận SVG. Kẻ tấn công nhúng một thực thể ngoài vào SVG, và nếu máy chủ parse SVG đó bằng một parser bật thực thể (để render, để lấy kích thước), tệp /etc/passwd rò ra. Đây là lý do bài tải tệp lên trước khuyên nhận dạng theo nội dung và xử lý mọi định dạng cấu trúc bằng parser an toàn — XXE và polyglot là hai mặt của cùng vấn đề "tệp người dùng chứa nhiều hơn vẻ ngoài".
Vì sao
XXE là một biến thể của cùng bài học injection xuyên suốt sê-ri, nhưng ở một chỗ bất ngờ: XML không chỉ là dữ liệu — nó có một ngôn ngữ con (DTD) có thể tham chiếu tài nguyên ngoài. Khi bạn nghĩ mình đang parse "dữ liệu", bạn thực ra đang chạy một trình thông dịch nhỏ có khả năng đọc tệp và gọi mạng. Kẻ tấn công điều khiển tài liệu XML thì điều khiển được những tham chiếu đó.
Điều này giải thích vì sao XXE tồn tại lâu như một lỗ mặc định: các parser bật đầy đủ tính năng XML để "đúng chuẩn", mà chuẩn XML có thực thể ngoài. Phải mất nhiều năm và nhiều CVE để cộng đồng chấp nhận rằng mặc định nên là "tắt tính năng nguy hiểm" thay vì "đúng chuẩn hoàn toàn". Xu hướng đó — an toàn theo mặc định — là điều phép đo này ghi nhận.
Nghĩa là gì trong thực tế
- Với XML không tin cậy, dùng defusedxml (Python) hoặc bộ tương đương trong ngôn ngữ của bạn. Nó cấm thực thể ở tầng thư viện, không phụ thuộc mặc định.
- Đừng bật
resolve_entities,load_dtd, hay tương đương trên dữ liệu người dùng. Nếu tài liệu nội bộ của bạn cần thực thể, tách luồng: parser lỏng cho dữ liệu tin cậy, parser chặt cho dữ liệu ngoài. - Cân nhắc bỏ XML hẳn cho API mới. JSON không có khái niệm thực thể ngoài — cùng lý do nó an toàn hơn pickle (bài trước). XXE là một lý do nữa để chọn JSON khi có thể.
- Kiểm phiên bản thư viện. "Mặc định an toàn" là thành quả của các bản cập nhật; một thư viện XML cũ có thể vẫn mặc định thủng.
Chỗ tôi không kết luận được
Tôi đo trên Python với lxml và ElementTree phiên bản hiện tại. Java (DocumentBuilderFactory), PHP (libxml), .NET (XmlReader) có mặc định khác nhau — một số vẫn thủng theo mặc định ở phiên bản cũ, và biện pháp tắt XXE khác nhau ở từng nền tảng. "Mặc định an toàn" của tôi là kết luận cho hệ sinh thái Python hiện đại, không phải cho mọi nơi.
Và tôi chỉ đo XXE đọc tệp cục bộ. Các biến thể khác — XXE mù (dữ liệu rò qua kênh ngoài như DNS/HTTP thay vì trả về trực tiếp), và "billion laughs" (thực thể lồng nhau gây cạn bộ nhớ, một dạng DoS) — dùng cùng cơ chế DTD nhưng cần môi trường đo khác, mà tôi không dựng.
Thử ba mươi giây
Tìm trong ứng dụng của bạn mọi chỗ parse XML từ nguồn ngoài — API nhận XML, upload tệp XML/SVG/DOCX (đều là XML bên trong), webhook định dạng XML.
Với mỗi chỗ, xem parser được cấu hình thế nào. Nếu bạn thấy resolve_entities=True, load_dtd=True, hay setFeature(...external-general-entities..., true), đó là XXE đang mở. Và nếu bạn không thấy defusedxml hay một cấu hình tắt-thực-thể tường minh, hãy thử gửi một payload file:///etc/hostname vào — nếu nội dung tệp quay lại trong phản hồi, bạn có một lỗ đọc tệp máy chủ.