XML External Entity (XXE) Injection (CWE-611): How It Works and How to Prevent It
An XML parser that honours a document's DTD will fetch SYSTEM entities from file:// or http:// URLs and paste the result into the parsed data, so an uploaded document can read server files or make the server call internal addresses. How external entities and entity expansion work, which parser settings cause them, and how defusedxml with forbid_dtd=True shuts them off, with a Flask example.
Plain English Explainer (ELI5)
A print shop fills in form letters. A customer's letter says: 'Dear [insert the contents of drawer 4 of your filing cabinet here]'. A careful printer only inserts words the customer supplied. A careless printer walks to drawer 4, which holds other customers' contracts, and prints them into the letter it posts back. XXE is the careless printer: the XML document tells the parser to go and fetch a file or a web address and insert it, and the parser does. The fix is to refuse letters that contain any 'insert from elsewhere' instructions at all.
Core Concepts & Subsystem Terms
- DTD and entities
- A Document Type Definition in
<!DOCTYPE ...>can declare entities, named pieces of text that the parser substitutes wherever&name;appears. Internal entities hold literal text; external ones point somewhere else. - External entity
<!ENTITY x SYSTEM "file:///etc/hostname">tells the parser to read that URL and put its contents where&x;is used. Withhttp://URLs the server makes the request, which turns XXE into server-side request forgery.- Entity expansion (Billion Laughs)
- Entities that reference other entities ten times over expand a kilobyte document into gigabytes of text in memory (CWE-776). It needs no external access, so only rejecting DTDs or capping expansion stops it.
- Parser defaults
- Behaviour depends on the library: lxml 5 resolves only internal entities by default but
resolve_entities=Trueandload_dtd=Truereopen the hole, Java'sDocumentBuilderFactoryresolves external entities unlessdisallow-doctype-declis set, and .NET needsDtdProcessing.Prohibit. - defusedxml
- A Python package that wraps the standard parsers and raises
EntitiesForbidden,ExternalReferenceForbiddenorDTDForbiddeninstead of processing dangerous constructs.forbid_dtd=Truerejects any document with a DOCTYPE.
Step-by-Step Attack Flow
The attacker uploads an XML document
It declares an external entity and references it in a field the server echoes back. An entity pointing to file:///etc/hostname illustrates the read case.
The parser processes the DTD
Because the application enabled DTD loading and entity resolution, the parser reads the referenced file or URL.
The file's content becomes data
The text lands in the customer element, and the endpoint returns it in its JSON response.
The attacker reads files or reaches internal hosts
Configuration files, keys and cloud metadata can be read this way. In 2014 Reginaldo Silva received a 33,500 USD bounty from Facebook for an XXE in its OpenID handling that could read files on its servers.
Source Code: Flaw vs. Secure Implementation
# invoices.py: the parser follows whatever the uploaded DTD asks for
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
# entities resolved, DTDs loaded and network access allowed
@app.post("/invoices/import")
def import_invoice():
parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)
root = etree.fromstring(request.get_data(), parser)
return {"customer": root.findtext("customer"), "total": root.findtext("total")}
# invoices.py: documents with a DTD are rejected before parsing
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from flask import Flask, abort, request
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1_000_000
# no DOCTYPE, no entities, no external references; body capped at 1 MB
@app.post("/invoices/import")
def import_invoice():
try:
root = fromstring(request.get_data(), forbid_dtd=True)
except (DefusedXmlException, ParseError):
abort(400)
return {"customer": root.findtext("customer"), "total": root.findtext("total")}
Engineering & System Hardening Checklist
- Parse untrusted XML with
defusedxmlin Python, or with DTDs disabled elsewhere: Javadisallow-doctype-declset to true, .NETDtdProcessing.Prohibit, libxml2 withoutXML_PARSE_NOENTandXML_PARSE_DTDLOAD. - Never pass
resolve_entities=True,load_dtd=Trueorno_network=Falseto lxml for input you do not control, and run Bandit (B320 flagslxml.etree) in CI. - Cap the request size (
MAX_CONTENT_LENGTH) and reject any document that contains a DOCTYPE. - Treat uploaded SVG, SAML, DOCX and XLSX files as XML and parse them with the same hardened settings.
- Add a CI test that posts a document with an external entity pointing to
file:///etc/hostnameand expects 400.