
Advisory и Python PoC для CSRF в Pluck CMS: проверка Referer в режиме fail-open в сочетании с загрузкой файлов с двойным расширением позволяет развернуть веб-шелл и выполнить удалённый код.
Одного посещения страницы администратором достаточно, чтобы удалить контент, внедрить страницы и разместить веб-шелл
Краткий обзор · Резюме · Первопричина · Цепочка атаки · Эксплойт · Устранение · Хронология
| CVE ID | CVE-2026-70376 |
| Трекинговый ID | 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 — High (высокий) · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Вектор | Сетевой · без привилегий · одно посещение страницы администратором (UI:R) |
| Воздействие | Уничтожение контента/DoS, внедрение сохраняемого контента, RCE на Apache/mod_php |
| Появилось | коммит f79f916 (дек. 2019) — уязвимая логика присутствует с этого момента |
| Исследователь | Ilhomjon Rustamov (@IlhomjonR) |
В админ-панели Pluck нигде в кодовой базе нет CSRF-токенов для запросов.
Каждое действие администратора, изменяющее состояние, проходит через единственную функцию
requestedByTheSameDomain(), единственная защита которой — сравнение хоста из заголовка Referer,
и эта проверка открыта нараспашку (fail open): когда запрос приходит вообще без заголовка
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, попадает в ветку
fail-open, и запрос разрешается. За этой проверкой нет никакого уровня токенов.
Проверка выполняется один раз в 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 у сессионной cookie (усилитель)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"]
Поддающиеся подделке действия: