PlatformPortSwigger Web Security Academy
TopicXML external entity (XXE) injection
LabExploiting XXE using external entities to retrieve files
DifficultyApprentice
GoalRead /etc/passwd from the server
ToolsBurp Suite (Proxy + Repeater)

What is XXE?#

XML external entity (XXE) injection happens when an application parses XML we control, and the parser (the library that reads the XML and turns it into data the app can use) is allowed to process external entities. An entity in XML is a named placeholder, declared in a DTD (Document Type Definition) and referenced in the document; an external entity takes its value from a URI the parser fetches. If we can declare our own entity that points at a local file, the parser reads that file and we can often get the contents reflected back.

To solve this lab we point an external entity at /etc/passwd and have the app echo it to us.

The target feature#

The shop has a product page with a Check stock feature. Open a product:

The lab shop with View details buttons
Open any product with View details.
The Com-Tool product page with a store dropdown and Check stock button
The Check stock feature: pick a store and submit.

Used normally, it returns a plain unit count:

A normal stock check returning 531 units
A normal check returns a number of units (here 531).

Finding the injection point#

Intercept the request in Burp. Check stock fires a POST /product/stock whose body is XML, not form data:

Burp showing the POST /product/stock request with an XML stockCheck body and a 531 response
The request body is XML (stockCheck / productId / storeId), parsed server-side: a prime XXE candidate.
<?xml version="1.0" encoding="UTF-8"?>
<stockCheck>
    <productId>1</productId>
    <storeId>1</storeId>
</stockCheck>

That first line, <?xml version="1.0" encoding="UTF-8"?>, is the XML declaration: it just states the XML version and character encoding for the parser. Any endpoint that parses XML we supply is worth testing for XXE. The next step is to declare our own entity and see whether the parser resolves it.

Exploitation: reading /etc/passwd#

Add a DOCTYPE that declares an external entity, then reference that entity where its value will be reflected. The productId value is echoed back in the app’s “Invalid product ID” error, so that is where we place the entity:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE test [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<stockCheck>
    <productId>&xxe;</productId>
    <storeId>1</storeId>
</stockCheck>

Piece by piece:

  • <!DOCTYPE test [ ... ]> adds an inline DTD, the section of an XML document where entities are declared. It has to come before the root element (<stockCheck>), because the document type declaration is only valid in the prolog, before the single root element opens.
  • <!ENTITY xxe SYSTEM "file:///etc/passwd"> declares an external entity named xxe. SYSTEM plus a file:// URI tells the parser to load the entity’s value from that local file. Which URI schemes work (file://, http://, php://, and so on) depends on the parser and the platform it runs on.
  • &xxe; references the entity. When the parser expands it, it substitutes the contents of /etc/passwd in that spot.
  • Because &xxe; sits inside <productId>, the file contents land where a product ID is expected, and the app reflects them straight back in its error message.

Send it, and the response leaks the file:

The XXE payload in Repeater and the response leaking /etc/passwd in the Invalid product ID error
The response: ‘Invalid product ID: root:x:0:0:…’ — /etc/passwd is read and echoed back. Lab solved.

The 400 Bad Request complains it’s an “Invalid product ID,” but the ID it’s complaining about is the full contents of /etc/passwd. Compared with the normal check earlier, which returned a plain unit count, seeing the file where that number used to be proves the content came from our entity, not from the app. Arbitrary file-read confirmed, and the lab is solved.

Real-world impact#

Reading /etc/passwd is a safe stand-in; the underlying primitive (the raw capability this bug gives us) is arbitrary file-read as the application user. In a real, authorized engagement that reads whatever the app can:

  • Secrets and source — .env files, framework config, database credentials, cloud tokens, and SSH private keys like ~/.ssh/id_rsa.
  • A pivot to SSRF — SSRF (server-side request forgery, making the server send requests to targets you choose) follows by swapping the file:// URI for http://, so the parser fetches URLs for you, reaching internal-only services and cloud metadata (see the XXE-to-SSRF lab ).
  • Denial of service and sometimes RCE — a small document can use recursive entity expansion (the “billion laughs” attack, where nested entities blow up to gigabytes and exhaust memory), and on some stacks XXE chains all the way to RCE (remote code execution).

Only ever against systems you are authorized to test.

Remediation#

  • Disable DTDs and external entities in the parser. This is the single most effective fix; almost no application legitimately needs them.
  • Prefer simpler formats. If the endpoint doesn’t need XML, accept JSON instead and drop the XML parser entirely.

A concrete example, parser-specific (here Java’s built-in DocumentBuilderFactory), turning off document-type declarations, which defeats entity-based XXE outright:

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

The exact API differs by language and library, but the idea is the same: tell the parser not to process DTDs or external entities. If DTDs can’t be disabled, at minimum disable external general and parameter entities (external-general-entities and external-parameter-entities).

Key takeaways#

  • Any endpoint that parses XML you supply is an XXE candidate; test it by declaring an entity and seeing whether it resolves.
  • Point the entity reference at a field that is reflected in the response, so the file contents come back to you.
  • SYSTEM "file:///..." reads local files; swapping it for http:// turns the same bug into SSRF.