# REPRO-2026-00356: WordPress Core unauthenticated path traversal in get_page_template() page-template resolution leading to conditional RCE ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-98 Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00356 GHSA: GHSA-7hp8-65ch-5whp CVE: CVE-2026-87902 ## Package Name: WordPress/wordpress-develop Ecosystem: github Affected: 4.7.0 through 7.1.1 Fixed: 7.1.2 ## Root Cause # RCA Report — CVE-2026-87902 / GHSA-7hp8-65ch-5whp ## Summary WordPress Core's `get_page_template()` (wp-includes/template.php) builds a candidate template filename from the `pagename` query variable after applying `urldecode()` to it, without the `validate_file()` guard that is applied to the sibling `$template` candidate three lines above. Because percent-encoded octets survive WordPress's query sanitization (`sanitize_title_for_query()` preserves `%xx` sequences) and the `pagename` query variable is settable by unauthenticated visitors, a double-url-encoded traversal payload is decoded inside `get_page_template()` and assembled as `page-{decoded}.php`. `locate_template()` then resolves and includes a readable local `.php` file outside the active theme directories. When the server also ships a useful local PHP target — PEAR's `pearcmd.php` with `register_argc_argv=On` (the default in the official PHP/WordPress Docker images) — the file inclusion becomes remote code execution via the well-known `pearcmd.php` `config-create` webshell-write transition. ## Impact - Package/component: WordPress Core (wordpress-develop), `wp-includes/template.php`, `get_page_template()` / `locate_template()`. - Affected versions: 4.7.0 through 7.1.1 (all branches); fixed in 7.1.2 with backports (7.0.6, 6.9.9, 6.8.10, 6.7.9, ... down to 4.7.37). - Risk: Critical (CVSS 4.0 9.2, CWE-98). Unauthenticated local PHP file inclusion, escalable to remote code execution when (1) the active theme has a top-level directory whose name starts with `page-` (e.g. Twenty Twelve's `page-templates/`) and (2) a readable local `.php` target such as `/usr/local/lib/php/pearcmd.php` exists. - Consequences demonstrated: arbitrary OS command execution as `www-data` (`uid=33(www-data)`) on the web server, i.e. full site compromise. ## Impact Parity - Disclosed/claimed maximum impact: unauthenticated remote code execution (claim: `api_remote` surface, `code_execution` impact). - Reproduced impact from this run: unauthenticated remote code execution through the real Apache/HTTP front door of WordPress 7.1.1 — an attacker-chosen OS command (`echo PRUVA-CMD-;id`) executed as `uid=33(www-data)` via a webshell that the include-chain wrote into the webroot, plus the underlying local file inclusion primitive proven independently with an out-of-theme marker file. - Parity: **full**. - Not demonstrated: nothing beyond the claimed impact (no persistence/privilege escalation beyond the web user, which is out of scope for the claim). ## Root Cause In WordPress ≤ 7.1.1, `get_page_template()` contains: ```php if ( $template && 0 === validate_file( $template ) ) { // validated $templates[] = $template; } if ( $pagename ) { $pagename_decoded = urldecode( $pagename ); if ( $pagename_decoded !== $pagename ) { // NOT validated (the bug) $templates[] = "page-{$pagename_decoded}.php"; } $templates[] = "page-{$pagename}.php"; } ``` Reachability chain (all unauthenticated, plain-permalink front-controller request): 1. `pagename` is a public query var; `?pagename=...` lands in `$wp->query_vars` after exactly one URL decode by PHP. 2. Double-encoding keeps octets alive: `sanitize_title_for_query()` preserves `%xx` sequences, so the query var still contains `%2e%2e%2f...` after `WP_Query` processing. 3. The 404 trap is avoided with `page_id`: in `WP_Query::get_posts()` (class-wp-query.php ≈ line 2276) a valid `page_id` **overwrites** the `WHERE` clause (`$where = " AND ID = "`), so the query returns the real page, `is_page` survives `WP::handle_404()`, and the template loader reaches `is_page() → get_page_template()`. 4. `get_page_template()` applies its own second `urldecode()`, turning `templates%2f%2e%2e%2f...` into `templates/../../../../usr/local/lib/php/pearcmd`, and prepends `page-` / appends `.php`. The leading `page-` is why the traversal must start inside a theme directory that literally starts with `page-` (Twenty Twelve's `page-templates/`). 5. `locate_template()` sees `file_exists(/page-templates/../../../../usr/local/lib/php/pearcmd.php) == true` and returns the path; template-loader.php `include`s it. 6. With `register_argc_argv=On` (default in the official PHP Docker images), `$_SERVER['argv']` is built from the raw query string split on `+`, so `&+config-create+/+/var/www/html/.php` makes the included `pearcmd.php` write an attacker-controlled PHP config file into the webroot. A second unauthenticated GET to that file executes arbitrary commands. Fix (7.1.2, diff verified against the official release): adds `0 === validate_file( $pagename_decoded )` to the pagename branch and introduces `_wp_is_template_path_allowed()`, a containment check (realpath must stay inside the stylesheet/template directories or `wp-includes/theme-compat`) applied to every candidate resolved by `locate_template()`. Fix reference: wordpress-develop `7.1.1...7.1.2` diff, files `src/wp-includes/template.php`, `src/wp-includes/version.php` (GHSA-7hp8-65ch-5whp, reporter Robert Ressl). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; only needs Docker + network). 2. The script: - pulls `wordpress:7.1.1-apache` and `mysql:8.0`, - builds a fixed image by replacing `/usr/src/wordpress` with the official `wordpress-7.1.2.tar.gz` (keeping `wp-config-docker.php`), - records precondition evidence (WP versions, `register_argc_argv=On`, `pearcmd.php` presence, absence/presence of the patch hunks), - starts MySQL + both WordPress instances (vulnerable on :18081, fixed on :18082), performs the real web installer flow over HTTP, installs and activates the legacy Twenty Twelve theme (advisory-named precondition, top-level `page-templates/`), - places a readable marker PHP at `/opt/pruva_lfi_marker.php` (out-of-theme LFI proof target), - twice attacks the vulnerable instance with an unauthenticated `GET /?page_id=2&pagename=templates%252f%252e%252e...%252fusr%252flocal%252flib%252fphp%252fpearcmd&+config-create+/+/var/www/html/.php`, then fetches the written shell with `?c=echo+PRUVA-CMD-;id`, - twice replays the identical attack against the fixed instance as a negative control, - writes `bundle/repro/runtime_manifest.json` with sha256-bound proof artifacts. 3. Expected evidence: both vulnerable attempts return the per-attempt marker `PRUVA-CMD-` and `uid=33(www-data)`; both fixed attempts show no marker and no shell (HTTP 404 for the shell, no LFI marker in the traversal response). ## Evidence - Preconditions/patch diff evidence: `bundle/logs/reproduction_preconditions.log` (vuln: `$wp_version = '7.1.1'`, `register_argc_argv=1`, pearcmd.php present, 0 occurrences of `validate_file( $pagename_decoded )`; fixed: 7.1.2 with the guard and `_wp_is_template_path_allowed`). - Run log: `bundle/logs/reproduction_steps.log` (`[vuln attempt 1] ... RCE=CONFIRMED LFI=CONFIRMED`, `2/2`, fixed `2/2 blocked`). - Per-attack request/response pairs: `bundle/logs/vuln_attempt{1,2}_{request,response}.txt` (response ends with `Successfully created default configuration file "/var/www/html/pruva_rce_vuln*_*.php"`). - Command-execution output: `bundle/logs/vuln_attempt{1,2}_command.txt` containing `PRUVA-CMD-` and `uid=33(www-data) gid=33(www-data) groups=33(www-data)`. - LFI primitive: `bundle/logs/vuln_attempt{1,2}_lfi_response.txt` begins with `PRUVA-LFI-MARKER-87902` (out-of-theme `/opt/pruva_lfi_marker.php` included). - Negative control: `bundle/logs/fixed_attempt{1,2}_*.txt` — no marker, shell GET 404. - Environment: Apache 2.4.68 / PHP 8.3.33 (mod_php, official `wordpress:7.1.1-apache` image, digest recorded in runtime_manifest.json), MySQL 8.0, Twenty Twelve 4.9, plain permalinks, default options. - Structured runtime evidence: `bundle/repro/runtime_manifest.json`. ## Recommendations / Next Steps - Upgrade to WordPress 7.1.2 (or the corresponding backport: 7.0.6, 6.9.9, 6.8.10, 6.7.9, 6.6.9, ... 4.7.37). - The fix approach is correct and sufficient: validate the decoded pagename with `validate_file()` and enforce theme-containment (`_wp_is_template_path_allowed()`) for every template path `locate_template()` resolves. - Defense-in-depth: set `register_argc_argv=Off` for web SAPIs (php.ini-production default) to neutralize the pearcmd.php transition; remove PEAR from web images; avoid activating classic themes with top-level `page-*` directories on unpatched installs. - Testing: regression test that `page-%2e%2e%2f...`-style pagenames (single- and double-encoded) never resolve outside the theme for both classic and block themes. ## Additional Notes - Idempotency: the script tears down and recreates its Docker network/containers on every run, reuses cached downloads (`/pruva/project-cache/packages`), rebuilds the fixed image only if missing/stale, and was executed twice consecutively with identical `CONFIRMED` results (exit 0). - Implementation details discovered empirically: PHP builds `$_SERVER['argv']` from the raw query string split on `+` and mangles single quotes, so the webshell payload is quote-free (``); `curl -g` is required because the payload otherwise triggers curl URL globbing. - The vulnerable path requires no authentication, no plugins, and default options; the only non-default elements are the documented preconditions (a `page-*` top-level theme directory — provided by the advisory-named Twenty Twelve theme — and a readable local `.php` target, provided by the stock official PHP/WordPress image). ## Reproduction Details Reproduced: 2026-09-22T19:34:53.946Z Duration: 4056 seconds Tool calls: 161 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00356 pruva-verify GHSA-7hp8-65ch-5whp pruva-verify CVE-2026-87902 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00356&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00356/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - GitHub Advisory: https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-87902 - Source: https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp ## Artifacts - bundle/repro/rca_report.md (analysis, 9814 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 20923 bytes) - bundle/logs/fixed_attempt1_command_request.txt (other, 103 bytes) - bundle/logs/fixed_attempt1_lfi_request.txt (other, 275 bytes) - bundle/logs/fixed_attempt1_lfi_response.txt (other, 36432 bytes) - bundle/logs/fixed_attempt1_request.txt (other, 384 bytes) - bundle/logs/fixed_attempt1_response.txt (other, 36432 bytes) - bundle/logs/fixed_attempt2_command.txt (other, 314 bytes) - bundle/logs/fixed_attempt2_command_request.txt (other, 103 bytes) - bundle/logs/fixed_attempt2_lfi_request.txt (other, 275 bytes) - bundle/logs/fixed_attempt2_lfi_response.txt (other, 36432 bytes) - bundle/logs/fixed_attempt2_request.txt (other, 384 bytes) - bundle/logs/fixed_attempt2_response.txt (other, 36432 bytes) - bundle/logs/reproduction_preconditions.log (log, 509 bytes) - bundle/logs/vuln.homepage.html (other, 34159 bytes) - bundle/logs/vuln_attempt1_command_request.txt (other, 102 bytes) - bundle/logs/vuln_attempt1_lfi_request.txt (other, 275 bytes) - bundle/logs/vuln_attempt2_command_request.txt (other, 102 bytes) - bundle/logs/vuln_attempt2_lfi_request.txt (other, 275 bytes) - bundle/logs/vuln_attempt2_lfi_response.txt (other, 23 bytes) - bundle/logs/vuln_attempt2_request.txt (other, 383 bytes) - bundle/logs/vuln_attempt2_response.txt (other, 2688 bytes) - bundle/repro/runtime_manifest.json (other, 4973 bytes) - bundle/repro/validation_verdict.json (other, 1674 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00356 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00356/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00356 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev