Labo de reproduction + scanner de liste d'URL + PoC pour CVE-2026-87902 / GHSA-7hp8-65ch-5whp — LFI non authentifié via get_page_template() de WordPress menant à une RCE conditionnelle (WP 4.7.0-7.1.1, corrigé en 7.1.2). Tests autorisés/défensifs.
get_page_template() de WordPress → RCE conditionnelLab de reproduction + scanner de liste d'URL + PoC, construit et validé de bout en bout contre de véritables WordPress 7.1.1 (vulnérable) et 7.1.2 (corrigé) sur le lab Docker.
include/require (traversée de chemin → inclusion PHP locale)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* (par ex. page-templates/ — présent dans Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney) ; (2) pour la RCE uniquement : un pearcmd.php lisible et register_argc_argv=On.wp-includes/template.php :: get_page_template() construit un candidat de template à partir de la
query var pagename contrôlée par l'attaquant sans validate_file() :
// WordPress 7.1.1 (VULNERABLE)
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) { // <-- no validate_file()
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
// WordPress 7.1.2 (PATCHED) — the guard the sibling $template branch already had, + realpath containment
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// plus new _wp_is_template_path_allowed() enforcing the resolved path stays inside a theme root
locate_template() fait ensuite file_exists($theme_dir . '/' . $candidate) et include le fichier trouvé.
Comme le candidat est page-{...}.php, la charge utile doit prolonger un répertoire de thème réel qui
commence par page- (par ex. page-templates/) puis remonter avec ../ vers n'importe quel .php lisible.
Le double encodage est obligatoire. get_query_var('pagename') est déjà décodé une fois par PHP, donc un
simple ../ fait que urldecode($pagename) === $pagename et la branche vulnérable est ignorée. Un
%252e%252e%252f doublement encodé survit au premier décodage sous la forme %2e%2e%2f et n'est transformé en ../
que par le urldecode() supplémentaire — c'est là le bug.
lab/)De véritables versions côte à côte sur l'hôte Docker, ne différant que par le correctif de sécurité :
| Service | URL (loopback uniquement) | WordPress | Rôle |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | vulnérable |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | témoin corrigé |
db | — | MySQL 8.4 | partagé (deux bases de données) |
Image de base wordpress:php8.3-apache (qui embarque déjà pearcmd.php et register_argc_argv=On),
avec le cœur fourni remplacé par les authentiques wordpress-7.1.1.zip / 7.1.2.zip. Twenty Fourteen est
activé (véritable page-templates/), et un fixture page-templates/ est également créé dans le thème actif.
ID de page publiée = 2 (Sample Page).
# on the docker lab host
cd /tmp/cve-2026-87902-lab
./up.sh # build + install both instances (idempotent)
./down.sh # tear down + remove volumes
Les ports se lient à 127.0.0.1 uniquement — l'instance vulnérable n'est jamais exposée au réseau.
poc/cve-2026-87902-scan.py)Python 3, stdlib uniquement (pas de pip install). Prend une liste d'URL et indique lesquelles sont
vulnérables. Le scan par défaut est non destructif : il inclut le fichier cœur en lecture seule
wp-links-opml.php et recherche le document OPML résultant — preuve que l'inclusion arbitraire de .php
s'est déclenchée, sans écriture ni changement d'état.
# single URL
./cve-2026-87902-scan.py http://target/
# a list of your assets, JSON report, 20 workers
./cve-2026-87902-scan.py -f urls.txt --threads 20 --json report.json
# from stdin, only show vulnerable/possibly rows
cat urls.txt | ./cve-2026-87902-scan.py --stdin -q
<generator> → wp-links-opml.php →
readme.html → asset /wp-includes/ ?ver=)./wp/v2/pages, repli ?rest_route=, page d'accueil
page-id-N, défaut page_id=2) — nécessaire pour que la requête se résolve en une Page et que get_page_template() s'exécute.segment × profondeur (segment par défaut templates, profondeurs 4,3,5,6,7), envoyer
page_id=<id>&pagename=<../ doublement encodé → wp-links-opml> (POST, pour éviter la redirection canonique) et
exiger un HTTP 200 portant <opml version="1.0"> + un marqueur secondaire structurel (</opml> / <outline / <dateCreated>)..php garanti inexistant. Si l'OPML apparaît encore, l'OPML est ambiant (proxy / cache / application de flux),
et non notre inclusion → rétrogradé en POSSIBLY. Seul un succès dont le contrôle est propre est VULNERABLE.Robustesse : préserve les chemins de sous-répertoire (http://host/blog), gère gzip/deflate et les charsets
inhabituels, réessaie une sonde une fois en cas d'erreur de transport, découvre/valide les ID de page (REST → ?rest_route= → page d'accueil
→ défauts), impose un budget de temps par cible, et n'affirme jamais NOT_VULNERABLE à partir d'une source de
version à faible confiance (asset ?ver= / readme.html) — celles-ci dégradent en POSSIBLY.
| Verdict | Signification |
|---|---|
VULNERABLE | L'oracle OPML s'est déclenché — LFI confirmée (définitif) |
NOT_VULNERABLE | version de la branche corrigée, ou version hors 4.7.0–7.1.1 |
POSSIBLY_VULNERABLE | version vulnérable/inconnue mais oracle silencieux (probablement pas de répertoire de thème page-*, disposition non standard, ou aucun ID de page découvrable) — vérifier manuellement |
NOT_WORDPRESS / ERROR | aucun indicateur WP / échec de transport |
Code de sortie : 2 si au moins un VULNERABLE, 1 si au moins un POSSIBLY (et aucun VULNERABLE), sinon 0.
Options utiles : --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids,
--max-time (budget par cible), --timeout, --threads, --proxy, --header, --insecure
(TLS désactivé — dev uniquement), --json, --jsonl. Pour une installation WordPress dans un sous-répertoire, passer la base complète
(par ex. https://host/blog) ; pour Bedrock/cœur dans wp/, le balayage essaie aussi les cibles d'oracle préfixées par wp/.
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
Exécute la chaîne PEAR pearcmd.php : la chaîne de requête séparée par + transporte les argv config-create qui écrivent
un marqueur .php sans guillemets sous /tmp ; une seconde requête l'inclut. Affiche le marqueur exécuté +
php_uname() + uid. Écrit un fichier sur la cible → cible unique, nécessite --i-have-authorization,
désactivé par défaut.
evidence/)