المدونة / شرح ثغرات CVE

fast-jwt: خلط خوارزميات JWT أُصلح من المحاولة الثالثة

عالجت fast-jwt خلط RSA في HS256 ثلاث مرات. CVE-2026-107722 توضح لماذا أغلق الإصلاحان الأولان المُدخل لا القاعدة.

أكملت دراسة الدكتوراه في أمن المعلوماتاجتزت المناقشة الجامعية · المناقشة النهائية للأطروحة لم تُجرَ بعد

نفس الثغرة، أُصلحت ثلاث مرات

بدون كلمة مرور. ودخل بصلاحية مسؤول. هذا ما يمنحه رمز JWT مزوَّر ضد المتحقق RS256 في fast-jwt — ثغرة أُصلحت ثلاث مرات قبل أن ينجح الإصلاح فعلاً. في 8 أكتوبر 2026 نشرت قاعدة GitHub Advisory Database الثغرة CVE-2026-107722 — التقرير وراء الإصلاح الذي نجح أخيراً. [1] اقرأ العنوان ببطء: «Incomplete patch of CVE-2026-34950»، أي إصلاح ناقص لـ CVE-2026-34950. والآن اقرأ عنوان CVE-2026-34950 نفسها، من أبريل: «Incomplete fix for CVE-2023-48223»، أي إصلاح ناقص لـ CVE-2023-48223. [3]

ثلاث نشرات أمنية، مكتبة واحدة، فئة ثغرة واحدة. كل إصلاح أغلق بالضبط المُدخل الذي أثبته الباحث السابق، وفي كل مرة جاء شخص بمُدخل مختلف قليلاً ومرّ مباشرة.

هذه السلسلة هي جوهر الموضوع. الثغرة نفسها مثال مدرسي على خلط الخوارزميات، وإذا كان هذا كل ما ستأخذه فستحدّث إلى 6.3.0 وتمضي قدماً. الجزء الذي يستحق وقتك هو لماذا فشل إصلاحان كفؤان ولم يفشل الثالث، لأن هذا الفرق شيء يمكنك تطبيقه على إصلاحاتك الخاصة غداً.

لم أُعِد إنتاج هذه الثغرة بنفسي في مختبر. كل ما يلي مأخوذ من النشرة الأمنية، والنشرة التي سبقتها، ومن الفرق البرمجي الذي أصلحهما. [1][2][3]

CVE-2023-48223   the original
      v
CVE-2026-34950   "incomplete fix"   9.1
      v                (added trim)
CVE-2026-107722  "incomplete patch" 9.8

الخادم يثق بالمفتاح. المهاجم يتحكم في النص

خلط خوارزميات JWT، في جملة واحدة: الرمز نفسه يخبر الخادم كيف يتحقق منه. فإذا كان التطبيق يوقّع بخوارزمية RS256 ويمكن إقناع المتحقق بالتحول إلى HS256 بدلاً منها، يتوقف المفتاح العام لـ RSA عن كونه مفتاحاً عاماً ويصبح سراً مشتركاً. وهو مفتاح عام، أي متاح للمهاجم. يوقّع المهاجم رمزاً به، ويحسب الخادم نفس قيمة HMAC، فتتطابق التوقيعات.

المثير في fast-jwt هو أين يُتخذ هذا القرار بالضبط. تحدد المكتبة ما إذا كان مفتاحك غير متماثل بالنظر إليه كنص: إن بدا كتلة PEM فتُعامله كـ RSA، وإن لم يبدُ كذلك فتُعامله كسر HMAC خام. [1]

هذا قرار معقول، وتفعل معظم مكتبات JWT شيئاً مشابهاً. لكنه يعني أن التصنيف يعتمد على مطابقة نصية، وللمطابقة النصية حالات حدية لا توجد في علم التشفير.

لماذا بدا trim() الإصلاح الصحيح

في أبريل، أبلغت CVE-2026-34950 أن مسافة بادئة تتجاوز فحص PEM. [3] كان المُطابِق تعبيراً نمطياً مثبتاً عند بداية النص، فالمفتاح الذي يبدأ بمسافة لم يتطابق، صُنّف كسر خام، وانفتح مسار HMAC.

كان الإصلاح في النسخة 6.2.0 استدعاءً لـ trim() قبل الفحص. [1][2] بسيط، وصحيح بوضوح للمُدخل المُبلَّغ عنه، وصدر مع اختبار انحدار. أظنّ أن معظم المراجعين وافقوا عليه في أقل من دقيقة، وربما كنت واحداً منهم.

لكن المُدخل المُبلَّغ عنه لم يكن الثغرة قط. الثغرة كانت في التثبيت. تعبير نمطي يبدأ بـ «^» يُشفّر افتراضاً: أن رأس PEM يقع في الموضع صفر. لا يُزيل trim() هذا الافتراض، بل يجعله صحيحاً فقط لفئة واحدة من الرموز، لأن String.prototype.trim يزيل فقط ما تصنّفه مواصفة ECMAScript كمسافات بيضاء. [1]

كل شيء آخر ما زال يعمل. اختبارات الانحدار التي صدرت مع الإصلاح الحقيقي تُعدّد ما يعنيه «كل شيء آخر» عملياً: بايت NUL، رمز هروب، DEL، رموز بعرض صفري، سطر تعليق، غلاف HTTP. [2] أي واحد منها أمام رأس PEM يجعل المفتاح سراً خاماً من جديد.

تصوغ النشرة الأمنية هذا أفضل مني: كان الإصلاح ضيقاً نصياً، للمسافات البيضاء فقط، بدلاً من معالجة الفئة كاملة. [1]

// 6.1.0 and earlier
/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/

// 6.2.0: trim() first, same anchor
trimmedKey.match(publicKeyPemMatcher)

// a leading NUL still walks past it

الإصلاح الثالث غيّر القاعدة لا المُدخل

انظر إلى ما يفعله 6.3.0 بشكل مختلف فعلاً، لأنه ليس قائمة إزالة أطول. [2]

توقف عن التثبيت في موضع واحد. مُطابِق جديد يبحث عن رأس PEM في أي مكان في النص، ويُقتطع المفتاح من هناك. يُعامَل المفتاح كسر HMAC خام فقط إذا لم يحتوِ على رأس PEM إطلاقاً، وبمجرد وجود رأس لا يمكن للمفتاح أن يعود إلى HMAC أبداً. انتقل فحص X.509 من includes إلى startsWith. المفتاح الذي يحمل علامات PEM لكنه لا يُصنَّف بوضوح يُطلق الآن خطأً بدلاً من السقوط بصمت في فرع HMAC.

تصوغ رسالة الالتزام البرمجي القاعدة الجديدة في سطر واحد، وهي جملة تستحق أن تُسرَق لقاعدة الشيفرة الخاصة بك: بمجرد وجود رأس، يجب أن يُصنَّف المفتاح كمادة مفتاح غير متماثل، ولا يمكنه أبداً العودة إلى HMAC. [2]

الإصلاحان الأولان حظرا مُدخلاً بعينه. الثالث سمح بشكل بعينه. هذا هو الفرق كله، ولهذا السبب يُغلق هذا الإصلاح الفئة كاملة بدلاً من حالة واحدة أخرى منها.

// 6.3.0 - find it anywhere
/-----BEGIN [A-Z0-9 ]+?-----/

const { pem, isRawSecret } =
  locatePem(trimmedKey)

if (isRawSecret) return hsAlgorithms
// unless algorithms explicitly pins HS256 - that still skips this check

تثبيت الخوارزمية يساعد. تثبيت الخاطئة لا يساعد

هنا الجزء الذي أراهن أنه يُوقع الناس. تُدرج النشرة الأمنية الإعدادات المعرّضة: غياب قائمة algorithms مسموحة، أو استخدام دالة استرجاع غير متزامنة للمفتاح، أو ضبط algorithms على HS256. والإعداد المُدرج كمحمي هو algorithms مضبوطة على RS256. [1]

تثبيت الخوارزمية هو النصيحة المعيارية ضد خلط الخوارزميات، وهي نصيحة صحيحة. لكن فريقاً يتصفح نشرة أمنية، يرى HS256 في وصف الهجوم، ويثبّت HS256، يكون قد ثبّت المتحقق على اختيار المهاجم نفسه. يجب أن تُسمّي القائمة المسموحة الخوارزمية التي توقّع بها فعلاً، لا تلك التي قرأت عنها في التحليل.

عندما أراجع شيفرة المصادقة، هذا هو الشكل الذي أبحث عنه أولاً: ضابط دفاعي مُعدّ بناءً على تقرير حادثة، لا بناءً على تصميم النظام نفسه. يجتاز المراجعة لأنه يبدو بوضوح ضابطاً، بينما يشير في الاتجاه الخاطئ.

هل خدمتك معرّضة فعلاً؟

أضيق مما توحي به درجة 9.8، ويجب أن تجتمع ثلاثة شروط. [1]

أولاً، النسخة المُثبَّتة فعلياً من fast-jwt بين 6.2.0 و6.2.4 بالنسبة لهذه الثغرة تحديداً؛ أما 6.1.0 وما قبلها فليست آمنة أيضاً، بل معرّضة للحلقة الثانية في السلسلة، CVE-2026-34950. تحقق مما ثبّته npm فعلاً لا مما يطلبه package.json، وتحقق من التبعية غير المباشرة: fast-jwt هي محرك JWT الافتراضي لـ @fastify/jwt، فكثير من الفرق تستخدمها دون أن تختارها بنفسها. [1]

ثانياً، المتحقق مُعدّ بإحدى الطرق المعرّضة أعلاه. ثالثاً، وهذا هو الشرط الوحيد الذي لا يمكن لأحد التحقق منه عنك، أن يحمل مفتاحك المُعدّ حرفاً غير فراغي قبل رأس PEM الخاص به. المفتاح النظيف غير متأثر إطلاقاً.

هذا الشرط الأخير خاصية في كيفية وصول المفتاح إلى العملية، لا في شيفرتك. مقروء من ملف، مُلصق في متغير بيئة، مسحوب من مدير أسرار، مُدمج مع تسمية بواسطة سكربت نشر. لا أملك بيانات عن مدى تكرار هذا في الواقع، ولا تملكها النشرة الأمنية أيضاً، فعاملها كموضع للبحث لا كاحتمال مُقدَّر.

ما الذي تتحقق منه اليوم

ثلاثة أشياء، بالترتيب الذي كنت سأقوم به.

حدّث إلى 6.3.0 وتأكد من النسخة المُثبَّتة بعد ذلك. ثم اضبط قائمة algorithms صريحة تُسمّي الخوارزمية غير المتماثلة التي تستخدمها خدمتك فعلاً. ثم، وهذا ما يبقى بعد هذه الثغرة نفسها، تحقق من شكل مفتاح التحقق عند الإقلاع بدلاً من الثقة بأي شيء يُسلّمه لك الملف أو متغير البيئة.

هذا التحقق الثالث يكلف سطراً واحداً، وكان كفيلاً بجعل آخر ثغرتين من هذه الثلاث غير قابلتين للاستغلال في خدمتك، بما فيها التالية. (الأولى، CVE-2023-48223، كانت من شكل مختلف — مفتاح صالح لم يتعرّف عليه المُطابِق القديم، لا بادئة زائدة أمامه — لذا هذا التحقق تحديداً لم يكن ليكتشفها.)

وهذا هو الدرس الحقيقي. مكتبة أصلحت نفس الفئة ثلاث مرات ستعود غالباً. أما مُحمِّل مفاتيح يرفض أي شيء ليس كتلة PEM بالضبط، فلن يهتم.

# the version that actually loads
npm ls fast-jwt

# pin the algorithm you really use
{ algorithms: ['RS256'] }

# assert the key's shape at boot
key.startsWith('-----BEGIN')

أصلح القاعدة، لا التقرير

كل واحد من هذه الإصلاحات صنعه شخص كفؤ، استجابةً لتقرير حقيقي، ومعه اختبار. لم يكن الفشل في الإهمال. كان في النطاق: وصف التقرير مُدخلاً، فعالج الإصلاح المُدخل، وبقيت القاعدة التي جعلت هذا المُدخل خطيراً كما هي تماماً.

السؤال الذي أطرحه عند مراجعة إصلاح ما مزعج عمداً: ما فئة المُدخلات التي هذا التقرير مثال واحد عليها، وهل يُغلق الإصلاح الفئة كاملة؟ إذا كانت الإجابة لا، فأنت لم تمنع الثغرة التالية، بل حدّدت موعداً لها.

هذا السؤال هو معظم ما تعنيه مراجعة الشيفرة الأمنية. تتبّع تقرير إلى القرار الذي جعله ممكناً، ثم التحقق مما إذا كان الإصلاح قد حرّك ذلك القرار أم حرّك العرَض فقط، هو عمل أقوم به بانتظام على شيفرة العملاء، وهو تحديداً ما لن يُبلّغ عنه أي ماسح آلي أبداً لأن رقم النسخة المُرقّعة يبدو صحيحاً. إذا أردت عيني مهاجم على إصلاح قبل أن يجد ثغرته شخص آخر، فتلك هي المحادثة التي تستحق أن تبدأ.

المصادر

هل تحتاج إلى هذا الفحص في منتجك؟

مراجعة أمان الشيفرة
جميع المقالات

08تواصل

هل تطلق منتجًا يتعامل مع بيانات المستخدمين؟

أرسل لي وصفًا مختصرًا. أرد خلال ثلاثة أيام بأسئلة أو بمخطط للعرض، ونتفق على السعر كتابيًا قبل بدء العمل.