Skip to main content
All articles

The document that reads your filesystem: XXE in docx, xlsx and SVG parsers

A docx, xlsx or SVG is a bundle of XML, and an XML parser that resolves external entities will fetch files and URLs on the attacker's behalf. CVE-2019-12415 in Apache POI is the office-document case. How XXE reaches an upload endpoint, how to find it, and how to turn it off.

By Published

9 min read2 views

Office documents are not opaque binaries. A .docx, .xlsx or .pptx is a ZIP archive full of XML, and an .svg is XML on its own. Anywhere your application accepts one of these and parses the XML inside, an XML parser is running on attacker-supplied input. If that parser resolves external entities, the attacker has handed you a file that reads your filesystem and reaches out to your internal network when you open it.

That is XML External Entity injection. The document is the delivery vehicle; the vulnerability is in how the XML is parsed.

The bug in one distinction

XML lets a document define entities, which are named substitutions. An internal entity is just a shorthand string. An external entity points somewhere else with a SYSTEM identifier: a local path like file:///etc/passwd, or a URL. When a parser that resolves external entities meets a reference to one, it fetches the target and splices the contents into the document before your code ever sees the result.

So the one distinction: a safe parser treats an external entity declaration as inert text, or refuses the DOCTYPE that declares it; a vulnerable parser follows the pointer and fetches what it points at. Same document, same bytes, entirely different behaviour, decided by one parser setting.

Why it varies

What XXE gets you depends on where the parser can reach and whether the result comes back to you.

  • File disclosure. The external entity points at a local file, the parser reads it, and the contents surface somewhere in the parsed output or an error message. Configuration files, keys and /etc/passwd are the usual targets.
  • Server-side request forgery. The entity points at a URL, and the parser makes the request from inside your network. Now the document reaches internal services, cloud metadata endpoints and anything else the server can talk to but the attacker cannot. This is why XXE and SSRF are close relatives: an entity resolver is an SSRF primitive that happens to live in an XML library.
  • Denial of service. Nested internal entities that expand exponentially, the "billion laughs" pattern, exhaust memory and CPU without ever touching the network.
  • Blind variants. Even when the parsed result never comes back to the attacker, the outbound fetch itself, or an error triggered by it, can carry data out or confirm an internal host exists.

The declaration is the same each time. The impact is set by the parser's reach and by whether output or errors leak back, which is why the same XXE finding can be rated anywhere from medium to critical depending on the environment.

The real CVE

CVE-2019-12415 is the office-document case stated plainly. NVD published it on 2019-10-23 with a CVSS 3.1 base score of 5.5 and classified it as CWE-611, improper restriction of XML external entity reference. In Apache POI up to 4.1.0, the XSSFExportToXml tool, used to convert a user-provided Excel workbook to XML, would process a specially crafted document in a way that let an attacker read local files or reach internal network resources through XXE. POI fixed it in 4.1.1, released 2019-10-20.

The detail that makes it a good teaching case is that POI is a library for reading the very documents attackers control. The vulnerable path was a conversion utility handed a workbook, which is precisely the kind of untrusted input an upload endpoint feeds a document library all day. Nothing about the workbook looked hostile at the ZIP or spreadsheet level; the payload was in the XML the library parsed on the application's behalf.

The same weakness class recurs across the document ecosystem, which is the point rather than a coincidence. CVE-2019-12331 covers XXE in PHPOffice's PhpSpreadsheet before 1.8.0, where a UTF-7 double-encoding trick slipped a payload past an earlier filter. CVE-2015-0250 covers XXE in Apache Batik's SVG-to-image conversion before 1.8, reading arbitrary files from a crafted SVG. Three languages, three document types, one weakness: a parser resolving external entities on untrusted input.

A lab you can build

No Docker. A language runtime and a text editor are enough, and the safest lab is one that reaches only your own machine.

  • Write a small SVG or a minimal XML file whose DOCTYPE declares an external entity pointing at a file you created for the test in a temp directory, not a real system file. Reference that entity once in the body.
  • Parse it with a default parser configuration in the language you care about: Java's DocumentBuilderFactory, Python's lxml or a defusedxml-free stdlib parser, PHP's libxml, or a document library like Apache POI, PhpSpreadsheet or a docx reader.
  • Print the parsed result and see whether your planted file's contents appear. Then apply the hardened parser configuration from the fix section below and parse the identical file again.

The finding is the contrast: the default parser inlines your test file's contents, the hardened parser does not. Point the entity at a file you own and at a local listener you control (a one-line python3 -m http.server on loopback) so you can watch the fetch happen without touching anything sensitive or leaving the machine. Reproducing "the parser fetched what the document told it to" is the whole lesson, and it is enough to prove a fix.

Finding it in source

XXE is a parser-configuration bug, so review turns on how parsers are constructed, not on the documents.

  • Find every XML parse of untrusted input. Grep for DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, XMLReader in Java; lxml.etree, xml.sax, xml.dom.minidom, pulldom in Python; simplexml_load, DOMDocument, libxml in PHP; and any XML deserialization. Then check which of those read files or bodies a user supplied.
  • Follow the document libraries. Uploads of .docx, .xlsx, .pptx and .svg almost always land in a parsing library. Note the library and version and check it against known XXE CVEs, POI below 4.1.1 and PhpSpreadsheet below 1.8.0 among them.
  • Look at the parser settings, or their absence. The vulnerable pattern is a parser built with defaults and no hardening. In Java, the absence of disallow-doctype-decl is the tell. In Python, a stdlib parser used where defusedxml should be, or an lxml parser constructed with resolve_entities=True.
  • Check SVG paths specifically. Image pipelines that accept SVG, thumbnail it, or rasterise it are easy to overlook because they feel like image handling, not XML parsing. Batik's CVE-2015-0250 lived exactly there.

Fixing it

The durable fix is one sentence: turn off DOCTYPE and external entity resolution on every parser that touches untrusted input. The specifics per stack:

Java. On DocumentBuilderFactory and friends, set the feature http://apache.org/xml/features/disallow-doctype-decl to true, which rejects any document with a DOCTYPE outright and is the strongest single setting. Where a DOCTYPE must be allowed, also set http://xml.org/sax/features/external-general-entities and http://xml.org/sax/features/external-parameter-entities to false, and http://apache.org/xml/features/nonvalidating/load-external-dtd to false. Note that setExpandEntityReferences does not control fetching, so it is not a fix on its own.

Python. Use the defusedxml package in place of the standard-library parsers, since the stdlib modules do not fully defend against these attacks by themselves. With lxml, note that version 5.0.0 changed the default so external entities are no longer expanded (resolve_entities='internal'), and later releases tightened external parameter-entity handling further, but do not rely on library defaults across versions: construct parsers with resolve_entities=False, no_network=True and DTD loading off, explicitly.

PHP. Keep libxml current and avoid the older global libxml_disable_entity_loader dance by not loading external entities at all; on modern PHP, do not enable LIBXML_NOENT, which is the flag that turns entity substitution on.

Everywhere. Patch the document libraries: POI 4.1.1, PhpSpreadsheet 1.8.0, Batik 1.8 and the equivalents for your stack. Parse uploads with a hardened parser regardless of the library's own defaults, and where the application only ever needs the data, not a document type system, prefer a format or parser with no entity concept at all.

Detecting it

XXE is a parser doing file and network I/O it was never meant to do, so the signals are that I/O and the document that requested it.

  • Outbound connections from document-processing hosts. A worker that parses uploads needs to read the upload and write a result, little else. Log its egress at the host firewall or egress proxy, baseline the few destinations it legitimately reaches, and alert on a connection to an internal address, to 169.254.169.254, or to an arbitrary internet host during a parse. That connection is the entity being resolved.
  • DNS queries from the same hosts. Blind XXE that cannot return output often carries data out in a hostname. A resolver log showing a document worker looking up names it never has before, especially long or random-looking ones, is the out-of-band channel.
  • Entity declarations in uploads. Office and drawing tools never emit <!ENTITY ... SYSTEM>. Unzip .docx, .xlsx and .pptx uploads, scan the XML parts and raw SVGs for <!ENTITY, and quarantine the file with a log line before any parser sees it. Editor-made SVGs may carry a PUBLIC W3C DOCTYPE; the SYSTEM entity is the tell.
  • The hardened parser's own refusals. After the fix, Xerces rejects a DOCTYPE with an exception naming the disallow-doctype-decl feature, and defusedxml raises DTDForbidden, EntitiesForbidden or ExternalReferenceForbidden. Log those as security events, not parse failures: each is an attempt the fix stopped.
  • Parse cost. A document that takes far more time or memory to parse than its size warrants is the entity-expansion variant. Cap both and alert when the cap trips.

Take this away

An office document or an SVG is XML, and XML parsers will, by default in too many stacks, fetch whatever an external entity points at. CVE-2019-12415 is the clean example: a library whose entire job is reading untrusted documents resolved external entities in a conversion path, and a crafted workbook became a file-read and an SSRF. The document was never the vulnerability. The parser configuration was.

So treat every upload of a document or image as XML until proven otherwise, and audit the parser that opens it rather than the file. One setting, disallow the DOCTYPE, closes the whole class for the parsers that support it. The rest is making sure no parser on an untrusted path was built with defaults.

Further reading

Was this useful?

Share

Tags

  • web security
  • cve analysis
  • secure code review
  • ssrf

Get the next write-up by email

New technical write-ups by email when we publish. No sales sequence, and every email has an unsubscribe link.

Prefer a feed? RSS. How we handle your address: privacy notice.

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Cloud Penetration Testing (AWS)

Identity, storage and workload misconfiguration testing across your AWS accounts.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.