
Detection toolkit and reproducible lab for CVE-2026-87902, an unauthenticated WordPress path traversal. Includes remote checker, IoC analyzer, Sigma rules, and Docker test bench.
check/ | Contrôleur distant, passif, sans accès au serveur |
detect/ | Analyseur d'IoC + règles Sigma |
offensive/ | Générateur de traces, pour valider la détection sur des journaux réels |
docker-compose.yml + provision/ | Banc d'essai, trois configurations |
tests/validate.py | Porte de qualité — conditionne toute publication |
docs/ANALYSIS.md | La vulnérabilité, le correctif, l'analyse d'atteignabilité mesurée |
make up # monte le banc make ioc # corpus d'attaque -> journaux -> détection
make scan # contrôle le banc make test # porte de qualité
Trois instances WordPress sur 127.0.0.1, qui isolent chaque facteur du verdict.
| Port | Instance | Cœur | Thème actif | Verdict attendu |
|---|---|---|---|---|
| 8091 | vuln-pre | 6.8.1 — non corrigé | Twenty Twelve, avec page-templates/ | AFFECTED_PRECONDITION_MET |
| 8092 | vuln-nopre | 6.8.1 — non corrigé | Twenty Twenty-Four, sans page-* | AFFECTED_CORE_ONLY |
| 8093 | patched | 6.8.10 — corrigé | Twenty Twelve, avec page-templates/ | NOT_AFFECTED |
8092 est la plus instructive : même cœur vulnérable que 8091, mais le prérequis
thème manque. C'est ce qui montre qu'un triage fondé sur la seule version surestime
l'exposition. Le thème est la seule variable entre 8091 et 8092, le correctif
la seule entre 8091 et 8093 : les trois embarquent le même type de contenu
personnalisé (provision/mu-plugins/00-lab-cpt.php) et la même sonde d'état de
requête (10-lab-debug.php, en-têtes X-Lab-* qui exposent is_page(), la valeur de
pagename vue par le chargeur et le template finalement inclus).
make up # démarre et provisionne — idempotent, relançable
make status # version de chaque instance
make down # arrêt make clean : supprime aussi les volumes
L'image Docker officielle fait pointer /var/log/apache2/access.log vers
/dev/stdout : les journaux sortent sur la sortie du conteneur, pas dans un fichier.
docker compose logs --no-log-prefix vuln-pre # accès + erreurs
docker compose logs --no-log-prefix vuln-pre > access.log # pour analyse
docker compose logs -f --no-log-prefix vuln-pre # en direct
make logs # les trois instances
Sur un serveur classique : /var/log/apache2/access.log, /var/log/nginx/access.log,
ou /home/*/logs/ chez la plupart des hébergeurs mutualisés. Le format doit inclure la
chaîne de requête — %r ou le format combined l'incluent, un LogFormat bâti sur
%U la perd, et sans elle aucune détection n'est possible.
check/wp-ghsa-7hp8-check.pyDepuis Internet, sans accès au serveur. Aucune charge, aucun traversal, aucune
écriture. Pour chaque hôte : détection WordPress, version recoupée sur cinq sources
(meta generator, flux RSS, wp-links-opml.php, readme.html, ?ver= des
ressources du cœur), thème actif, et sondage du répertoire page-*.
python3 check/wp-ghsa-7hp8-check.py --hosts-file hosts.txt --csv out.csv --json out.json
| Verdict | Signification |
|---|---|
AFFECTED_PRECONDITION_MET | Cœur vulnérable et répertoire page-* sur le thème actif. Prioritaire. |
AFFECTED_THEME_UNKNOWN | Cœur vulnérable, thème non déterminé. |
AFFECTED_CORE_ONLY | Cœur vulnérable, prérequis thème absent. À corriger quand même. |
VERSION_UNKNOWN | WordPress détecté, version masquée. |
NOT_AFFECTED | Version ≥ correctif de sa branche. |
« AFFECTÉ » signifie que le code vulnérable est présent, pas qu'un attaquant peut
exécuter du code. Voir docs/ANALYSIS.md.
--transport browser (défaut) pilote le Google Chrome installé ; les sous-requêtes
partent d'un fetch() exécuté dans la page et héritent de sa pile TLS, de son ordre
d'en-têtes HTTP/2 et de ses cookies, ce qui évite d'être filtré par un CDN avant que
l'URL soit lue. --transport direct n'utilise que la bibliothèque standard.
--scheme http|https évite le repli https → http, qui laisse sinon une ligne 400
avec un ClientHello TLS brut dans le journal de la cible.
Chaque requête est horodatée à la milliseconde dans un journal JSONL : identifiant de
session, numéro de requête, phase, URL, statut, taille, durée, IP de sortie, marqueur.
Le marqueur SECAUDIT/<nonce> part en en-tête X-Security-Audit et en suffixe
d'User-Agent — ajouté, jamais substitué, pour rester visible dans un access log
standard sans casser la signature de navigateur. Personnalisable via --marker.
Pas d'oracle distant. L'option
--probe-inclusioneffectue une comparaison différentielle sur cible inerte (wp-includes/version.php, déjà chargé au bootstrap :require_onceen ferait un no-op intégral). Sur une installation standard elle renvoieNOT_REACHABLEy compris sur un cœur vulnérable, avec des réponses identiques à l'octet près entre instance corrigée et non corrigée. Ce n'est pas une limite de l'outil : WordPress répond 404 avant de consulter la hiérarchie de templates. Démonstration chiffrée dansdocs/ANALYSIS.md, section 3.
Une regex littérale du type pagename=.*%2e%2e%2f se contourne en changeant la casse
(%2E), en encodant une fois de plus (%252e), ou en panachant littéral et encodé
(templates/..%2f../). Toute liste de motifs est incomplète par construction.
On part donc du code, pas de l'écriture de l'attaquant :
pagename subit au plus deux décodages avant d'atteindre le disque — celui de
PHP sur la chaîne de requête, puis l'urldecode() explicite de get_page_template().file_exists() doit contenir
un composant ... Sous Linux, le répertoire parent s'écrit exactement sur les deux
octets 0x2E 0x2E ; il n'existe aucune autre représentation au niveau du
système de fichiers.Donc : décoder jusqu'à point fixe et tester à chaque niveau. C'est un sur-ensemble strict de ce que fait WordPress — ajouter une couche d'encodage ne fait que déplacer la correspondance d'un niveau, que l'on traverse aussi.
python3 detect/wp-ghsa-7hp8-ioc.py access.log
docker compose logs --no-log-prefix vuln-pre | python3 detect/wp-ghsa-7hp8-ioc.py -
| Règle | Sévérité | Déclencheur |
|---|---|---|
GHSA-7hp8-traversal-pagename | CRITICAL | composant .. dans pagename, à n'importe quel niveau de décodage |
GHSA-7hp8-traversal-param | HIGH | même primitive dans un autre paramètre (thèmes et extensions appellent aussi locate_template()) |
GHSA-7hp8-traversal-path | HIGH | composant .. dans le chemin d'URL (nginx laisse passer %2f, pas Apache par défaut) |
GHSA-7hp8-overlong-encoding | MEDIUM | sur-encodage UTF-8 (%c0%ae). Inopérant sous Linux, mais jamais légitime |
GHSA-7hp8-theme-page-dir-probe | LOW | sondage d'un répertoire page-* de thème — la reconnaissance |
Règles Sigma dans detect/sigma-wp-ghsa-7hp8.yml.
Sigma ne sachant pas décoder récursivement, elles énumèrent les niveaux 0 à 3 : c'est
une approximation assumée, pour le tri de premier niveau. Repasser les
correspondances dans l'analyseur pour décider.
WP::parse_request() lit $_POST avant $_GET. pagename peut donc
arriver dans un corps de requête, absent de tout access log. Couverture nécessaire au
niveau WAF ou ModSecurity, sur le corps.Aucune règle sur access log ne couvre le premier point. C'est une limite du support, pas de la règle — mais elle doit être connue avant d'annoncer une couverture complète.
offensive/generate-traces.py rejoue 12 écritures différentes de la même charge
(littérale, simple/double/triple encodage, casse haute et basse, panachages,
sur-encodage UTF-8, point-espace) plus 7 requêtes légitimes qui leur ressemblent
(slug à points, wp-includes dans un slug, permalien à date, pourcent encodé).
Il n'obtient rien : il n'existe pas d'exploit distant pour ce vecteur sur une installation standard. Il produit des traces — c'est tout son objet.
make ioc # génère le corpus, récupère les journaux réels, passe l'analyseur
Attendu, et vérifié par make test sur des journaux Apache réels :
12 charges détectées sur 12 (11 CRITICAL, le sur-encodage en MEDIUM parce qu'il
n'est pas exploitable sous Linux), 0 alerte sur les 7 requêtes légitimes, et
détection effective à trois niveaux de décodage différents.
Cible limitée au banc local ; toute autre exige --i-have-authorization.
register_argc_argv = Off dans le php.ini du SAPI web ; désinstaller PEAR si
inutilisé — c'est le pivot inclusion → exécution cité par l'avis.open_basedir limité à la racine du site : confine toute inclusion locale.make test est la condition de publication : matrice des 25 branches de l'avis
(pièges de comparaison inclus — 6.8.9 < 6.8.10 numériquement, pré-versions,
branches hors matrice), contrôleur contre les trois instances, et règle IoC validée
sur journaux réels. L'assertion NOT_REACHABLE de la sonde y est figée volontairement :
si elle casse, c'est le comportement qui a changé et l'analyse doit être reprise.
À n'utiliser que sur des actifs dont vous avez la responsabilité, ou sous mandat écrit.
Le banc n'écoute que sur 127.0.0.1 ; les instances vulnérables ne doivent jamais être
exposées. La sonde 10-lab-debug.php divulgue des chemins serveur : elle est réservée
au banc.