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)
| API | Feature / property | Lab value |
|---|---|---|
DocumentBuilderFactory | disallow-doctype-decl | true |
DocumentBuilderFactory | external-general-entities | false |
DocumentBuilderFactory | external-parameter-entities | false |
DocumentBuilderFactory | load-external-dtd | false |
DocumentBuilderFactory | XIncludeAware / ExpandEntityReferences | false |
DocumentBuilderFactory | FEATURE_SECURE_PROCESSING | true |
DocumentBuilderFactory | ACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_SCHEMA | "" |
TransformerFactory | FEATURE_SECURE_PROCESSING | true |
TransformerFactory | ACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_STYLESHEET | "" |
XMLInputFactory | SUPPORT_DTD | false |
XMLInputFactory | IS_SUPPORTING_EXTERNAL_ENTITIES | false |
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
DOCTYPEwith 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
*.xmlupload 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 unsafe→TEXT LAB-XXE-SECRET-not-a-real-key(lab file we own)java XxeLab safe→SAXParseExceptionDOCTYPE is disallowed- strace:
secret.txtopened only on the unsafe path TransformerFactory+ emptyACCESS_EXTERNAL_DTD→TransformerConfigurationException- 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' .