XML Tutorial · XML in Code · Lesson 22 of 28
XML Security: XXE and Entity Attacks
XML external entity (XXE) attacks, billion-laughs entity expansion, SSRF through DTDs, and safe parser settings in Python, PHP, Java and .NET.
Why XML parsers are an attack surface
A DTD can declare entities, and an entity can refer to an external resource:
<!DOCTYPE order [ <!ENTITY secret SYSTEM "file:///etc/passwd"> ]>
<order><note>&secret;</note></order>
If the parser expands it, the file's contents end up in the document, and often in an error message or response shown to the attacker. Variants:
| Attack | What happens |
|---|---|
| XXE file disclosure | SYSTEM "file:///…" reads local files |
| SSRF | SYSTEM "http://169.254.169.254/…" makes the server call internal URLs (cloud metadata!) |
| Billion laughs | Nested entities expand a 1 KB document into gigabytes (denial of service) |
| Out-of-band XXE | Parameter entities send data to the attacker's server |
XXE was a separate category in the OWASP Top 10 (2017) and is still found in SOAP services, SAML, file uploads (DOCX, SVG) and RSS importers.
See it happen, safely
These examples create a harmless local file and parse a document that tries to read it. Each runtime's default settings are shown first.
Python with lxml: since lxml 5 the default parser expands only internal entities, so this document is rejected.
Turning resolve_entities on re-enables the attack:
from lxml import etree
with open("secret.txt", "w") as f:
f.write("db_password=hunter2")
attack = b"""<!DOCTYPE order [ <!ENTITY secret SYSTEM "secret.txt"> ]>
<order><note>&secret;</note></order>"""
def note(parser=None):
try:
return repr(etree.fromstring(attack, parser).findtext("note"))
except etree.XMLSyntaxError as e:
return f"rejected ({e.msg})"
print("default parser:", note())
print("resolve_entities=True:", note(etree.XMLParser(resolve_entities=True)))
print("hardened parser:", note(etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)))
Output
default parser: rejected (Entity 'secret' not defined, line 2, column 22) resolve_entities=True: 'db_password=hunter2' hardened parser: ''
PHP: libxml2 doesn't load external entities unless you pass LIBXML_NOENT (a dangerously misleading name: it
means "substitute entities"):
<?php
file_put_contents("/tmp/secret.txt", "db_password=hunter2");
$attack = '<!DOCTYPE order [ <!ENTITY secret SYSTEM "file:///tmp/secret.txt"> ]>
<order><note>&secret;</note></order>';
$doc = new DOMDocument();
$doc->loadXML($attack);
echo "default: '", $doc->getElementsByTagName("note")->item(0)->textContent, "'\n";
$doc = new DOMDocument();
$doc->loadXML($attack, LIBXML_NOENT);
echo "LIBXML_NOENT: '", $doc->getElementsByTagName("note")->item(0)->textContent, "'\n";
Output
default: '' LIBXML_NOENT: 'db_password=hunter2'
Java: DocumentBuilderFactory does resolve external entities by default. Disallow DTDs entirely:
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.nio.file.*;
public class Main {
static String parse(DocumentBuilderFactory factory, String xml) {
try {
return "'" + factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes("UTF-8")))
.getElementsByTagName("note").item(0).getTextContent() + "'";
} catch (Exception e) {
return "rejected: " + e.getMessage();
}
}
public static void main(String[] args) throws Exception {
Files.writeString(Path.of("secret.txt"), "db_password=hunter2");
String attack = """
<!DOCTYPE order [ <!ENTITY secret SYSTEM "secret.txt"> ]>
<order><note>&secret;</note></order>""";
System.out.println("default: " + parse(DocumentBuilderFactory.newInstance(), attack));
DocumentBuilderFactory safe = DocumentBuilderFactory.newInstance();
safe.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
safe.setXIncludeAware(false);
safe.setExpandEntityReferences(false);
System.out.println("hardened: " + parse(safe, attack));
}
}
Output
default: 'db_password=hunter2' hardened: rejected: DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.
Run this on your computer — see “Get Started” for setup.
Billion laughs: modern libxml2 detects runaway expansion and stops:
from lxml import etree
levels = ['<!ENTITY lol0 "lol">'] + [
f'<!ENTITY lol{i} "{("&lol" + str(i - 1) + ";") * 10}">' for i in range(1, 10)]
bomb = f"<!DOCTYPE bomb [{''.join(levels)}]><bomb>&lol9;</bomb>".encode()
print(f"document size: {len(bomb)} bytes, would expand to {3 * 10 ** 9:,} characters")
try:
etree.fromstring(bomb, etree.XMLParser(resolve_entities=True))
except etree.XMLSyntaxError as e:
print("stopped:", e.msg)
Output
document size: 750 bytes, would expand to 3,000,000,000 characters stopped: Maximum entity amplification factor exceeded, see xmlCtxtSetMaxAmplification., line 1, column 7
Safe settings checklist
| Language | Safe setup for untrusted XML |
|---|---|
| Python | defusedxml for the standard library; lxml with resolve_entities=False, no_network=True (the defaults since lxml 5 are safer) |
| PHP | Never pass LIBXML_NOENT or LIBXML_DTDLOAD for untrusted input |
| Java | factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true), plus FEATURE_SECURE_PROCESSING |
| .NET | XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit } (the default since .NET 4.5.2 for most APIs) |
| JavaScript | Browsers don't load external entities; in Node, disable entity processing in your library |
The simplest rule: if you don't need DTDs, reject any document that contains a <!DOCTYPE. See the
OWASP XXE Prevention Cheat Sheet.
Test yourself
What is the most robust defence against XXE when your application doesn't need DTDs?
Reject or disable DOCTYPE declarations entirelyEscape the input with htmlspecialcharsValidate against an XSD after parsingUse HTTPS
Show answer
Answer: Reject or disable DOCTYPE declarations entirely
Entities are declared in the DTD; disallowing DOCTYPE removes the attack at its source, before parsing.