Back

XXE Examples: Vulnerable vs. Secure

How to Read These Examples

Each pair shows the same feature—an endpoint that parses a user-supplied XML document—first as it is commonly written (vulnerable), then hardened. The attack payload is identical in every case:

<?xml version="1.0"?>
<!DOCTYPE data [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>

Against the vulnerable code, the response echoes the contents of /etc/passwd. Against the secure code, the parser either throws on the DOCTYPE or leaves &xxe; unresolved—no file is read. Notice how small the diff is: XXE prevention is almost always a configuration change, not a rewrite.

Java (JAXP DocumentBuilderFactory)

Vulnerable

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;

public String parseUser(InputStream xmlInput) throws Exception {
    // VULNERABLE: default factory resolves DTDs and external entities.
    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
    DocumentBuilder builder = dbf.newDocumentBuilder();
    Document doc = builder.parse(xmlInput);      // &xxe; is expanded here
    return doc.getDocumentElement().getTextContent();  // leaks the file
}

Secure

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;

public String parseUser(InputStream xmlInput) throws Exception {
    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

    // SECURE: reject any DOCTYPE outright -- the decisive control.
    dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
    // Defence in depth in case a DTD must ever be allowed:
    dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
    dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
    dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
    dbf.setXIncludeAware(false);
    dbf.setExpandEntityReferences(false);
    dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
    dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");

    DocumentBuilder builder = dbf.newDocumentBuilder();
    Document doc = builder.parse(xmlInput);   // throws on the malicious DOCTYPE
    return doc.getDocumentElement().getTextContent();
}

Python (Flask + ElementTree / defusedxml)

Vulnerable

from flask import Flask, request
import xml.etree.ElementTree as ET   # standard library

app = Flask(__name__)

@app.route("/parse", methods=["POST"])
def parse_xml():
    # VULNERABLE on affected versions: no protection against DTD/entity abuse,
    # and no defence against entity-expansion (billion laughs) DoS.
    root = ET.fromstring(request.data)
    return root.text or ""

Secure

from flask import Flask, request, abort
import defusedxml.ElementTree as ET   # pip install defusedxml
from defusedxml.common import DefusedXmlException

app = Flask(__name__)

@app.route("/parse", methods=["POST"])
def parse_xml():
    try:
        # SECURE: defusedxml forbids DTDs, external entities, and entity bombs.
        root = ET.fromstring(request.data)
    except DefusedXmlException:
        abort(400, "XML with DTDs or entities is not accepted")
    return root.text or ""

If you must use lxml instead of defusedxml, build a locked-down parser explicitly:

from lxml import etree

parser = etree.XMLParser(resolve_entities=False, no_network=True,
                         load_dtd=False, dtd_validation=False)
root = etree.fromstring(request.data, parser=parser)

PHP (DOMDocument / SimpleXML)

Vulnerable

<?php
// VULNERABLE if LIBXML_NOENT is set (it enables entity substitution),
// or on old libxml (< 2.9.0) where external entities loaded by default.
$doc = new DOMDocument();
$doc->loadXML($_POST["xml"], LIBXML_NOENT | LIBXML_DTDLOAD);  // do NOT do this
echo $doc->textContent;   // may leak file contents
?>

Secure

<?php
// SECURE: no LIBXML_NOENT, no LIBXML_DTDLOAD, and LIBXML_NONET blocks the network.
$doc = new DOMDocument();
$ok = $doc->loadXML($_POST["xml"], LIBXML_NONET);
if ($ok === false) {
    http_response_code(400);
    exit("Invalid XML");
}
echo $doc->textContent;   // &xxe; is not substituted -> nothing leaks

// SimpleXML equivalent, same principle:
$sxe = simplexml_load_string($_POST["xml"], "SimpleXMLElement", LIBXML_NONET);
?>

On modern PHP, external entities are already off by default; the vulnerability is almost always re-enabling them with LIBXML_NOENT. The secure version's real job is to never opt back in.

.NET (XmlDocument / XmlReader)

Vulnerable (legacy .NET Framework)

using System.Xml;

public string ParseUser(string xml)
{
    // VULNERABLE on old .NET Framework: XmlDocument used a live resolver
    // that fetched external DTDs and entities by default.
    var doc = new XmlDocument();
    doc.LoadXml(xml);                 // &xxe; expanded
    return doc.DocumentElement.InnerText;   // leaks the file
}

Secure

using System.Xml;
using System.IO;

public string ParseUser(string xml)
{
    var settings = new XmlReaderSettings
    {
        DtdProcessing = DtdProcessing.Prohibit,  // SECURE: no DOCTYPE allowed
        XmlResolver   = null,                     // never fetch external content
        MaxCharactersFromEntities = 1024          // cap entity expansion
    };

    using var stringReader = new StringReader(xml);
    using var reader = XmlReader.Create(stringReader, settings);

    var doc = new XmlDocument { XmlResolver = null };
    doc.Load(reader);                 // throws on the malicious DOCTYPE
    return doc.DocumentElement.InnerText;
}

Bonus: Hardening an SVG Upload Handler

A frequent real-world trap: an "image" upload that server-side code parses as XML. The fix is the same parser hardening—applied where you might not think to look.

Vulnerable (Python thumbnail/metadata step)

import xml.etree.ElementTree as ET

def extract_svg_title(uploaded_bytes):
    # VULNERABLE: an "image" upload is parsed as XML with default settings.
    root = ET.fromstring(uploaded_bytes)
    title = root.find("{http://www.w3.org/2000/svg}title")
    return title.text if title is not None else ""

Secure

import defusedxml.ElementTree as ET
from defusedxml.common import DefusedXmlException

def extract_svg_title(uploaded_bytes):
    try:
        # SECURE: same parsing, hardened parser -- DTDs/entities rejected.
        root = ET.fromstring(uploaded_bytes)
    except DefusedXmlException:
        raise ValueError("Rejected SVG containing a DTD or entities")
    title = root.find("{http://www.w3.org/2000/svg}title")
    return title.text if title is not None else ""

Side-by-Side Summary

StackVulnerable patternThe one change that fixes it
Java (JAXP)Default DocumentBuilderFactorydisallow-doctype-decl = true
Pythonxml.etree / lxml defaults on untrusted inputUse defusedxml (or resolve_entities=False, no_network=True)
PHPLIBXML_NOENT set, or libxml < 2.9.0Drop LIBXML_NOENT; pass LIBXML_NONET
.NET FrameworkXmlDocument with default resolverDtdProcessing.Prohibit + null XmlResolver

The pattern across all four is identical: the vulnerability is an insecure default or an opt-in to danger, and the fix is one or two lines that tell the parser to stop resolving things it was never supposed to resolve for untrusted input.

Next Steps