● CWE-611 · OWASP A05:2021

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. Bei http://-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=True und load_dtd=True öffnen die Lücke wieder; Javas DocumentBuilderFactory löst externe Entitäten auf, solange disallow-doctype-decl nicht gesetzt ist; .NET braucht DtdProcessing.Prohibit.
defusedxml
Ein Python-Paket, das die Standard-Parser umhüllt und bei gefährlichen Konstrukten EntitiesForbidden, ExternalReferenceForbidden oder DTDForbidden auslöst, statt sie zu verarbeiten. forbid_dtd=True lehnt jedes Dokument mit DOCTYPE ab.

Schritt-für-Schritt Angriffsablauf

Schritt 1

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.

Schritt 2

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.

Schritt 3

Der Dateiinhalt wird zu Daten

Der Text landet im Element customer, und der Endpunkt gibt ihn in seiner JSON-Antwort zurück.

Schritt 4

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

VERWUNDBARE IMPLEMENTIERUNG
# 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")}
GEHÄRTETER SICHERHEITS-PATCH
# 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

Quellen

← Zum vollständigen Sicherheitsverzeichnis Alle Anleitungen zu Schwachstellen →