XXE and XML Bombs: Why You Should Think Twice Before Parsing XML in Python

XXE and XML Bombs: Why You Should Think Twice Before Parsing XML in Python

Django Security Series β€” Post 5 | Series I: Injection Attacks
OWASP A02:2025 β€” Security Misconfiguration (CWE-611 / CWE-776) | Reading time: ~15 min

πŸ§ͺ Run it yourself. This attack ships as a runnable lab in django-security-lab: an XML webhook that reads an <order> document. The lxml view (entity resolution on) lets a file:// external entity read a flag it should never reach; the defusedxml view refuses the entity declaration and returns 400. Reproduce both from the command line with curl β€” and see the detection surprise: Bandit flags the stdlib parsers but is blind to the lxml one that actually performs the read, so the lab ships the custom rule that catches it.

The first four posts of this series walked the same root cause β€” confusing data with code β€” through four different interpreters. Post 1 carried it into the SQL database, Post 2 into the browser's HTML parser, Post 3 into the template engine, and Post 4 into the operating-system shell. Post 5 closes Series I at the last interpreter on the list, and the one Django developers most often forget is an interpreter at all: the XML parser.

This post surprised me in a specific way. I set out assuming PetiΓ§Γ£o Brasil had no XXE surface at all β€” it speaks JSON, and accepts no XML uploads. What I hadn't considered is that XML surfaces are often indirect. The platform has a PDF/A check that reads a certificate's XMP metadata block β€” which is XML β€” through pikepdf, and pikepdf parses it with lxml under the hood. I never wrote a line of XML-parsing code, yet a dependency two levels down was handing bytes from a PDF to an XML parser. pikepdf turns out to harden that parser itself (resolve_entities=False and no_network=True, two of the settings in Pattern 2's fix below), but that was a decision someone else made, in a chain I did not write.

This post covers how an XML entity turns a parser into a file reader, which of Python's parsers still allow it, and how to close it with defusedxml or an explicitly hardened lxml parser.


The Attack: What It Is and How It Works

XML is more than a data format β€” the specification includes a Document Type Definition (DTD), a small grammar that a document can carry inline (the internal subset) or reference externally. The DTD lets a document declare entities. An entity is XML's built-in substitution mechanism β€” think of it as a named placeholder, much like a constant or a macro: you define it once, and wherever the parser later sees a reference &name;, it replaces that reference with the entity's value before it hands you the parsed tree. Entities exist for two perfectly legitimate reasons. The first is escaping β€” inserting characters that would otherwise break the markup: you write &lt; so the parser reads a literal < as text instead of the start of a tag. The second is reuse β€” defining a boilerplate string once (a company name, a legal notice, a repeated URL) and referencing it many times so a single edit updates them all. You have already met the escaping kind β€” &lt;, &gt;, &amp;, &apos;, &quot; are the five predefined XML entities, standing in for <, >, &, ', and ". The danger is that a document can define its own entities, and those definitions can point outside the document.

There are three kinds of custom entity, and the security story lives in the second and third:

  • Internal entity β€” a literal string substitution: <!ENTITY company "ACME Corp">. Benign on its own.
  • External general entity β€” a substitution whose value is fetched from a URI: <!ENTITY xxe SYSTEM "file:///etc/passwd">. When the parser resolves &xxe;, it reads that URI and inlines the result. This is the heart of XXE.
  • External parameter entity β€” the same idea, referenced with %name; inside the DTD itself.

The classic XXE reads a local file. A parser that resolves external entities treats this document as an instruction to read /etc/passwd and place its contents where &xxe; appears:

<?xml version="1.0"?>
<!DOCTYPE data [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>

If the application parses this and reflects the resulting <data> value anywhere β€” an error message, a confirmation page, a stored record the attacker can read back β€” the contents of /etc/passwd come with it. On a Django server the files worth reading sit next to the code: settings.py, a .env, anything that holds the SECRET_KEY.

In a Django app, the realistic entry point is any endpoint or task that accepts XML the attacker controls, and XML never really left: SOAP integrations with payment gateways and partners, RSS and Atom feed readers, partner webhooks, and the formats users upload every day β€” a .docx, .xlsx or .pptx is a ZIP archive full of XML, and an .svg is XML. A partner-feed importer that hands request.FILES['feed'].read() to etree.fromstring() is typical. Whether that line reads /etc/passwd depends on the installed lxml: before lxml 5.0 (December 2023) its default parser resolved external entities, and iterparse() kept doing so until lxml 6.1.0 (April 2026). A safety that depends on the installed version is not a decision anyone made.

What makes XXE dangerous is what makes it easy to miss in review: the vulnerable line is a routine parse() call that looks like passive data handling, not an obvious sink like subprocess or cursor.execute.

A note on OWASP classification: this post sits in Series I (Injection Attacks) because its root cause β€” untrusted data interpreted as instructions by the XML parser β€” is the same "data vs. code" confusion that drives SQL injection, XSS, SSTI and command injection. The OWASP Top 10:2025, however, files XXE under A02 Security Misconfiguration: the fix is a parser-configuration decision rather than input sanitisation. This post uses both CWE-611 (Improper Restriction of XML External Entity Reference) and CWE-776 (Improper Restriction of Recursive Entity References in DTDs) as its canonical weakness identifiers.

The same entities do other damage. The SYSTEM identifier also speaks http://, so a parser that fetches URLs becomes a proxy into internal services and a cloud metadata endpoint, which is server-side request forgery. When nothing is echoed back, parameter entities and an attacker-hosted DTD carry a file out inside a URL instead (blind XXE). And the XML bomb needs no external access at all: in Billion Laughs, ten levels of entities, each referencing the one below ten times, expand a document under a kilobyte into a billion copies of lol, about 3 GB, until the worker runs out of memory.


Real-World Incidents

Facebook XXE β€” Reginaldo Silva (2014)

In November 2013 the Brazilian security researcher Reginaldo Silva reported an XML External Entity vulnerability in Facebook's OpenID consumer endpoint (the public disclosure followed in January 2014). Facebook accepted an OpenID login request, fetched and parsed an XML document from the identity provider, and did so with a parser that resolved external entities β€” so Silva could make Facebook's servers read local files and make outbound requests from inside Facebook's network. He had found the same OpenID XXE flaw in 2012, and Google had paid him US$500 for it in App Engine and Blogger; after Facebook patched the file read he showed its security team how the bug escalated to remote code execution, which they confirmed. Facebook awarded him US$33,500 β€” the largest single payout its bug-bounty program had made at the time, raised once Facebook rated the bug remote code execution β€” because an XXE that can pivot to internal services and the file system is not a "read one file" bug but a foothold inside the perimeter.

The lesson for Django developers is the through-line of this whole post: the vulnerable code was not doing anything exotic β€” it was parsing an XML document received from an external party, the single most ordinary XML operation there is. The initial access maps to MITRE ATT&CK T1190 (Exploit Public-Facing Application), and reading the server's own files through the parser to T1005 (Data from Local System). A one-line switch to a parser that refuses DTDs and external entities would have closed the entire chain at the source.

Sources: Reginaldo Silva β€” Remote Code Execution via XXE in Facebook; Threatpost β€” Facebook Pays $33,500 Bounty for Major Code Execution Flaw


Django's Default Protections

The headline is the same as Post 4's: Django does not parse arbitrary XML for you, and it provides no automatic protection when your code does. Stock Django parses form-encoded and multipart bodies into request.POST and leaves any other body as raw bytes in request.body, and Django REST Framework's default parsers add JSON β€” neither puts an XML parser in the default request path, so a plain Django app has no XXE surface until a developer deliberately adds XML parsing. That is protective by omission, and it is worth stating plainly: if you never parse untrusted XML, you cannot be hit by XXE.

Two nuances complete the picture. First, Django's own XML serialization framework β€” the code behind loaddata/dumpdata fixtures β€” was hardened years ago to refuse DTDs, entity declarations and external references, so loading a fixture is not a concern. But that path handles your own trusted fixtures, not user input, and it tells you nothing about the parser you reach for when you parse a request body. Second, and this is the part that catches people: the protection or exposure lives entirely in the Python library you choose, and Django has no say in it.

  • lxml β€” the fast, libxml2-based parser most Django projects install for real XML work. Since lxml 5.0 (December 2023) its default parser no longer resolves external entities (resolve_entities='internal'). The safe defaults took a while to reach every entry point: iterparse() and ETCompatXMLParser still resolved external entities until lxml 6.1.0 (April 2026, CVE-2026-41066), and 'internal' still allowed external parameter entities until 6.1.3 (September 2026). And code that passes resolve_entities=True β€” the lab's vulnerable view β€” gets the full file read back on every version. Harden the parser explicitly or use defusedxml, rather than letting the installed version decide.
  • xml.etree.ElementTree, xml.dom.minidom, xml.sax β€” the standard library β€” sit on Expat, which, in the words of Python's XML security docs, "does not access local files or create network connections" by default.

The takeaway mirrors command injection: the moment your code calls etree.fromstring, minidom.parse, or a SOAP/feed library, you have stepped outside anything Django can protect, and the safety of that parse is entirely your responsibility.


The Vulnerable Pattern and the Fix

Pattern 1 β€” lxml with Entity Resolution Turned On

The lab's webhook builds its parser with resolve_entities=True, the explicit footgun, and the fix routes the same body through defusedxml.ElementTree (imported as defused_etree):

-_UNSAFE_PARSER = etree.XMLParser(resolve_entities=True)
-
 def webhook(request):
-    root = etree.fromstring(request.body, parser=_UNSAFE_PARSER)
+    try:
+        root = defused_etree.fromstring(request.body)
+    except (EntitiesForbidden, DTDForbidden, ExternalReferenceForbidden,
+            defused_etree.ParseError):
+        return HttpResponse("Rejected: not an acceptable XML document", status=400)
     order_id = root.findtext(".//orderId") or ""
     return JsonResponse({"received": order_id})

On the vulnerable view, a body declaring <!ENTITY xxe SYSTEM "file:///tmp/xxe/flag.txt"> with &xxe; inside <orderId> makes lxml inline the flag into order_id. defusedxml mirrors Python's XML modules one for one and refuses a dangerous declaration as soon as the parser meets it: by default it forbids entity declarations and external references (forbid_entities=True, forbid_external=True) while allowing a plain DOCTYPE that declares only element structure, so the payload raises EntitiesForbidden before any file is read. The same refusal stops an XML bomb, whose nested entities are declarations too. forbid_dtd=True rejects any DOCTYPE at all, with DTDForbidden. All three exceptions share the base class DefusedXmlException. A truncated or malformed body raises ParseError instead, which is not one of them, so the fix catches it too. Use defusedxml.ElementTree, not the package's lxml shim: importing defusedxml.lxml warns that it "is no longer supported and will be removed". Both views are in the lab: views_vulnerable.py and views_secure.py.

Pattern 2 β€” A SOAP Response Trusted Because of Who Sent It

The mistake is assuming a "trusted partner" response is safe to parse loosely. A compromised or malicious upstream β€” or a machine-in-the-middle on an unauthenticated leg β€” returns a document whose entities read your server's files. When a library needs a real lxml tree, build the parser yourself:

+_SAFE_PARSER = etree.XMLParser(
+    resolve_entities=False,   # keep &entity; references unexpanded
+    no_network=True,          # no http(s) fetches for SYSTEM identifiers
+    load_dtd=False,           # do not load an external DTD
+)
+
 def fetch_partner_status(order_ref):
     resp = requests.post("https://partner.example/soap", data=build_envelope(order_ref))
-    doc = etree.fromstring(resp.content)
+    doc = etree.fromstring(resp.content, parser=_SAFE_PARSER)
+    if doc.getroottree().docinfo.doctype:
+        raise ValueError("DTD not permitted")
     return doc.findtext('.//status')

On lxml 6.1.3 or later the old line does not read files. From 5.0 to 6.1.2 it still loads external parameter entities, so a payload can read a local file and leak it through the parser's error log. Either way it does whatever the installed version does. The explicit parser makes the decision in your code: resolve_entities=False leaves entity references unexpanded, no_network=True keeps the parser off the network, and rejecting any DOCTYPE refuses the DTD an XXE payload depends on. Trust in the sender is not trust in the bytes.

One control covers both patterns: where you control both ends of the contract, prefer JSON, which has no entities, no DTD and no external-reference feature to abuse. Keep XML for the cases you cannot avoid β€” inbound SOAP, fixed-format partner feeds, office and SVG uploads β€” and route each one through one of the fixes above.


The Analyst's View

For a CySA+ analyst, XXE is the cleanest example in this series of why a green SAST run is not a safety certificate. The scanners do ship XXE rules β€” Bandit's B313–B319 flag xml.etree, sax, expatbuilder, minidom and pulldom and tell you to install defusedxml. But those rules are name-based and target the standard library, and modern stdlib parsers no longer resolve external entities by default. The parser that performs the file read in the lab is lxml with resolve_entities=True, and Bandit's lxml checks (B320 for the calls, B410 for the import) have been removed β€” so a codebase whose XXE surface runs through lxml scans clean. An analyst who reads "0 findings" on XML-parsing code as "no XXE" has mistaken the tool's coverage for the attack surface.

The second analyst point is the response. An XXE finding is incident-response-shaped: assume the read succeeded and rotate whatever the reachable files exposed, from the SECRET_KEY to database credentials and the API keys in a .env. That is a threat model a scanner cannot hand you.


Catching It Automatically

Testing Your Defence

One test proves the fix: send the file-read payload to the secure view and check that it is refused and the flag never comes back. This is the one the lab uses:

XXE_FILE_READ = (
    '<?xml version="1.0"?>\n'
    f'<!DOCTYPE order [ <!ENTITY xxe SYSTEM "{FLAG_PATH.as_uri()}"> ]>\n'
    "<order><orderId>&xxe;</orderId></order>"
).encode()

def test_secure_view_rejects_external_entity(self):
    resp = self.client.post(
        "/xxe/secure/", data=XXE_FILE_READ, content_type="application/xml"
    )
    self.assertEqual(resp.status_code, 400)
    self.assertNotContains(resp, "FLAG{", status_code=400)

setUp writes the off-limits flag file the entity points at. Point the test at /xxe/vulnerable/ and it fails: the view answers 200 with the flag in orderId. A test that only parsed a benign document would pass on both views. The lab's tests.py adds a legitimate order that must still parse.

Scanning It

I expected Bandit to be the easy win here β€” XXE is a textbook class, and Bandit ships XML rules that name defusedxml in the remediation text. It found nothing on the lab's vulnerable view, and chasing down why is more useful than a green checkmark would have been.

Bandit (which walks the Python AST for B-numbered risky calls) does have XML checks β€” B405 and B313/B314 on xml.etree, B315–B319 and B406–B409 across sax, expatbuilder, minidom and pulldom β€” and they fire, recommending defusedxml. But they are name-based and target the standard library. The lab's vulnerable view parses with lxml, and Bandit's lxml checks β€” B320 and B410 β€” have been removed: ask for B410 explicitly and Bandit 1.9.4 answers Unknown test found in profile: B410. So Bandit reports zero on the lxml parse, on both views. The inversion is the lesson worth keeping: modern stdlib ElementTree no longer resolves external entities, so the parser Bandit flags is the one already hardened against the file read, while lxml β€” the parser that performs the XXE whenever resolve_entities=True is set β€” is the one it cannot see. (Bandit's own tracker records that the lxml guidance was unhelpful, because the defusedxml.lxml shim it pointed at was only ever an example and is now deprecated.)

Semgrep community (p/django, p/python, p/owasp-top-ten) reports zero on both views, and the full r/python registry (371 rules) is zero on the vulnerable view too. The registry carries an XXE rule (use-defused-xml-parse), but run directly it fires on neither the lxml view nor a stdlib fromstring probe.

So no off-the-shelf rule gives a fire/silent assert on the lxml XXE the lab actually needs. The companion lab ships a small custom Semgrep rule that flags an lxml parse not routed through defusedxml β€” the explicit resolve_entities=True footgun and a bare implicit-defaults parse β€” and stays silent on the defusedxml fix and a hardened parser= object:

semgrep scan --config rules/xxe.yaml labs/post_05_xxe/views_vulnerable.py   # 1 finding
semgrep scan --config rules/xxe.yaml labs/post_05_xxe/views_secure.py       # 0 findings

Its limits are real. It is name-based: it matches four calls on an etree receiver, so it misses etree.iterparse(...) and etree.ETCompatXMLParser(), the two entry points that resolved external entities by default until lxml 6.1.0, as well as lxml.etree.fromstring(...) written out in full and a fromstring or XMLParser imported directly from lxml.etree β€” all five are silent under Semgrep 1.170.0. It trusts any parser= object to be hardened. And it flags a bare etree.fromstring(x) that lxml 6.1.3 and later parse without loading any external entity, as fragile by design. It is also source-agnostic: it cannot prove the bytes are untrusted, and it does not use the taint mode Semgrep's open-source engine offers. The captured runs and the reproduction are in the lab's scans/ directory.

XXE is a runtime class, so prove the fix dynamically as well: the lab README's curl walkthrough sends the same file-read payload to both views, and only the vulnerable one returns the flag.


The habit this post left me with: treat a bare etree.fromstring on request data the same way I'd treat cursor.execute(raw_sql) β€” route every untrusted parse through defusedxml.

Series II opens the next front: Broken Access Control. Post 6 begins with IDOR β€” Insecure Direct Object Reference β€” where the app authenticates who you are but never checks what you are allowed to touch, and a single incremented ID in a URL hands one user another user's data. Injection was about data crossing into code; access control is about identity crossing a boundary it was never authorised to cross.

Further Reading

Next in this series β†’ Post 6: Broken Access Control and IDOR: When Logging In Is Not the Same as Being Allowed

← Back to the series