● CWE-611 · OWASP A05:2021

Injection d'entité externe XML (XXE, CWE-611) : fonctionnement et prévention

Un parseur XML qui applique la DTD d'un document va chercher les entités SYSTEM sur des URL file:// ou http:// et colle le résultat dans les données analysées : un document envoyé peut donc lire des fichiers du serveur ou faire appeler au serveur des adresses internes. Comment fonctionnent les entités externes et l'expansion d'entités, quels réglages du parseur les provoquent, et comment defusedxml avec forbid_dtd=True les bloque, avec un exemple Flask.

Explication en Langage Simple (ELI5)

Une imprimerie remplit des lettres types. La lettre d'un client dit : « Cher [insérez ici le contenu du tiroir 4 de votre classeur] ». Un imprimeur soigneux n'insère que les mots fournis par le client. Un imprimeur négligent va au tiroir 4, qui contient les contrats d'autres clients, et les imprime dans la lettre qu'il renvoie par la poste. Le XXE, c'est l'imprimeur négligent : le document XML dit au parseur d'aller chercher un fichier ou une adresse web et de l'insérer, et le parseur s'exécute. La correction consiste à refuser toute lettre contenant des instructions « insérez depuis ailleurs ».

Concepts Clés et Termes

DTD et entités
Une Document Type Definition dans <!DOCTYPE ...> peut déclarer des entités, des morceaux de texte nommés que le parseur substitue partout où &nom; apparaît. Les entités internes contiennent du texte littéral ; les externes pointent ailleurs.
Entité externe
<!ENTITY x SYSTEM "file:///etc/hostname"> demande au parseur de lire cette URL et de placer son contenu là où &x; est utilisé. Avec des URL http://, c'est le serveur qui fait la requête, ce qui transforme le XXE en falsification de requête côté serveur.
Expansion d'entités (Billion Laughs)
Des entités qui référencent d'autres entités dix fois de suite font passer un document d'un kilo-octet à des gigaoctets de texte en mémoire (CWE-776). Aucun accès externe n'est nécessaire, donc seuls le refus des DTD ou le plafonnement de l'expansion l'arrêtent.
Réglages par défaut du parseur
Le comportement dépend de la bibliothèque : lxml 5 ne résout par défaut que les entités internes, mais resolve_entities=True et load_dtd=True rouvrent la faille ; DocumentBuilderFactory en Java résout les entités externes sauf si disallow-doctype-decl est activé ; et .NET exige DtdProcessing.Prohibit.
defusedxml
Un paquet Python qui enveloppe les parseurs standard et lève EntitiesForbidden, ExternalReferenceForbidden ou DTDForbidden au lieu de traiter les constructions dangereuses. forbid_dtd=True refuse tout document contenant un DOCTYPE.

Déroulement de l'Attaque Étape par Étape

Étape 1

L'attaquant envoie un document XML

Il déclare une entité externe et la référence dans un champ que le serveur renvoie. Une entité pointant vers file:///etc/hostname illustre le cas de lecture.

Étape 2

Le parseur traite la DTD

Comme l'application a activé le chargement des DTD et la résolution des entités, le parseur lit le fichier ou l'URL référencés.

Étape 3

Le contenu du fichier devient des données

Le texte arrive dans l'élément customer, et l'endpoint le renvoie dans sa réponse JSON.

Étape 4

L'attaquant lit des fichiers ou atteint des hôtes internes

Fichiers de configuration, clés et métadonnées cloud peuvent être lus ainsi. En 2014, Reginaldo Silva a reçu de Facebook une prime de 33 500 dollars pour un XXE dans sa gestion d'OpenID qui permettait de lire des fichiers sur ses serveurs.

Code Source : Vulnérable vs Sécurisé

IMPLÉMENTATION VULNÉRABLE
# invoices.py : le parseur suit tout ce que demande la DTD envoyée
from flask import Flask, request
from lxml import etree

app = Flask(__name__)


# entités résolues, DTD chargées et accès réseau autorisé
@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")}
PATCH SÉCURISÉ ET ROBUSTE
# invoices.py : les documents avec DTD sont refusés avant l'analyse
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


# ni DOCTYPE, ni entités, ni références externes ; corps plafonné à 1 Mo
@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")}

Liste de Contrôle de Sécurité pour l'Ingénierie

Sources

← Parcourir tout l'annuaire sécurité Tous les guides de vulnérabilités →