Skip to content
Field Value
Platform PortSwigger Web Security Academy
Type Blind XXE — Local DTD Repurposing + Error-Based Exfiltration
Difficulty Expert
Objective Trigger a parser error containing the contents of /etc/passwd by redefining an entity in a local DTD

Exploiting XXE to Retrieve Data by Repurposing a Local DTD — Writeup


Initial Observation

Same stock check endpoint. Testing everything — text injection, general entities, parameter entities pointing at external URLs, error-based payloads — all fail. No external connections are allowed and there's no exploit server in scope this time.

That rules out the previous techniques. We need something that doesn't leave the server.


Understanding Local DTD Repurposing

The Problem with Previous Techniques

The error-based lab worked because we hosted a malicious external DTD on the exploit server. The reason external DTDs matter is a technical XML restriction: inside an internal DTD subset, you can't nest parameter entity references inside another entity's value. External DTDs lift that restriction — they're processed under different rules that allow the chaining we need.

When outbound connections are blocked, the exploit server trick is dead. But the rule says "external DTD" — not "external to the server." A DTD file that already exists on the server filesystem is external to our internal subset.

think of it like a library with a strict "no outside books" rule.

We can't bring our own book in. But we can find a book that's already on the shelf, tear out a page, and replace it with our own content. The library's own book becomes our delivery method

The Local DTD on This Server

The server runs GNOME and has a DTD at /usr/share/yelp/dtd/docbookx.dtd that declares a parameter entity called %ISOamso. That's the page to replace.

XML allows the internal subset to override entities declared in an external DTD — and as xml rule, the internal definition wins. So we redefine %ISOamso with our malicious chain before loading the local DTD. When the DTD processes and reaches its own %ISOamso declaration, it gets our version. Our chain runs in the external context, nesting rules lifted.

Payload

<!DOCTYPE teto [
  <!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; exfil SYSTEM &#x27;file:///tetodoesnotexists/&#x25;file;&#x27;>">
    &#x25;eval;
    &#x25;exfil;
  '>
  %local_dtd;
]>

Step by step:

%local_dtd; — loads the local DTD from the filesystem. The parser processes docbookx.dtd as part of the document's DTD, which is where %ISOamso normally lives.

%ISOamso — we've already redefined it above with our chain. When the local DTD hits its own %ISOamso declaration, our version takes over.

Inside %ISOamso — CHAOS This is where the payload looks unreadable. The reason is nesting depth: we're writing an entity value (level 1) that contains a declaration (level 2) that contains another declaration (level 3). Each level consumes one round of XML entity encoding.

Symbol needed Encoded as Why
% (level 1) &#x25; Can't write bare % inside an entity value
% (level 2) &#x26;#x25; Double-encoded — becomes &#x25; after level 1, then % after level 2
' &#x27; Single quote would close the outer %ISOamso value

Once all encoding resolves, the chain is identical to the error-based lab:

  1. %file reads /etc/passwd
  2. %eval declares %exfil with the file contents embedded in a nonexistent file path
  3. %exfil triggers a load that fails with a FileNotFoundException
  4. The error message contains the full path — which now includes the /etc/passwd contents
  5. Application surfaces the parser error → we read the file

Attack Path

Sending the full payload in Burp Repeater:

Screenshot

The parser error contains /etc/passwd, as usual, this will solve the lab 7.7

(Tbh, this lab does not make sense to me but ok) Kidding.

Resources