Hãy hình dung một biểu mẫu giấy có ô ghi "điền tên bạn vào đây". Bình thường người ta điền "Nam", "Lan". Nhưng ô đó lại có một luật kỳ lạ: nếu bạn viết "lấy nội dung tủ hồ sơ số 3 dán vào đây", thư ký sẽ ngoan ngoãn đi mở tủ, photo giấy tờ bên trong, rồi dán vào chính chỗ đó — và trả cả tờ về cho bạn. Bạn tưởng mình đang điền dữ liệu, nhưng thật ra bạn đang ra lệnh. XML External Entity (XXE) chính là cái ô biểu mẫu đó, và thư ký cả tin là 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 chịu 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 tôi cho XXE đọc /etc/passwd thật, rồi kể lại một chuyện đáng mừng mà tôi không đoán trước: các thư viện XML Python hiện đại mặc định đã chặn nó.

Toàn bộ chạy trong container tự dựng, tự dọn. Tôi chỉ tấn công tiến trình do chính mình tạo ra, không nhắm vào hệ thống của ai.

Cái payload trông vô hại đến mức nào

Một tài liệu XXE trông như thế này — ngắn đến mức dễ tưởng là vô hại:

<?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, và giá trị của nó không phải một chuỗi bạn gõ vào, mà là nội dung của tệp /etc/passwd. Khi parser gặp &xxe; trong thân tài liệu, nếu nó chịu giải thực thể ngoài, nó đọc tệp đó và chèn thẳng vào kết quả — mà kết quả thì thường được trả về cho người dùng. Cả cuộc tấn công gói gọn trong một dòng khai báo.

Sáu parser, một payload

Tôi thử đúng payload đó qua sáu cấu hình parser Python khác nhau. Chạy ba lần, kết quả giống hệt nhau từng chữ:

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, phơi bày trần trụi: parser cấu hình sai đọc /etc/passwd của máy chủ và trả về nguyên nội dung — cả danh sách người dùng hệ thống — cho kẻ vừa gửi vài dòng XML lên. Không cần shell, không cần leo thang, chỉ một tài liệu và một parser cả tin.

Chỗ khiến tôi thở phào

Điều tôi không dự đoán khi bắt đầu: mặc định của thư viện Python hiện đại đã an toàn. Bốn trong sáu cấu hình chặn XXE, gồm cả lxml.etree.fromstring gọi trần lẫn 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ì lẳng lặng đi đọc tệp. Đây là một thay đổi lớn so với vài năm trước, khi XXE còn là lỗ mặc định của rất nhiều parser. Giống như cái bẫy Referer từng được trình duyệt âm thầm vá, XXE ở Python đã được vá ngay ở tầng mặc định của thư viện. Nếu bạn dùng parser chuẩn và không đụng vào cấu hình, nhiều khả năng bạn đã an toàn mà chẳng biết mình được ai bảo vệ.

Nhưng — và đây là chỗ cần cẩn thận — 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 cộng load_dtd=True là XXE hoạt động đầy đủ, đọc được cả /etc/passwd. Mà người ta bật những cờ này vì những lý do nghe rất hợp lý: "tài liệu của tôi cần thực thể nội bộ", "tôi phải validate theo DTD". Bật để giải quyết một nhu cầu có thật, rồi vô tình mở toang 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, chứ không còn là lỗi mặc định thụ động — và theo một nghĩa nào đó nó còn nguy hiểm hơn, vì lỗ hổng giờ ẩn sau một cái cờ trông vô hại.

Cách chắc ăn nhất, theo tôi, là dùng defusedxml: nó chặn tường minh chứ không dựa vào mặc định của ai. 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 nổ ngay trước mặt, buộc lập trình viên phải xử lý. Với dữ liệu XML không tin cậy, đây là lựa chọn an toàn nhất, vì nó không thể bị "bật nhầm cờ" thành thủng: nó cấm thực thể ở tầng thư viện, không để lại chỗ nào cho một cấu hình lỡ tay.

XML nấp ở nhiều nơi hơn bạn tưởng

Một điều khiến XXE dai dẳng đến khó chịu: rất nhiều định dạng là XML ở bên trong mà bạn không hề để ý. Tệp .docx, .xlsx, .pptx của Office thực chất là các gói nén chứa XML. Tệp .svg là XML. Nhiều tệp cấu hình, feed RSS/Atom, SOAP API, và đủ loại đị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ằm chờ.

Kịch bản thực tế mà tôi thấy hay bị bỏ sót: một trang cho tải lên "ảnh đại diện" và chấp nhận SVG. Kẻ tấn công nhúng một thực thể ngoài vào tệp SVG, và nếu máy chủ parse SVG đó bằng một parser có bật thực thể (để render, để lấy kích thước ảnh), thì /etc/passwd rò ra ngoài. Đây đúng là lý do các bài về tải tệp lên luôn khuyên nhận dạng theo nội dung tệp chứ không theo đuôi, và xử lý mọi định dạng có cấu trúc bằng parser an toàn — XXE và tệp polyglot là hai mặt của cùng một câu chuyện: "tệp người dùng gửi lên luôn chứa nhiều thứ hơn vẻ ngoài của nó".

Vì sao lỗ này tồn tại lâu đến vậy

XXE thật ra là một biến thể của đúng bài học injection chạy xuyên suốt sê-ri, chỉ nằm ở một chỗ bất ngờ: XML không đơn thuần là dữ liệu — nó mang theo 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 tí hon có khả năng đọc tệp và gọi mạng. Ai điều khiển được tài liệu XML thì điều khiển được những tham chiếu đó — và thế là ranh giới giữa "dữ liệu" với "lệnh" nhòe đi, đúng như mọi lỗ injection khác.

Điều này cũng giải thích vì sao XXE sống dai như một lỗ mặc định: các parser bật đầy đủ tính năng XML để cho "đúng chuẩn", mà chuẩn XML thì có thực thể ngoài thật. Phải mất nhiều năm và không ít CVE, cộng đồng mới 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 tới từng chữ". Cái xu hướng ấy — an toàn theo mặc định — chính là điều dễ chịu nhất mà thí nghiệm nhỏ này cho tôi thấy.

Nghĩa là gì trong công việc hằng ngày

  • 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 vào việc mặc định có tốt hay không.
  • Đừng bật resolve_entities, load_dtd, hay cờ 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 thật sự cần thực thể, hãy tách luồng ra: parser lỏng cho dữ liệu tin cậy, parser chặt cho dữ liệu đến từ ngoài.
  • Cân nhắc bỏ hẳn XML cho các API mới. JSON không có khái niệm thực thể ngoài — cùng lý do khiến nó an toàn hơn pickle. XXE là thêm một cái cớ nữa để chọn JSON khi có thể.
  • Kiểm tra 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ũ kỹ có thể vẫn mặc định thủng như thời trước.

Cùng một cái bẫy, đổi ngôn ngữ

Cái bẫy XXE không phải đặc sản của Python — nó chỉ là một hình dạng cụ thể của một sai lầm chung, và các nền tảng khác đều có góc riêng của nó.

Trong Java, XXE từng là một trong những lỗ kinh điển nhất, vì các parser mặc định như DocumentBuilderFactory, SAXParserFactory hay XMLInputFactory xưa nay bật thực thể ngoài. Cách vá chuẩn là gọi factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) — tức cấm luôn khai báo DOCTYPE, đúng tinh thần defusedxml: chặn ở tầng cấu hình chứ không mong mặc định đủ tốt. Ai từng đọc hướng dẫn XXE của OWASP hẳn nhớ cả một danh sách dài các feature phải tắt cho từng loại factory.

Trong Go, encoding/xml của thư viện chuẩn không giải thực thể ngoài — nó bỏ qua DTD hoàn toàn, nên XXE gần như không tồn tại nếu bạn dùng đúng thứ chuẩn. Đây là ví dụ đẹp của "an toàn theo mặc định" được đưa vào ngay từ thiết kế ngôn ngữ, chứ không phải vá dần qua các bản phát hành.

Còn ở cơ sở dữ liệu, họ hàng gần nhất của XXE là các hàm XML nội tại: XMLTYPE của Oracle hay các hàm xử lý XML trong SQL Server từng cho phép một truy vấn nạp tài nguyên ngoài qua tài liệu XML người dùng đưa vào — cùng một lỗ, chỉ khác chỗ nó nằm trong tầng CSDL thay vì tầng ứng dụng.

Mẫu số chung: mỗi khi một trình phân tích được phép với tay ra ngoài tài liệu — đọc tệp, gọi mạng, nạp tài nguyên — thì dữ liệu người dùng đã ngầm trở thành lệnh. Cách chữa ở mọi ngôn ngữ đều giống nhau đến kỳ lạ: khóa parser lại cho nó chỉ biết đọc dữ liệu, tắt mọi khả năng vươn ra ngoài, và đừng bao giờ tin vào mặc định mà không tự kiểm.

Nếu muốn tự thấy tận mắt, hãy đi tìm trong ứng dụng của bạn mọi chỗ parse XML từ nguồn ngoài — API nhận XML, ô tải lên tệp XML/SVG/DOCX (đều là XML bên trong), webhook định dạng XML. Với mỗi chỗ, nhìn xem parser được cấu hình ra sao. Thấy resolve_entities=True, load_dtd=True, hay setFeature(...external-general-entities..., true) là XXE đang mở toang. 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, thử gửi vào một payload file:///etc/hostname — nếu nội dung tệp quay ngược lại trong phản hồi, bạn vừa tìm thấy một lỗ đọc tệp máy chủ ngay trong sân nhà mình.