
نشرة أمنية وإثبات مفهوم (PoC) بلغة Python لثغرة CSRF في نظام إدارة المحتوى Pluck CMS: فحص مُحيل (Referer) يعمل بنمط الفتح عند الفشل (fail-open) إلى جانب رفع الملفات بامتداد مزدوج، ما يتيح نشر قشرة ويب (webshell) وتنفيذ أكواد برمجية عن بُعد.
زيارة واحدة من مسؤول لصفحة تحذف المحتوى، وتحقن الصفحات، وتُسقط قشرة ويب
نظرة سريعة · الملخص · السبب الجذري · سلسلة الهجوم · الاستغلال · المعالجة · الجدول الزمني
| معرّف CVE | CVE-2026-70376 |
| معرّف التتبع | PT-2026-68036 |
| المنتج | pluck-cms/pluck — Pluck CMS (PHP بتخزين ملفات مسطحة) |
| المتأثر | 4.7.x حتى 4.7.21-dev / فرع master الحالي |
| نقطة الضعف | CWE-352 (CSRF) · CWE-434 (رفع غير مقيد، مضخِّم) |
| CVSS v3.1 | 8.0 — عالية · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| ناقل الهجوم | شبكة · بدون صلاحيات · زيارة واحدة لصفحة من مسؤول (UI:R) |
| الأثر | تدمير المحتوى/DoS، حقن محتوى مخزَّن، RCE على Apache/mod_php |
| أُدخلت في | commit f79f916 (ديسمبر 2019) — المنطق الهش موجود منذ ذلك الحين |
| الباحث | Ilhomjon Rustamov (@IlhomjonR) |
لا يحتوي لوحة إدارة Pluck على أي رموز CSRF لكل طلب في أي مكان في قاعدة الكود.
كل إجراء إداري يغيّر الحالة محكوم بوظيفة واحدة،
requestedByTheSameDomain()، التي لا دفاع لها سوى مقارنة مضيف Referer
— وهذا الفحص يفشل بشكل مفتوح: عندما يصل طلب بدون ترويسة Referer
إطلاقًا، تُرجع الوظيفة true ويُسمح بالإجراء.
ولأن صفحة المهاجم تتحكم بالكامل في ما إذا كان Referer سيُرسل
(<meta name="referrer" content="no-referrer">)، يمكن إجبار أي مسؤول مسجّل دخوله
يزور صفحة خبيثة على تنفيذ إجراءات مميّزة عبر المواقع.
تعمل عدة إجراءات تدميرية عبر GET، ولا يضع Pluck أي سمة SameSite
على PHPSESSID (تطبّق المتصفحات SameSite=Lax، التي ما تزال تُرسل
مع تنقّلات GET ذات المستوى الأعلى)، لذا يمكن الوصول إليها عبر المواقع في ظل
إعدادات المتصفح الافتراضية.
كما تتيح نقطة ضعف ثانوية في فلتر الرفع لنفس الـCSRF زرع ملف
shell.php.jpg بامتداد مزدوج — محوّلةً الـCSRF إلى RCE فوري عند الزيارة (drive-by)
على مضيفات Apache/mod_php.
data/inc/functions.admin.phpfunction requestedByTheSameDomain() {
if (isset($_SERVER['HTTP_HOST'])) { $myDomain = $_SERVER['HTTP_HOST']; }
elseif (isset($_SERVER['SCRIPT_URI'])) { $myDomain = $_SERVER['SCRIPT_URI']; }
else { $myDomain = NULL; }
if (isset($_SERVER['HTTP_REFERER'])) { $requestsSource = $_SERVER['HTTP_REFERER']; }
else { $requestsSource = NULL; }
$referelDomain = parse_url($requestsSource, PHP_URL_HOST);
if ($myDomain != NULL && $requestsSource != NULL &&
(strcmp(trim($myDomain), trim($referelDomain)) === 0)) {
return true; // Referer host == our host -> allow
} elseif ($myDomain == NULL || $requestsSource == NULL) {
show_error("Be carefull with clicking links, ...", 1);
return true; // Referer ABSENT -> FAIL OPEN -> allow <==
} else {
return false; // Referer host mismatch -> block
}
}
يُرفض طلب عبر المواقع يحمل Referer أجنبيًا بشكل صحيح (فرع else)،
وهذا يخلق إحساسًا زائفًا بالحماية — لكن المهاجم ببساطة يمنع إرسال Referer،
فيصل إلى فرع الفشل المفتوح، ويُسمح بالطلب.
لا توجد طبقة رموز خلف هذا الفحص.
يُطبَّق هذا الحاجز مرة واحدة في admin.php ويُعتمد عليه لكامل مبدّل الإجراءات:
$isCSRF = requestedByTheSameDomain();
if (isset($_GET['action']) && $isCSRF) {
switch ($_GET['action']) {
case 'deletefile': include_once('data/inc/deletefile.php'); break;
case 'deleteimage': include_once('data/inc/deleteimage.php'); break;
case 'deletepage': include_once('data/inc/deletepage.php'); break;
case 'module_delete': /* ... */
case 'images': include_once('data/inc/images.php'); break; // upload
// ...
}
}
SameSite على كعكة الجلسة (مضخِّم)لا يستدعي Pluck أبدًا session_set_cookie_params()، لذلك يرث PHPSESSID
القيمة الافتراضية الفارغة → تطبّق المتصفحات SameSite=Lax، التي ما تزال تُرسل
مع تنقّلات GET ذات المستوى الأعلى. وبالتالي فإن الإجراءات المعرّضة عبر GET
(deletefile, deleteimage, deletepage, module_delete, theme_delete, logout)
قابلة للتزوير بزيارة صفحة واحدة.
data/inc/images.php (مضخِّم RCE)if (in_array($_FILES['imagefile']['type'], // client-controlled MIME
array('image/pjpeg','image/jpeg','image/png','image/gif'))) {
$imagewhitelist = array('jfif', '.png', '.jpg', '.gif', 'jpeg');
if (!in_array(strtolower(substr($_FILES['imagefile']['name'], -4)), $imagewhitelist)) {
show_error($lang['general']['upload_failed'], 1); // only checks LAST 4 chars
} else {
copy($_FILES['imagefile']['tmp_name'], 'images/'.latinOnlyInput($_FILES['imagefile']['name']));
// ...
}
}
كلا الفحصين يمكن تجاوزهما بسهولة:
$_FILES[...]['type']) → اضبطه على image/jpeg;shell.php.jpg
ينتهي بـ .jpg ويجتاز الفحص.يُكتب الملف إلى images/shell.php.jpg؛ على مضيف Apache/mod_php مع
معالجة الامتدادات المتعددة، يُنفَّذ كـ PHP.
زيارة واحدة لصفحة من مسؤول مصادَق — دون أي نقرة.
flowchart LR
A[Admin logged into Pluck] --> B[Opens attacker page]
B --> C["meta referrer=no-referrer<br/>suppresses Referer"]
C --> D[Top-level nav / auto-form to admin.php]
D --> E["Lax PHPSESSID cookie rides along<br/>Referer absent"]
E --> F["requestedByTheSameDomain() -> FAIL OPEN -> true"]
F --> G1[deletefile / deletepage -> destruction / DoS]
F --> G2[editpage -> stored-content injection]
F --> G3["images upload -> shell.php.jpg -> RCE"]
الإجراءات القابلة للتزوير تشمل:
| الإجراء | الطريقة | الأثر |
|---|---|---|
admin.php?action=deletefile&var1=<f> | GET | حذف أي ملف مرفوع |
admin.php?action=deletepage&... | GET | حذف صفحات الموقع (DoS) |
admin.php?action=module_delete&... | GET | إزالة الوحدات |
admin.php?action=logout | GET | تسجيل خروج المسؤول |
admin.php?action=editpage | POST | حقن محتوى صفحات مخزَّن |
admin.php?action=images (رفع) | POST | زرع shell.php.jpg → RCE |
توجد مجموعة أدوات PoC عاملة في exploit/:
pluck_csrf_rce.py — لإثبات منطق الفشل المفتوح، رفع قشرة ويب عبر CSRF
وتنفيذ أوامر، حذف ملفات عبر CSRF، أو توليد صفحة إغراء.csrf_poc.html — صفحة drive-by المستقلة التي تُسلَّم إلى مسؤول ضحية.pip install requests
# Prove the fail-open Referer logic
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck -p 'AdminPass1!' probe