
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.
Una sola visita de un administrador a una página elimina contenido, inyecta páginas y suelta un webshell
De un vistazo · Resumen · Causa raíz · Cadena de ataque · Exploit · Remediación · Cronología
| 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: