● CWE-611 · OWASP A05:2021

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 URL http:// 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=True y load_dtd=True vuelven a abrir el agujero; DocumentBuilderFactory de Java resuelve entidades externas salvo que se active disallow-doctype-decl; y .NET necesita DtdProcessing.Prohibit.
defusedxml
Un paquete de Python que envuelve los parsers estándar y lanza EntitiesForbidden, ExternalReferenceForbidden o DTDForbidden en lugar de procesar construcciones peligrosas. forbid_dtd=True rechaza cualquier documento con DOCTYPE.

Flujo de Ataque Paso a Paso

Paso 1

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.

Paso 2

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.

Paso 3

El contenido del archivo se convierte en datos

El texto acaba en el elemento customer, y el endpoint lo devuelve en su respuesta JSON.

Paso 4

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

IMPLEMENTACIÓN VULNERABLE
# 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")}
PARCHE SEGURO Y ROBUSTO
# 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

Fuentes

← Ver el directorio completo de seguridad Todas las guías de vulnerabilidades →