
Advisory e PoC Python per Pluck CMS CSRF: il controllo Referer fail-open e il caricamento a doppia estensione consentono l'implementazione di webshell e l'esecuzione remota di codice.
Una singola visita di una pagina da parte di un admin elimina contenuti, inietta pagine e rilascia una webshell
A colpo d'occhio · Riepilogo · Causa principale · Catena d'attacco · Exploit · Rimedi · Cronologia
| ID CVE | CVE-2026-70376 |
| ID di tracciamento | PT-2026-68036 |
| Prodotto | pluck-cms/pluck — Pluck CMS (flat-file PHP) |
| Versioni affette | 4.7.x fino a 4.7.21-dev / master attuale |
| Debolezza | CWE-352 (CSRF) · CWE-434 (upload senza restrizioni, amplificatore) |
| CVSS v3.1 | 8.0 — Alto · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Vettore | Rete · nessun privilegio · una sola visita di pagina da parte di un admin (UI:R) |
| Impatto | Distruzione di contenuti/DoS, iniezione di contenuti persistenti, RCE su Apache/mod_php |
| Introdotta | commit f79f916 (dic 2019) — logica vulnerabile presente da allora |
| Ricercatore | Ilhomjon Rustamov (@IlhomjonR) |
Il pannello di amministrazione di Pluck non ha token CSRF per-request in nessun punto del codebase.
Ogni azione amministrativa che modifica lo stato è protetta da una singola funzione,
requestedByTheSameDomain(), la cui unica difesa è un confronto dell'host del Referer
— e quel controllo fallisce in modalità fail-open: quando una richiesta arriva senza alcuna
intestazione Referer, la funzione restituisce true e l'azione viene consentita.
Poiché una pagina dell'attaccante controlla completamente se viene inviato un Referer
(<meta name="referrer" content="no-referrer">), qualsiasi amministratore autenticato che
visiti una pagina dannosa può essere costretto a eseguire azioni privilegiate cross-site.
Diverse azioni distruttive vengono eseguite tramite GET e Pluck non imposta nessun
attributo SameSite su PHPSESSID (i browser applicano SameSite=Lax, che viene comunque inviato
nelle navigazioni GET di primo livello), quindi sono raggiungibili cross-site con le
impostazioni predefinite del browser.
Una debolezza secondaria nel filtro di upload consente allo stesso CSRF di collocare
un file a doppia estensione shell.php.jpg — trasformando il CSRF in una RCE drive-by
su host 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
}
}
Una richiesta cross-site con un Referer esterno viene correttamente respinta (il ramo else),
il che crea un falso senso di protezione — ma l'attaccante si limita a
sopprimere il Referer, raggiunge il ramo fail-open e la richiesta viene consentita.
Dietro questo controllo non esiste alcun livello di token.
Il controllo viene applicato una volta in admin.php e considerato attendibile per l'intero switch delle azioni:
$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 sul cookie di sessione (amplificatore)Pluck non chiama mai session_set_cookie_params(), quindi PHPSESSID eredita il
valore predefinito vuoto → i browser applicano SameSite=Lax, che viene comunque inviato
nelle navigazioni GET di primo livello. Le azioni esposte via GET (deletefile, deleteimage, deletepage,
module_delete, theme_delete, logout) sono quindi falsificabili con una singola
visita di pagina.
data/inc/images.php (amplificatore 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']));
// ...
}
}
Entrambi i controlli possono essere aggirati banalmente:
$_FILES[...]['type']) → impostare image/jpeg;shell.php.jpg
termina con .jpg e supera il controllo.Il file viene scritto in images/shell.php.jpg; su un host Apache/mod_php con
gestione delle estensioni multiple viene eseguito come PHP.
Una sola visita di pagina da parte di un admin autenticato — nessun clic.
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"]
Le azioni falsificabili includono: