XML external entity processing can read local files or reach hosts the attacker cannot, all inside a message the server parses. Configure the parser to deny it.
Entities are a parser feature
XML entities are a parser feature that lets a document refer to other content, and the same mechanism is what turns a small request into an expansion bomb if left unguarded. A document can define an entity that points at a local file, a network URL, or another entity, and when the parser resolves it, it reads the target and inserts its content into the document structure. That convenience is also the blast radius: a parser that is allowed to follow such references will happily read /etc/passwd over the wire, or loop forever expanding nested entities.
External pointers leak
External pointers leak because the parser follows them on your behalf. A typical XXE attack sends an entity declaration that references a file path or a URL, and a vulnerable server reads the file or contacts the address and reflects part of it back in the response, which is how an internal file or an intranet host becomes visible to someone who only controls a tiny XML payload. The damage depends on what the parser is allowed to access, so the leak is bounded only by the privileges of the process doing the parsing, not by the size of the request.
Turn the feature off
Turning the feature off is the simplest and strongest fix: disable external general entities and external parameter entities, decide whether the parser may access the network at all, and avoid parsers that resolve them by default. Where entities are unavoidable, validate the document against a schema and run the parser with the least privilege that still does the job. Treating XXE as a configurable feature rather than a fixed language law is what keeps a legacy-data exchange safe, because the interface that reads simple XML should never also be a path that reaches the disk or a neighboring intranet host.
When it breaks
A file-read payload is the classic breakage: a document declares <!ENTITY xxe SYSTEM "file:///etc/passwd"> and the application echoes &xxe; inside an error message or a preview field. The request stays tiny while the parser performs the privileged read, so access logs look innocent and the leak appears only in the response body. The damage follows the parser process, not the attacker, so a secret readable by the service account becomes a remote readout.
The same shape turns into server-side request forgery when the system identifier is a URL. A value such as http://169.254.169.254/latest/meta-data/ targets cloud credentials, while an intranet endpoint answers a request no outsider could reach directly. Each retry fans out from the server, so XXE amplifies SSRF: one short string triggers a server-side fetch.
Then there is the billion laughs bomb, which needs no network. A handful of nested entities, each expanding tenfold, turns a kilobyte of XML into gigabytes of text and exhausts memory before any schema check runs. A related surprise is SVG upload: browsers treat SVG as XML, so an avatar intake carrying a DOCTYPE with an external entity smuggles the same feature through the image pipeline. Defenses fail unevenly when one intake disables document types but feeds, SAML assertions, or the thumbnailer that re-parses the same file still resolve them.
Check it quickly
Save the following as xxe_probe.py in a scratch directory and run python3 xxe_probe.py. It uses only the Python standard library, touches no network or disk, and compares a permissive baseline against a hardened entry point. The baseline shows an internal-entity bomb expanding to one thousand characters, while the hardened path refuses every document type that declares entities.
"""XXE guard demo: stdlib only, no network, no disk reads."""
import io
import re
import xml.sax
import xml.sax.handler
BENIGN = '<?xml version="1.0"?><note><to>ops</to><body>hello</body></note>'
FILE_READ = ('<?xml version="1.0"?><!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'
'<r>&xxe;</r>')
SSRF = ('<?xml version="1.0"?><!DOCTYPE r [<!ENTITY xxe SYSTEM '
'"http://169.254.169.254/latest/meta-data/">]><r>&xxe;</r>')
BILLION_LAUGHS = ('<?xml version="1.0"?><!DOCTYPE l [<!ENTITY a "0123456789">'
'<!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">'
'<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">]>'
'<l>&c;</l>')
SVG_UPLOAD = ('<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/hostname">]>'
'<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>')
class _Text(xml.sax.handler.ContentHandler):
def __init__(self):
self.chunks = []
def characters(self, content):
self.chunks.append(content)
class _RejectResolver(xml.sax.handler.EntityResolver):
def resolveEntity(self, publicId, systemId):
raise xml.sax.SAXException(f"blocked external entity: {systemId!r}")
def naive_parse(xml_text):
"""Permissive baseline: plain stdlib parse with no guard."""
parser = xml.sax.make_parser()
handler = _Text()
parser.setContentHandler(handler)
parser.parse(io.StringIO(xml_text))
return "".join(handler.chunks)
def safe_parse(xml_text):
"""Hardened entry point: refuse entity machinery, then lock the parser."""
if re.search(r"<!DOCTYPE|<!ENTITY", xml_text, re.IGNORECASE):
raise ValueError("rejected: DOCTYPE/entity declarations are not accepted")
parser = xml.sax.make_parser()
parser.setFeature(xml.sax.handler.feature_external_ges, False)
try:
parser.setFeature(xml.sax.handler.feature_external_pes, False)
except xml.sax.SAXNotSupportedException:
pass # expat never fetches parameter entities; keep going
parser.setEntityResolver(_RejectResolver())
handler = _Text()
parser.setContentHandler(handler)
parser.parse(io.StringIO(xml_text))
return "".join(handler.chunks)
def check_rejected(name, payload):
try:
safe_parse(payload)
except ValueError as exc:
print(f"blocked {name}: {exc}")
return
raise AssertionError(f"safe_parse accepted {name}; expected ValueError")
def main():
text = safe_parse(BENIGN)
assert "hello" in text, f"benign parse lost content: {text!r}"
print(f"benign ok: {text.strip()!r}")
expanded = naive_parse(BILLION_LAUGHS)
assert len(expanded) >= 1000, f"baseline did not expand: {len(expanded)} chars"
print(f"baseline expands billion-laughs to {len(expanded)} chars (unsafe)")
for name, payload in [("file-read", FILE_READ), ("ssrf", SSRF),
("billion-laughs", BILLION_LAUGHS),
("svg-upload", SVG_UPLOAD)]:
check_rejected(name, payload)
print("PASS: 1 benign accepted, 1 baseline expansion shown, 4 malicious inputs rejected")
if __name__ == "__main__":
main()
A successful run prints the benign text, reports the baseline expansion as unsafe, lists four blocked inputs for file read, SSRF, billion laughs, and SVG upload, and ends with a PASS line. The hardened function rejects any DOCTYPE or ENTITY declaration before parsing, then disables external entities and installs a resolver that raises on any residual reference. The assertions passed with Python 3.14 on macOS. The demo covers parser configuration, not allow-listing or the upload pipeline that must also reject document types.
Further reading
Sources
- OWASP: XXE, the attack description, checked 2027-10-16.
- OWASP XXE Prevention Cheat Sheet, how to disable external entities, checked 2027-10-16.
- CWE-611: XXE, the weakness definition and mitigations, checked 2027-10-16.
- W3C: XML 1.0, the entity and document type specification, checked 2027-10-16.