
Advisory und Python-PoC für Pluck-CMS-CSRF: Fail-open-Referer-Prüfung plus Upload mit doppelter Dateiendung ermöglicht die Bereitstellung einer Webshell und Remote-Code-Ausführung.
Ein einziger Admin-Seitenbesuch löscht Inhalte, injiziert Seiten und hinterlässt eine Webshell
Auf einen Blick · Zusammenfassung · Grundursache · Angriffskette · Exploit · Behebung · Zeitleiste
| CVE-ID | CVE-2026-70376 |
| Tracking-ID | PT-2026-68036 |
| Produkt | pluck-cms/pluck — Pluck CMS (flat-file PHP) |
| Betroffen | 4.7.x bis 4.7.21-dev / aktueller master |
| Schwachstelle | CWE-352 (CSRF) · CWE-434 (uneingeschränkter Upload, Verstärker) |
| CVSS v3.1 | 8.0 — Hoch · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Vektor | Netzwerk · keine Privilegien · ein einziger Admin-Seitenbesuch (UI:R) |
| Auswirkung | Inhaltslöschung/DoS, Injektion gespeicherter Inhalte, RCE auf Apache/mod_php |
| Eingeführt | Commit f79f916 (Dez. 2019) — verwundbare Logik seither vorhanden |
| Forscher | Ilhomjon Rustamov (@IlhomjonR) |
Das Pluck-Adminpanel hat im gesamten Codebase keine CSRF-Tokens pro Anfrage.
Jede zustandsändernde Admin-Aktion wird durch eine einzige Funktion, requestedByTheSameDomain(), abgesichert, deren einzige Verteidigung ein Vergleich des Referer-Hosts ist — und diese Prüfung schlägt offen fehl (Fail-open): Wenn eine Anfrage ganz ohne Referer-Header eintrifft, gibt die Funktion true zurück und die Aktion wird erlaubt.
Da eine Angreifer-Seite vollständig kontrolliert, ob ein Referer gesendet wird (<meta name="referrer" content="no-referrer">), kann jeder angemeldete Administrator, der eine bösartige Seite besucht, gezwungen werden, privilegierte Aktionen cross-site auszuführen.
Mehrere destruktive Aktionen laufen über GET, und Pluck setzt kein SameSite-Attribut auf PHPSESSID (Browser wenden SameSite=Lax an, das bei Top-Level-GET-Navigationen dennoch mitgesendet wird). Daher sind sie bei den Standard-Browsereinstellungen cross-site erreichbar.
Eine sekundäre Schwachstelle im Upload-Filter ermöglicht es derselben CSRF, eine Datei mit doppelter Endung shell.php.jpg zu platzieren — und macht aus der CSRF auf Apache/mod_php-Hosts eine Drive-by-RCE.
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
}
}
Eine Cross-Site-Anfrage mit einem fremden Referer wird korrekt abgewiesen (der else-Zweig), was ein trügerisches Sicherheitsgefühl erzeugt — aber der Angreifer unterdrückt den Referer schlichtweg, trifft auf den Fail-open-Zweig, und die Anfrage wird erlaubt. Hinter dieser Prüfung gibt es keine Token-Schicht.
Die Prüfung wird einmal in admin.php ausgeführt und für die gesamte Aktions-Switch-Anweisung als vertrauenswürdig behandelt:
$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 auf dem Session-Cookie (Verstärker)Pluck ruft niemals session_set_cookie_params() auf, daher erbt PHPSESSID den leeren Standardwert → Browser wenden SameSite=Lax an, das bei Top-Level-GET-Navigationen dennoch mitgesendet wird. Über GET exponierte Aktionen (deletefile, deleteimage, deletepage, module_delete, theme_delete, logout) sind daher mit einem einzigen Seitenbesuch fälschbar.
data/inc/images.php (RCE-Verstärker)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']));
// ...
}
}
Beide Prüfungen lassen sich trivial umgehen:
$_FILES[...]['type']) → einfach image/jpeg setzen;shell.php.jpg endet auf .jpg und besteht die Prüfung.Die Datei wird nach images/shell.php.jpg geschrieben; auf einem Apache/mod_php-Host mit Multi-Extension-Behandlung wird sie als PHP ausgeführt.
Ein einziger Seitenbesuch eines authentifizierten Admins — kein Klick.
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"]
Folgende Aktionen sind fälschbar: