XML External Entity Injection (XXE, CWE-611): Funktionsweise und Schutz
Ein XML-Parser, der die DTD eines Dokuments beachtet, holt SYSTEM-Entitäten von file://- oder http://-URLs und fügt das Ergebnis in die geparsten Daten ein. Ein hochgeladenes Dokument kann so Serverdateien lesen oder den Server interne Adressen aufrufen lassen. Wie externe Entitäten und Entitätsexpansion funktionieren, welche Parser-Einstellungen sie verursachen und wie defusedxml mit forbid_dtd=True sie abschaltet, mit einem Flask-Beispiel.
Einfache Erklärung (ELI5)
Eine Druckerei füllt Serienbriefe aus. Der Brief eines Kunden lautet: „Sehr geehrte [hier den Inhalt von Schublade 4 Ihres Aktenschranks einfügen]“. Ein sorgfältiger Drucker fügt nur Wörter ein, die der Kunde geliefert hat. Ein nachlässiger Drucker geht zu Schublade 4, in der die Verträge anderer Kunden liegen, und druckt sie in den Brief, den er zurückschickt. XXE ist der nachlässige Drucker: Das XML-Dokument weist den Parser an, eine Datei oder Webadresse zu holen und einzufügen, und der Parser tut es. Die Lösung: Briefe mit irgendeiner Anweisung „von anderswo einfügen“ grundsätzlich ablehnen.
Kernkonzepte & Begriffe
- DTD und Entitäten
- Eine Document Type Definition in
<!DOCTYPE ...>kann Entitäten deklarieren, benannte Textstücke, die der Parser überall dort einsetzt, wo&name;steht. Interne Entitäten enthalten wörtlichen Text, externe verweisen woandershin. - Externe Entität
<!ENTITY x SYSTEM "file:///etc/hostname">weist den Parser an, diese URL zu lesen und ihren Inhalt dort einzusetzen, wo&x;verwendet wird. Beihttp://-URLs stellt der Server die Anfrage, womit XXE zu serverseitiger Anfragefälschung wird.- Entitätsexpansion (Billion Laughs)
- Entitäten, die jeweils zehnmal auf andere Entitäten verweisen, blähen ein Kilobyte großes Dokument auf Gigabytes Text im Speicher auf (CWE-776). Dafür ist kein externer Zugriff nötig, nur das Ablehnen von DTDs oder eine Expansionsgrenze stoppt es.
- Parser-Standardeinstellungen
- Das Verhalten hängt von der Bibliothek ab: lxml 5 löst standardmäßig nur interne Entitäten auf, doch
resolve_entities=Trueundload_dtd=Trueöffnen die Lücke wieder; JavasDocumentBuilderFactorylöst externe Entitäten auf, solangedisallow-doctype-declnicht gesetzt ist; .NET brauchtDtdProcessing.Prohibit. - defusedxml
- Ein Python-Paket, das die Standard-Parser umhüllt und bei gefährlichen Konstrukten
EntitiesForbidden,ExternalReferenceForbiddenoderDTDForbiddenauslöst, statt sie zu verarbeiten.forbid_dtd=Truelehnt jedes Dokument mit DOCTYPE ab.
Schritt-für-Schritt Angriffsablauf
Der Angreifer lädt ein XML-Dokument hoch
Es deklariert eine externe Entität und verwendet sie in einem Feld, das der Server zurückgibt. Eine Entität, die auf file:///etc/hostname zeigt, zeigt den Lesefall.
Der Parser verarbeitet die DTD
Weil die Anwendung das Laden von DTDs und das Auflösen von Entitäten aktiviert hat, liest der Parser die verwiesene Datei oder URL.
Der Dateiinhalt wird zu Daten
Der Text landet im Element customer, und der Endpunkt gibt ihn in seiner JSON-Antwort zurück.
Der Angreifer liest Dateien oder erreicht interne Hosts
Konfigurationsdateien, Schlüssel und Cloud-Metadaten lassen sich so lesen. 2014 erhielt Reginaldo Silva von Facebook eine Prämie von 33.500 US-Dollar für eine XXE-Lücke in der OpenID-Verarbeitung, mit der sich Dateien auf den Servern lesen ließen.
Quellcode: Verwundbar vs. Sicher
# invoices.py: der Parser folgt allem, was die hochgeladene DTD verlangt
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
# Entitäten aufgelöst, DTDs geladen, Netzwerkzugriff erlaubt
@app.post("/invoices/import")
def import_invoice():
parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)
root = etree.fromstring(request.get_data(), parser)
return {"customer": root.findtext("customer"), "total": root.findtext("total")}
# invoices.py: Dokumente mit DTD werden vor dem Parsen abgelehnt
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from flask import Flask, abort, request
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1_000_000
# kein DOCTYPE, keine Entitäten, keine externen Verweise; Body auf 1 MB begrenzt
@app.post("/invoices/import")
def import_invoice():
try:
root = fromstring(request.get_data(), forbid_dtd=True)
except (DefusedXmlException, ParseError):
abort(400)
return {"customer": root.findtext("customer"), "total": root.findtext("total")}
Checkliste für Engineering & Systemsicherheit
- Nicht vertrauenswürdiges XML in Python mit
defusedxmlparsen, anderswo mit abgeschalteten DTDs: in Javadisallow-doctype-declauf true, in .NETDtdProcessing.Prohibit, libxml2 ohneXML_PARSE_NOENTundXML_PARSE_DTDLOAD. - lxml für fremde Eingaben nie
resolve_entities=True,load_dtd=Trueoderno_network=Falseübergeben und Bandit (B320 meldetlxml.etree) in der CI ausführen. - Die Request-Größe begrenzen (
MAX_CONTENT_LENGTH) und jedes Dokument mit DOCTYPE ablehnen. - Hochgeladene SVG-, SAML-, DOCX- und XLSX-Dateien als XML behandeln und mit denselben gehärteten Einstellungen parsen.
- Einen CI-Test ergänzen, der ein Dokument mit einer externen Entität auf
file:///etc/hostnamesendet und 400 erwartet.