
Avis de sécurité et PoC Python pour Pluck CMS CSRF : une vérification du Referer en fail-open associée à un téléversement à double extension permet le déploiement d'un webshell et l'exécution de code à distance.
Une seule visite de page d'un administrateur supprime du contenu, injecte des pages et dépose un webshell
En bref · Résumé · Cause racine · Chaîne d'attaque · Exploit · Remédiation · Chronologie
| Identifiant CVE | CVE-2026-70376 |
| Identifiant de suivi | PT-2026-68036 |
| Produit | pluck-cms/pluck — Pluck CMS (PHP à fichiers plats) |
| Versions affectées | 4.7.x jusqu'à 4.7.21-dev / master actuel |
| Faiblesse | CWE-352 (CSRF) · CWE-434 (téléversement sans restriction, amplificateur) |
| CVSS v3.1 | 8.0 — Élevée · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Vecteur | Réseau · aucun privilège · une visite de page d'administrateur (UI:R) |
| Impact | Destruction de contenu/DoS, injection de contenu stocké, RCE sur Apache/mod_php |
| Introduction | commit f79f916 (déc. 2019) — logique vulnérable présente depuis |
| Chercheur | Ilhomjon Rustamov (@IlhomjonR) |
Le panneau d'administration de Pluck ne comporte aucun jeton CSRF par requête, nulle part dans le code.
Chaque action d'administration modifiant l'état est protégée par une seule fonction,
requestedByTheSameDomain(), dont la seule défense est une comparaison de l'hôte du Referer
— et cette vérification échoue en mode fail-open : lorsqu'une requête arrive sans en-tête
Referer du tout, la fonction renvoie true et l'action est autorisée.
Comme une page attaquante contrôle entièrement l'envoi ou non d'un Referer
(<meta name="referrer" content="no-referrer">), tout administrateur connecté qui
visite une page malveillante peut être contraint d'exécuter des actions privilégiées en cross-site.
Plusieurs actions destructrices s'exécutent en GET, et Pluck ne définit aucun attribut
SameSite sur PHPSESSID (les navigateurs appliquent SameSite=Lax, qui est tout de même transmis
lors des navigations GET de premier niveau). Elles sont donc accessibles en cross-site avec les
paramètres de navigateur par défaut.
Une faiblesse secondaire du filtre de téléversement permet au même CSRF de déposer un
fichier à double extension shell.php.jpg — transformant le CSRF en RCE par drive-by
sur les hôtes 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
}
}
Une requête cross-site avec un Referer étranger est correctement rejetée (branche
else), ce qui crée une fausse impression de protection — mais l'attaquant se contente
de supprimer le Referer, atteint la branche fail-open, et la requête est autorisée.
Aucune couche de jeton ne vient derrière cette vérification.
Le contrôle est appliqué une seule fois dans admin.php et fait foi pour l'intégralité du
switch d'actions :
$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 sur le cookie de session (amplificateur)Pluck n'appelle jamais session_set_cookie_params(), donc PHPSESSID hérite de la valeur
par défaut vide → les navigateurs appliquent SameSite=Lax, qui est tout de même transmis
lors des navigations GET de premier niveau. Les actions exposées en GET (deletefile,
deleteimage, deletepage, module_delete, theme_delete, logout) sont donc
forgeables en une seule visite de page.
data/inc/images.php (amplificateur 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']));
// ...
}
}
Les deux vérifications sont trivialement contournables :
$_FILES[...]['type']) → définir image/jpeg ;shell.php.jpg
se termine par .jpg et passe.Le fichier est écrit dans images/shell.php.jpg ; sur un hôte Apache/mod_php avec
gestion des extensions multiples, il s'exécute en PHP.
Une seule visite de page d'un administrateur authentifié — aucun 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"]
Les actions forgeables incluent :