Inyección de entidades externas XML (XXE, CWE-611): cómo funciona y cómo evitarla
Un parser XML que respeta el DTD de un documento descarga entidades SYSTEM desde URL file:// o http:// y pega el resultado en los datos analizados, así que un documento subido puede leer archivos del servidor o hacer que el servidor llame a direcciones internas. Cómo funcionan las entidades externas y la expansión de entidades, qué ajustes del parser las provocan y cómo defusedxml con forbid_dtd=True las bloquea, con un ejemplo en Flask.
Explicación en Lenguaje Sencillo (ELI5)
Una imprenta rellena cartas modelo. La carta de un cliente dice: 'Estimado [inserte aquí el contenido del cajón 4 de su archivador]'. Un impresor cuidadoso solo inserta palabras que dio el cliente. Uno descuidado va al cajón 4, donde están los contratos de otros clientes, y los imprime en la carta que devuelve por correo. El XXE es el impresor descuidado: el documento XML le dice al parser que vaya a buscar un archivo o una dirección web y lo inserte, y el parser lo hace. La solución es rechazar cualquier carta que traiga instrucciones del tipo 'inserte desde otro sitio'.
Conceptos Clave y Términos
- DTD y entidades
- Una Document Type Definition en
<!DOCTYPE ...>puede declarar entidades, fragmentos de texto con nombre que el parser sustituye donde aparezca&nombre;. Las entidades internas contienen texto literal; las externas apuntan a otro lugar. - Entidad externa
<!ENTITY x SYSTEM "file:///etc/hostname">indica al parser que lea esa URL y ponga su contenido donde se use&x;. Con URLhttp://quien hace la petición es el servidor, lo que convierte el XXE en falsificación de peticiones del lado del servidor.- Expansión de entidades (Billion Laughs)
- Entidades que hacen referencia a otras entidades diez veces seguidas convierten un documento de un kilobyte en gigabytes de texto en memoria (CWE-776). No necesita acceso externo, así que solo lo detiene rechazar los DTD o limitar la expansión.
- Valores por defecto del parser
- El comportamiento depende de la biblioteca: lxml 5 resuelve por defecto solo entidades internas, pero
resolve_entities=Trueyload_dtd=Truevuelven a abrir el agujero;DocumentBuilderFactoryde Java resuelve entidades externas salvo que se activedisallow-doctype-decl; y .NET necesitaDtdProcessing.Prohibit. - defusedxml
- Un paquete de Python que envuelve los parsers estándar y lanza
EntitiesForbidden,ExternalReferenceForbiddenoDTDForbiddenen lugar de procesar construcciones peligrosas.forbid_dtd=Truerechaza cualquier documento con DOCTYPE.
Flujo de Ataque Paso a Paso
El atacante sube un documento XML
Declara una entidad externa y la referencia en un campo que el servidor devuelve. Una entidad que apunta a file:///etc/hostname ilustra el caso de lectura.
El parser procesa el DTD
Como la aplicación activó la carga de DTD y la resolución de entidades, el parser lee el archivo o la URL referenciados.
El contenido del archivo se convierte en datos
El texto acaba en el elemento customer, y el endpoint lo devuelve en su respuesta JSON.
El atacante lee archivos o alcanza hosts internos
Así pueden leerse archivos de configuración, claves y metadatos de la nube. En 2014 Reginaldo Silva recibió de Facebook una recompensa de 33.500 dólares por un XXE en su gestión de OpenID que permitía leer archivos de sus servidores.
Código Fuente: Vulnerable vs. Seguro
# invoices.py: el parser sigue lo que pida el DTD subido
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
# entidades resueltas, DTD cargados y acceso a red permitido
@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: los documentos con DTD se rechazan antes de analizarlos
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
# sin DOCTYPE, sin entidades, sin referencias externas; cuerpo limitado a 1 MB
@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")}
Lista de Verificación de Seguridad para Ingeniería
- Analiza XML no confiable con
defusedxmlen Python, o con los DTD desactivados en otros lenguajes:disallow-doctype-decla true en Java,DtdProcessing.Prohibiten .NET, libxml2 sinXML_PARSE_NOENTniXML_PARSE_DTDLOAD. - No pases nunca
resolve_entities=True,load_dtd=Truenino_network=Falsea lxml con entradas que no controlas, y ejecuta Bandit (B320 señalalxml.etree) en CI. - Limita el tamaño de la petición (
MAX_CONTENT_LENGTH) y rechaza cualquier documento que contenga un DOCTYPE. - Trata los archivos SVG, SAML, DOCX y XLSX subidos como XML y analízalos con los mismos ajustes reforzados.
- Añade una prueba en CI que envíe un documento con una entidad externa que apunte a
file:///etc/hostnamey espere 400.