
Advisory and Python PoC for Pluck CMS CSRF: fail-open Referer check plus double-extension upload enables webshell deployment and remote code execution.
A single admin page-visit deletes content, injects pages, and drops a webshell
At a glance · Summary · Root cause · Attack chain · Exploit · Remediation · Timeline
| CVE ID | CVE-2026-70376 |
| Tracking ID | PT-2026-68036 |
| Product | pluck-cms/pluck — Pluck CMS (flat-file PHP) |
| Affected | 4.7.x through 4.7.21-dev / current master |
| Weakness | CWE-352 (CSRF) · CWE-434 (unrestricted upload, amplifier) |
| CVSS v3.1 | 8.0 — High · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Vector | Network · no privileges · one admin page-visit (UI:R) |
| Impact | Content destruction/DoS, stored-content injection, RCE on Apache/mod_php |
| Introduced | commit f79f916 (Dec 2019) — vulnerable logic present since |
| Researcher | Ilhomjon Rustamov (@IlhomjonR) |
The Pluck admin panel has no per-request CSRF tokens anywhere in the codebase.
Every state-changing admin action is gated by a single function,
requestedByTheSameDomain(), whose only defense is a Referer-host comparison
— and that check fails open: when a request arrives with no Referer
header at all, the function returns true and the action is allowed.
Because an attacker page fully controls whether a Referer is sent
(<meta name="referrer" content="no-referrer">), any logged-in administrator who
visits a malicious page can be forced to perform privileged actions cross-site.
Several destructive actions run on GET, and Pluck sets no SameSite
attribute on PHPSESSID (browsers apply SameSite=Lax, which still rides along
on top-level GET navigations), so they are reachable cross-site under default
browser settings.
A secondary upload-filter weakness lets the same CSRF plant a
shell.php.jpg double-extension file — turning the CSRF into a drive-by RCE
on Apache/mod_php hosts.
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
}
}
A cross-site request with a foreign Referer is correctly rejected (the else
branch), which creates a false sense of protection — but the attacker simply
suppresses the Referer, hits the fail-open branch, and the request is allowed.
There is no token layer behind this check.
The gate is applied once in admin.php and trusted for the whole action switch:
$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 on the session cookie (amplifier)Pluck never calls session_set_cookie_params(), so PHPSESSID inherits the empty
default → browsers apply SameSite=Lax, which still rides along on top-level GET
navigations. GET-exposed actions (deletefile, deleteimage, deletepage,
module_delete, theme_delete, logout) are therefore forgeable with a single
page visit.
data/inc/images.php (RCE amplifier)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']));
// ...
}
}
Both checks are trivially bypassed:
$_FILES[...]['type']) → set image/jpeg;shell.php.jpg
ends in .jpg and passes.The file is written to images/shell.php.jpg; on an Apache/mod_php host with
multi-extension handling it executes as PHP.
One authenticated admin page-visit — no click.
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"]
Forgeable actions include:
| Action | Method | Impact |
|---|---|---|
admin.php?action=deletefile&var1=<f> | GET | Delete arbitrary uploaded file |
admin.php?action=deletepage&... | GET | Delete site pages (DoS) |
admin.php?action=module_delete&... | GET | Remove modules |
admin.php?action=logout | GET | Log the admin out |
admin.php?action=editpage | POST | Inject stored page content |
admin.php?action=images (upload) | POST | Plant shell.php.jpg → RCE |
A working PoC toolkit lives in exploit/:
pluck_csrf_rce.py — prove the fail-open logic, CSRF-upload a webshell and
run commands, CSRF-delete files, or generate a lure.csrf_poc.html — the standalone drive-by page delivered to a victim admin.pip install requests