FlawOpen
● CWE-611 · OWASP A05:2021

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. With http:// 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=True and load_dtd=True reopen the hole, Java's DocumentBuilderFactory resolves external entities unless disallow-doctype-decl is set, and .NET needs DtdProcessing.Prohibit.
defusedxml
A Python package that wraps the standard parsers and raises EntitiesForbidden, ExternalReferenceForbidden or DTDForbidden instead of processing dangerous constructs. forbid_dtd=True rejects any document with a DOCTYPE.

Step-by-Step Attack Flow

Step 1

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.

Step 2

The parser processes the DTD

Because the application enabled DTD loading and entity resolution, the parser reads the referenced file or URL.

Step 3

The file's content becomes data

The text lands in the customer element, and the endpoint returns it in its JSON response.

Step 4

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

UNPATCHED FLAW
# 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")}
HARDENED SECURE PATCH
# 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

Sources

← Browse the full security directory All vulnerability guides →