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 URLhttp://, 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=Trueetload_dtd=Truerouvrent la faille ;DocumentBuilderFactoryen Java résout les entités externes sauf sidisallow-doctype-declest activé ; et .NET exigeDtdProcessing.Prohibit. - defusedxml
- Un paquet Python qui enveloppe les parseurs standard et lève
EntitiesForbidden,ExternalReferenceForbiddenouDTDForbiddenau lieu de traiter les constructions dangereuses.forbid_dtd=Truerefuse tout document contenant un DOCTYPE.
Déroulement de l'Attaque Étape par Étape
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.
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.
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.
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é
# 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")}
# 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
- Analysez le XML non fiable avec
defusedxmlen Python, ou avec les DTD désactivées ailleurs :disallow-doctype-declà true en Java,DtdProcessing.Prohibiten .NET, libxml2 sansXML_PARSE_NOENTniXML_PARSE_DTDLOAD. - Ne passez jamais
resolve_entities=True,load_dtd=Trueouno_network=Falseà lxml pour des entrées que vous ne contrôlez pas, et exécutez Bandit (B320 signalelxml.etree) en CI. - Plafonnez la taille des requêtes (
MAX_CONTENT_LENGTH) et refusez tout document contenant un DOCTYPE. - Traitez les fichiers SVG, SAML, DOCX et XLSX envoyés comme du XML et analysez-les avec les mêmes réglages durcis.
- Ajoutez un test CI qui envoie un document avec une entité externe pointant vers
file:///etc/hostnameet attend 400.