This is a parser-feature lab, not an out-of-band exfil cookbook. Target is a 60-line Java main that builds a DocumentBuilder and a TransformerFactory, plus a 6-line XML file whose external entity points at a local lab file I created. Goal: print the JDK version, show the unhardened builder echoing that lab file, then show the hardened factory throwing (DOCTYPE is disallowed) or ignoring the entity. I do not ship an FTP/HTTP listener, I do not put expect:// or a parameter-entity OOB chain in the XML, I do not point the entity at /etc/passwd.

1Figure 1. A DTD entity is a fetch. Defense is "do not fetch", not "fetch but do not print".
2XML  ->  DocumentBuilder / Transformer  ->  file:// lab secret  OR  SAXParseException
3disallow-doctype-decl=true  =>  throw before resolve

Lab layout

1labs/xxe_lab/
2  secret.txt          # created here; not a real key
3  lab.xml             # file:// entity to secret.txt
4  XxeLab.java         # unsafe vs safe flags
1$ java -version
2openjdk version "11.0.22" 2024-01-16
3OpenJDK Runtime Environment (build 11.0.22+7-post-Ubuntu-0ubuntu2)
4OpenJDK 64-Bit Server VM (build 11.0.22+7-post-Ubuntu-0ubuntu2, mixed mode)
5
6$ printf 'LAB-XXE-SECRET-not-a-real-key\n' > /tmp/xxe_lab/secret.txt
7$ chmod 600 /tmp/xxe_lab/secret.txt

Tiny document. The entity URL is a path I own. No host, no FTP, no UNC.

1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE foo [
3  <!ENTITY xxe SYSTEM "file:///tmp/xxe_lab/secret.txt">
4]>
5<foo>&xxe;</foo>

Lab binary

 1/* XxeLab.java — local file entity only; no network */
 2import javax.xml.XMLConstants;
 3import javax.xml.parsers.DocumentBuilder;
 4import javax.xml.parsers.DocumentBuilderFactory;
 5import javax.xml.transform.TransformerFactory;
 6import org.w3c.dom.Document;
 7import org.xml.sax.InputSource;
 8import java.io.StringReader;
 9
10public class XxeLab {
11    static DocumentBuilderFactory unsafeFactory() throws Exception {
12        return DocumentBuilderFactory.newInstance(); /* defaults */
13    }
14
15    static DocumentBuilderFactory safeFactory() throws Exception {
16        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
17        dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
18        dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
19        dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
20        dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
21        dbf.setXIncludeAware(false);
22        dbf.setExpandEntityReferences(false);
23        dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
24        dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
25        dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
26        return dbf;
27    }
28
29    static void parse(DocumentBuilderFactory dbf, String xml) throws Exception {
30        DocumentBuilder db = dbf.newDocumentBuilder();
31        Document doc = db.parse(new InputSource(new StringReader(xml)));
32        System.out.println("TEXT " + doc.getDocumentElement().getTextContent());
33    }
34
35    public static void main(String[] args) throws Exception {
36        String xml = new String(java.nio.file.Files.readAllBytes(
37            java.nio.file.Paths.get("lab.xml")));
38        String mode = args.length > 0 ? args[0] : "safe";
39        try {
40            if (mode.equals("unsafe"))
41                parse(unsafeFactory(), xml);
42            else
43                parse(safeFactory(), xml);
44        } catch (Exception e) {
45            System.out.println("THROW " + e.getClass().getName());
46            System.out.println("MSG " + e.getMessage());
47            if (e.getCause() != null)
48                System.out.println("CAUSE " + e.getCause().getMessage());
49        }
50    }
51}
1javac XxeLab.java

Artifact: unhardened parse echoes the lab file

On this OpenJDK 11.0.22, DocumentBuilderFactory.newInstance() still expands a file:// general entity. That is the sink. The bytes are from /tmp/xxe_lab/secret.txt, a file I wrote.

1$ java XxeLab unsafe
2TEXT LAB-XXE-SECRET-not-a-real-key

That is in-band reflection: the parse tree contains the entity body, getTextContent() prints it. I stop here. I do not wrap the same entity in an FTP URL. I do not add a parameter-entity send to a listener.

What I refuse to put in this notebook: SYSTEM "ftp://...", SYSTEM "http://169.254.169.254/...", expect://, a working OOB DTD on a second host, a billion-laughs bomb sized to freeze the JVM.

Sanitized reproduction: hardened factory throws

1$ java XxeLab safe
2THROW org.xml.sax.SAXParseException
3MSG DOCTYPE is disallowed when the feature
4    http://apache.org/xml/features/disallow-doctype-decl set to true.

That is the ticket artifact. Input is lab.xml with a doctype; output is a throw before secret.txt is opened. Confirm with strace that the secret is not read on the safe path:

1$ strace -e openat -f java XxeLab safe 2>&1 | grep secret
2# no hits
3
4$ strace -e openat -f java XxeLab unsafe 2>&1 | grep secret
5openat(AT_FDCWD, "/tmp/xxe_lab/secret.txt", O_RDONLY) = 5

If a JDK already refuses DTDs by default, unsafe also throws. I still set the features explicitly. Defaults move; the explicit list does not.

Second path — doctype allowed, external general entities off — the parser ignores the entity instead of throwing. I keep this only for apps that must accept a doctype they author:

1static DocumentBuilderFactory ignoreExt() throws Exception {
2    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
3    dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
4    dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
5    dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
6    dbf.setExpandEntityReferences(false);
7    return dbf;
8}
1$ java XxeLab ignore
2TEXT
3# empty text node; entity not expanded. secret.txt not opened.
4THROW  (none)

Empty TEXT is “ignoring”. Prefer the throw (disallow-doctype-decl) when the app does not need DTDs.

TransformerFactory: the other factory people forget

XSL and some SOAP stacks go through TransformerFactory, not DocumentBuilder. Same class of fetch, different setters.

1static TransformerFactory safeTf() throws Exception {
2    TransformerFactory tf = TransformerFactory.newInstance();
3    tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
4    tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
5    tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
6    return tf;
7}

A stylesheet that tries to pull an external DTD or an external stylesheet with those attributes set to "":

1$ java TfLab lab.xsl
2THROW javax.xml.transform.TransformerConfigurationException
3MSG  Access to external DTDs has been denied due to restriction
4     set by the accessExternalDTD property.

FEATURE_SECURE_PROCESSING alone is not the whole fix on every JDK. I set ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_STYLESHEET to the empty string as well. ACCESS_EXTERNAL_SCHEMA on the builder is the schema twin.

StAX, for the same ticket:

1XMLInputFactory xf = XMLInputFactory.newFactory();
2xf.setProperty(XMLInputFactory.SUPPORT_DTD, false);
3xf.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
1$ java StaxLab lab.xml
2THROW javax.xml.stream.XMLStreamException
3MSG  DTD is not allowed

Three APIs, one rule: no untrusted external resolution.

Features I actually disable (checklist)

APIFeature / propertyLab value
DocumentBuilderFactorydisallow-doctype-decltrue
DocumentBuilderFactoryexternal-general-entitiesfalse
DocumentBuilderFactoryexternal-parameter-entitiesfalse
DocumentBuilderFactoryload-external-dtdfalse
DocumentBuilderFactoryXIncludeAware / ExpandEntityReferencesfalse
DocumentBuilderFactoryFEATURE_SECURE_PROCESSINGtrue
DocumentBuilderFactoryACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_SCHEMA""
TransformerFactoryFEATURE_SECURE_PROCESSINGtrue
TransformerFactoryACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_STYLESHEET""
XMLInputFactorySUPPORT_DTDfalse
XMLInputFactoryIS_SUPPORTING_EXTERNAL_ENTITIESfalse

Exact key strings vary by parser (Xerces in the JDK vs a bundled Xerces vs Woodstox). If setFeature throws ParserConfigurationException for an unknown key, I catch and fail closed (do not continue with a half-hardened factory).

Mitigation

  • Centralize XML construction in one helper that returns the safe factory. Ban DocumentBuilderFactory.newInstance() in application code via a checkstyle / Error Prone rule.
  • Do not “fix” XXE by stripping DOCTYPE with a regex. Encodings and UTF-16 BOMs exist. Disable the features.
  • Egress: app JVMs do not need FTP or arbitrary HTTP from the parser. Even with features off, I keep that ACL. I still do not test it with a working FTP entity.
  • Billion-laughs is availability, not exfil. Entity-expansion limits (jdk.xml.entityExpansionLimit) are a second control after DTDs are off.
  • SAML, SOAP, office-document extractors, and XML signatures are the real entry points. Grep those stacks for factory setup, not only *.xml upload handlers.
1$ grep -R 'DocumentBuilderFactory\|TransformerFactory\|XMLInputFactory' \
2    --include='*.java' .

Any hit without the feature list above is a finding. A wrapper that takes Factory from the caller is the same finding one frame up.

What I file after this lab

  • JDK: OpenJDK 11.0.22
  • java XxeLab unsafeTEXT LAB-XXE-SECRET-not-a-real-key (lab file we own)
  • java XxeLab safeSAXParseException DOCTYPE is disallowed
  • strace: secret.txt opened only on the unsafe path
  • TransformerFactory + empty ACCESS_EXTERNAL_DTDTransformerConfigurationException
  • Fix: feature table above, fail closed on unknown keys
  • Out of scope: FTP/HTTP OOB, parameter-entity exfil, cloud metadata URLs

Commands appendix

1printf 'LAB-XXE-SECRET-not-a-real-key\n' > /tmp/xxe_lab/secret.txt
2javac XxeLab.java
3java XxeLab unsafe
4java XxeLab safe
5strace -e openat -f java XxeLab safe 2>&1 | grep secret
6grep -R 'DocumentBuilderFactory' --include='*.java' .