
إثبات مفهوم ومدقّق ثغرات لـ CVE-2026-18963، وهو استيلاء على الحساب دون مصادقة في تدفق إعادة تعيين بيانات الاعتماد في Keycloak. ملف Python واحد، مع مكوّن متصفح Playwright اختياري.
إثبات مفهوم وفاحص ثغرة لتجاوز تدفق إعادة تعيين بيانات الاعتماد في Keycloak، مع مكوّن متصفح اختياري يعرض الحالة الناتجة في نافذة Chrome حقيقية.
الأداة عبارة عن ملف واحد، cve_2026_18963_poc.py، مكتوب بالاعتماد على مكتبة Python القياسية. Playwright هو التبعية الخارجية الوحيدة ويُستورد بشكل كسول (lazily)، لذا يعمل الجانب الخاص بـ HTTP على مفسّر Python قياسي دون تثبيت أي شيء.
cve_2026_18963_poc.py 1009 lines, Python 3.10 or newer
شكر وتقدير. أُبلغ Red Hat عن CVE-2026-18963 من قبل James Paremain، وهو مُدرَج في استشارة المورّد. لم يكتشف Orevim هذه الثغرة أو يبلّغ عنها. هذا المستودع هو إثبات مفهوم مستقل كُتب انطلاقًا من الاستشارة المنشورة.
CVE-2026-18963 هي تجاوز للمصادقة في تدفق نسيت كلمة المرور في Keycloak. وهي عيبان يعملان معًا.
الملاحظة غير المقيّدة النطاق. عندما يفتح المستخدم شاشة اختيار أداة المصادقة،
تكتب Keycloak ملاحظة AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED في
جلسة المصادقة. الملاحظة هي قيمة منطقية مجرّدة. فهي تسجّل أن شاشة اختيار
قد عُرضت، لا أي تنفيذ عرضها. وتعيش الملاحظة أطول من التنفيذ الذي أنشأها.
المعالج غير المحمي. يضبط ResetCredentialEmail.action() قيمة emailVerified
على المستخدم المُحدَّد ويستدعي success() دون التحقق من رمز الإجراء
الذي يُفترض أن يحمله الرابط المُرسَل بالبريد. تفترض الدالة أنه لا يمكن
الوصول إليها إلا عبر اتباع ذلك الرابط.
وعند تسلسلهما، يقوم مستدعٍ غير مُصادَق بتعيين الملاحظة مقابل تنفيذ اسم المستخدم،
ثم يدفع التدفق قدمًا بحيث يصبح تنفيذ البريد هو الحالي، ثم يعيد الدخول إلى رابط إعادة التعيين.
تتسبب الملاحظة القديمة في عرض شاشة الاختيار مرتبطةً بتنفيذ البريد. وإرسال جسم فارغ إلى
loginAction الخاص بذلك التنفيذ يستدعي المعالج غير المحمي مباشرةً. فيتقدم التدفق إلى
إجراء UPDATE_PASSWORD المطلوب دون قراءة أي بريد على الإطلاق.
| المعرّف | CVE-2026-18963، GHSA-4gv3-mc9p-5wqc |
| الفئة | CWE-640، آلية استرداد كلمة مرور ضعيفة لكلمة المرور المنسية |
| CVSS v3.1 | 9.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| المتأثر | >= 26.0.0, < 26.4.15، >= 26.5.0, < 26.6.6، >= 26.7.0, < 26.7.2 |
| مُصحَّح في | 26.4.15، 26.6.6، 26.7.2. ويأتي خط 26.8.0 مع الإصلاح |
| الحزمة | org.keycloak:keycloak-services (Maven) |
| تاريخ النشر | 18 أغسطس 2026 |
| أبلغ عنه | James Paremain، مُدرَج في استشارة Red Hat |
الشرط المسبق الوحيد هو أن يوفّر النطاق (realm) استرداد كلمة المرور للمستخدمين غير المُصادَقين. لا يُشترط أي بيانات اعتماد أو رمز أو جلسة سابقة.
ثمانية طلبات، بالترتيب. تتطابق الوسوم مع تسميات الخطوات التي تطبعها الأداة وأسماء
الملفات التي تكتبها تحت --out.
| # | الوسم | الطريقة | ما تفعله |
|---|---|---|---|
| 1 | login | GET | رابط التفويض. ينشئ جلسة المصادقة ويُنتج رابط إعادة التعيين. |
| 2 | reset | GET | صفحة إعادة تعيين بيانات الاعتماد. تحمل نموذج اسم المستخدم. |
| 3 | tryanother | POST | tryAnotherWay=on. يكتب الملاحظة غير المقيّدة النطاق. |
| 4 | username | POST | المعرّف. يدفع التدفق قدمًا بحيث يصبح تنفيذ البريد هو الحالي. يرسل بريد الاسترداد إذا تمكّن المعرّف من الحل. |
| 5 | reenter | GET | رابط إعادة التعيين مرة أخرى. تجعل الملاحظة القديمة المحدِّد مرتبطًا بتنفيذ البريد. |
| 6 | action | POST | جسم فارغ إلى loginAction الخاص بذلك التنفيذ. يستدعي المعالج غير المحمي. |
| 7 | required-action | GET | يتبع Location إلى صفحة UPDATE_PASSWORD. |
| 8 | setpassword | POST | كلمة المرور الجديدة. تُرسَل فقط مع --set-password. |
الخطوة 6 هي الاستغلال، والجسم الفارغ جوهري. فإرسال
authenticationExecution=<id> بدلًا من ذلك هو تفاعل المحدِّد العادي: فهو
يبدّل التنفيذ ويعيد إرسال بريد الاسترداد، وهو ما لا يصل إلى
العيب أبدًا. أما طلب POST الفارغ فلا يجيب على أي تحدٍّ، لذا تستدعي Keycloak المعالج
للتنفيذ الذي تركته الملاحظة القديمة هو الحالي.
# Decide whether the instance is affected. Changes nothing, sends no mail.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check
# Additionally report which identifiers resolve to a real account.
# Resolving accounts receive a recovery email. See the note below.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --check --users-file names.txt --rate 0.25
# Reach the set-password form for one account and stop there.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected]
# Complete the takeover. This locks the account holder out.
./cve_2026_18963_poc.py --url https://sso.example.com --realm demo \
--client-id account-console --username [email protected] \
--set-password 'N3w-passw0rd!'
--check يفحص باستخدام معرّف عشوائي بالصيغة
<16 hex>@<8 hex>.invalid. نطاق .invalid محجوز بموجب RFC 2606 ولا
يمكن تحليله، لذا لا يمكن للفحص أن يتعارض مع حساب حقيقي ولا
أن يتسبب في إرسال بريد. فهو يحدد حالة البناء دون المساس
بأي أحد.
إضافة
--usersتغيّر ذلك. الخطوة 4 هي إرسال نموذج نسيت كلمة المرور العادي. في نطاق (realm) مُهيّأ بـ SMTP، يتلقى المعرّف الذي يُحلّ إلى مستخدم لديه عنوان بريد إلكتروني رسالة استرداد كلمة مرور حقيقية، على البُنى المصابة والمُرقّعة على حد سواء. أما الفحص المجرّد فلا يفعل ذلك، لأنResetCredentialEmail.authenticate()لا يرسل شيئًا عندما يكون المستخدم null. تعامل مع--check --usersكتدفق يُخطر أشخاصًا حقيقيين وتأكد من أنه ضمن نطاق تصريحك قبل تشغيله.
يصنّف الأداة بناءً على الاستجابة للخطوة 6، وهي طلب POST الفارغ.
| الاستجابة | النتيجة | المعنى |
|---|---|---|
| 400 | account-absent | عمل المعالج دون رمز إجراء ولم يُحلّ أي مستخدم. البناء مصاب. |
302 مع Location | form-reached | عمل المعالج ويُحلّ الحساب. مصاب، وهذا المعرّف حقيقي. |
| أي شيء آخر | not-vulnerable | لم يتقدم طلب POST الفارغ. |
| لا محدِّد في الخطوة 5 | not-vulnerable | لم يُحترم الملاحظة القديمة. |
| 302 بعد الخطوة 8 | password-set | تغيّرت كلمة المرور وأصدر النطاق جلسة. |
| 429 بعد تجاوز ميزانية إعادة المحاولة | rate-limited | لا حكم. توقف التشغيل. |
| نتيجة سلبية بلغت بعد أي تقييد | inconclusive | انظر أدناه. |
الاستجابة 400 هي المهمة. فهي تفصل بين البناء مصاب وهذا
الحساب موجود، مما يتيح لـ --check إثبات التعرّض على نسخة
دون تعداد مستخدم حقيقي واحد. أما حلّ المعرّفات فهو سؤال
منفصل واختياري.
النتيجة السلبية لا تكون سليمة إلا إذا لم يُقيَّد أي شيء. يُنهي Keycloak جلسة
المصادقة وفق ساعته الخاصة. تكلف استجابة 429 في منتصف السلسلة تراجعًا، وسلسلة
تستأنف بعد نوم طويل تُرجع بالضبط الصفحة التي تُرجعها البنية المُرقّعة.
الإبلاغ عن ذلك كـ غير مصاب هو نمط الفشل الوحيد الذي يجب ألا
يمتلكه أي فاحص، لذا تُخفَّض أي نتيجة سلبية بلغت بعد حدث تقييد إلى
inconclusive مع عدد مرات التقييد والنصيحة بإعادة التشغيل بوتيرة أبطأ.
لا شيء بشأن النشر مُثبَّت بشكل صلب. تقرأ الأداة ما تحتاجه:
--url و--realm، أو المقدَّم مباشرة عبر --discovery-url. مرّر
--auth-url لتخطي الاكتشاف بالكامل.login-actions/reset-credentials.قالب تطبيق أحادي الصفحة (Keycloakify وما شابه) لا يُصيّر أي
عنصر <form> من جهة الخادم على الإطلاق. يوجد الإجراء فقط في كائن kcContext
الذي يُضمّنه القالب في كتلة <script>. تلجأ الأداة إلى قراءة
loginAction وloginResetCredentialsUrl من ذلك الكائن، مع فكّ تهريب
\/ و\uXXXX. وبما أنه لا يوجد نموذج فلا توجد حقول لنقلها،
لذا فإن جسم الطلب هو فقط ما تتجاوزه الخطوة، وتأتي أسماء الحقول من
--username-field و--password-fields.
كل إعداد افتراضي للبروتوكول مكشوف كعلم، بما في ذلك حقل المحدِّد
نفسه (--selector-field، الافتراضي tryAnotherWay=on).