What an XML External Entity Attack Is and Where It Works
An XXE attack makes a parser read a file or fetch a URL the sender names. Run on five parsers, three leaked a local file by default or with one flag.

An XML document can declare its own shorthand. A DOCTYPE at the top may define an entity, a name that stands for some text, and the body can then use the name. One kind of entity does not carry its text with it: it points to somewhere else, a file or a URL. A parser that follows the pointer pastes the target's content into the document. If the document came from someone you do not trust and your server parsed it, you have just let that person choose a file for your server to read. That is an XML external entity attack, XXE for short, and it is old, well documented and still found in new code.
Three lines that read a file
<?xml version="1.0"?>
<!DOCTYPE d [<!ENTITY x SYSTEM "file:///etc/hostname">]>
<d><v>&x;</v></d>Line two declares an entity named x whose value is whatever sits at file:///etc/hostname. Line three uses it inside <v>. A parser that resolves external entities reads the file and puts its text in the element. Nothing about the document looks hostile; it is a few characters of well-formed XML. The harm depends on what the application does with the parsed value. If it echoes the value back in a response or an error message, the attacker reads the file. If it does not, the same mechanism can still make your server request a URL (reaching internal services the attacker cannot reach, or a metadata endpoint of a cloud host), or hold a connection open, which is why the family of attacks is called server-side request forgery when it uses the network.
What five parsers did
I parsed that file with each, with the library defaults first:
| Parser | Default | With the "resolve" option |
|---|---|---|
Java 21 DocumentBuilderFactory | returned the file content | n/a |
Python lxml 6.1.3 | refused: Entity 'x' not defined | resolve_entities=True returned the file content |
Python xml.etree.ElementTree | refused: undefined entity &x; | no resolve switch |
PHP 8.5 simplexml_load_string | returned an empty string | LIBXML_NOENT returned the file content |
Node fast-xml-parser 5 | refused: External entities are not supported | n/a |
Node xml2js | refused: Invalid character entity | n/a |
The Java line is the one that should worry you. With DocumentBuilderFactory.newInstance() and no configuration, it printed the container's host name, the content of the file the entity named. Java parsers resolved external entities by default for a long time, and the default has not been safe for most of the JAXP API's history, which is why nearly every Java XXE advisory says "set these features". Details and the exact settings are in disabling DTDs and external entities in Java and Python.
The Python results show how modern defaults help and how one option undoes them. lxml has refused external entities by default for several releases, and the output above shows the refusal message. Changing resolve_entities from its default to True reads the file:
from lxml import etree
data = open("xxe.xml", "rb").read()
etree.fromstring(data) # XMLSyntaxError: Entity 'x' not defined
etree.fromstring(data, etree.XMLParser(resolve_entities=True)) # reads /etc/hostnamelxml default XMLSyntaxError: Entity 'x' not defined, line 3, column 10
lxml resolve_entities=True '4cc1864fc1ac\n'
lxml resolve_entities='internal' XMLSyntaxError: Entity 'x' not defined, line 3, column 10
lxml resolve_entities=False ''The third row is the safe middle: 'internal' expands entities defined inside the document and still refuses external ones, which is the default. Code that sets resolve_entities=True "to make entities work" has switched the protection off. PHP shows the same trap in a flag with a misleading name:
simplexml_load_string($xml) string(0) ""
simplexml_load_string($xml, ..., LIBXML_NOENT) string(13) "e330752a6765\n"LIBXML_NOENT reads as "no entities" and means "substitute entities", which includes the external ones. Code copied from an old answer that adds it to make a parse work has opened the hole. The PHP and Node details are in safe XML handling in PHP and Node.
The defusedxml approach
The defusedxml package takes the opposite stance: it replaces the standard parsers with wrappers that raise an error when the document contains an entity declaration at all.
defusedxml.ElementTree EntitiesForbidden: EntitiesForbidden(name='x', system_id='file:///etc/hostname', ...)
defusedxml.lxml EntitiesForbidden: EntitiesForbidden(name='x', system_id=None, public_id=None)The exception names the entity and its target, so a log line can say exactly what was attempted, which is the signal you want to raise an alert on. (The defusedxml.lxml module printed a deprecation warning in 0.7.1 saying it is no longer supported; use the plain lxml options above, or defusedxml.ElementTree, for new code.)
Variations of the attack
The file read is the demonstration, and there are others that use the same mechanism.
Server-side request forgery. The entity's system identifier is a URL: http://169.254.169.254/... or an internal host. The parser makes the request from the server's network position.
Blind and out-of-band. If the value is never echoed, the attacker uses parameter entities to send the file content to a server they control inside a URL, or causes an error message that includes it.
Denial of service. Entities that expand into gigabytes (stopping the billion laughs attack in your parser), or a SYSTEM entity that points at a device that never ends, such as a stream of random bytes.
Through other formats. The XML does not have to be the obvious request body. An SVG upload, a DOCX or XLSX file (which are zips of XML), a SAML message, an RSS feed your server fetches and a SOAP request all reach an XML parser. Many real XXE reports involve the feature nobody remembered used XML.
Why parsers do this
The behavior is not a bug. External entities are in the XML specification, and they were designed for assembling large documents from files, a use that predates the web as we use it. The specification lets a processor choose not to read them, and modern libraries increasingly do. The practical conclusion is simple: for data exchange, the feature is never needed, so turn it off everywhere, and do not rely on a default that depends on the library and its version.
What to do
Three rules cover it, and the other articles in this cluster apply them per language.
Disable DTDs and external entities in the parser configuration, explicitly, even where the default is already safe. A default can change between versions, and the explicit setting documents the intent.
Reject what you do not need. If the application never needs a
DOCTYPE, refuse documents that have one. SOAP forbids them outright.Test it. Keep the three-line file in your test suite, run it through the parsing path of each service, and assert that the parse fails and nothing is read.
Related reading
For the overview, see XML security problems to close before production. The per-language settings are in disabling DTDs and external entities in Java and Python and safe XML handling in PHP and Node, and the related denial of service in stopping the billion laughs attack in your parser. The OWASP guidance is the XML External Entity Prevention Cheat Sheet, and the weakness is listed as CWE-611. For a safe parse of an unknown file, see safe XML parsing in Python with defusedxml.
When the parser is not the problem
You never parse untrusted XML. An internal tool that reads only files the team wrote has a small exposure, though a file can arrive from a download or a dependency. Close it anyway; it costs a line.
The input is another format. JSON and CSV have no entity mechanism. The risk moves to other places, such as injection into queries or paths.
The output is the leak. If the parsed value is never shown or sent anywhere and the process cannot reach anything sensitive, the damage from a successful read is limited. That is a reason to isolate the process, not to leave the parser open.
The library is current but the code is old. An explicit resolve_entities=True or LIBXML_NOENT left from an old fix overrides a safe default. Search your codebase for them.


