The Guard Was Three Lines Above
In WordPress 7.1.1 and every release back to 4.7.0, get_page_template() in wp-includes/template.php builds a list of candidate template filenames and hands them to the template loader. One candidate came from the request. It was built with urldecode() from the pagename query variable and appended to the list without ever being passed through validate_file(), WordPress’s own path traversal check. The line that did call validate_file() sat three lines above it, in a sibling branch that had been protected the whole time.
The missing check is CVE-2026-87902: unauthenticated local file inclusion in page template resolution, with a conditional path to remote code execution. WordPress fixed it in 7.1.2, released September 22, 2026, and the release note is blunt about the class of bug (“a critical severity security vulnerability”, backported to every branch still receiving security fixes, currently down to 4.7). The advisory, GHSA-7hp8-65ch-5whp, lists the affected range as 4.7.0 through 7.1.1, credits Robert Ressl for the disclosure, and rates it Critical at CVSS 4.0 9.2.
Two facts about the week matter more than the score. Exploitation attempts began the same day the patch shipped, and the whole chain from first probe to a file on disk took under four hours. Patchstack published a follow-up on what its firewall saw, and the first attempts arrived at 11:49 UTC on September 22, the day 7.1.2 was published. The first attempt to write a file to disk came at 15:34 UTC.
Two branches, one check
Patchstack’s day-one analysis is the clearest public description of the sink, and the vulnerability is short enough to read directly:
// wp-includes/template.php, get_page_template(), WordPress <= 7.1.1
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";
}
Read the two blocks as a pair. The candidate built from $template is checked. The candidates built from $pagename, which is attacker-controlled request data, are not. The urldecode() call is what makes the second branch worse than an oversight: WordPress runs the slug through its own sanitiser before this point, and that sanitiser deliberately preserves percent-encoded octets while rewriting literal dots and truncating at literal slashes. A literal ../../ payload does not survive it. A percent-encoded one does, and then get_page_template() decodes it back into a real traversal. Patchstack’s detection guidance follows from the same detail: %2e%2e in a pagename value is the high-signal string.
Three constraints decide what an attacker can actually reach, and they are worth stating because they explain why this is not a universal one-request compromise:
- The filename is assembled as
"page-{$pagename}.php", so the payload has to continue a directory whose name starts withpage-, and the target file must end in.phpbecause the extension is appended. - That means the condition on the theme side is a top-level directory such as
page-templates. Per the advisory, that includes the legacy Twenty Twelve and Twenty Fourteen themes plus third-party themes such as Neve, Hestia and Sydney. - The request also has to carry a
page_idthat resolves to a real page. Without it WordPress serves a 404 and the template loader never runs the vulnerable branch. The first observed payloads includedpage_idfor exactly that reason, which tells you the person who wrote them had read the code path rather than copied a scanner’s defaults.
From an inclusion to a file on disk
An inclusion of a local .php file is not code execution of the attacker’s choosing. It runs whatever that file does, which is why the interesting primitive is the well known PEAR transition. pearcmd.php becomes useful when PHP runs with register_argc_argv enabled, because the query string is then exposed to the included script as $argv.
That gives the chain three stages, all of which Patchstack observed in its own traffic:
- Point the inclusion at an ordinary core file such as
wp-links-opml.phpand read the response to see whether the host is vulnerable. - Point it at
pearcmd.phpwith+config-showappended, trying/usr/local/lib/php/pearcmd.php,/usr/share/php/pearcmd.phpand/usr/share/pear/pearcmd.php, to confirm PEAR is present and the argv trick works. - Swap
config-showforconfig-createand have PEAR write a file with attacker-controlled PHP content to a path the attacker chooses.
The preconditions that make stage three work are not exotic. register_argc_argv is on by default in the official PHP Docker images, and in cPanel environments running PHP below 8.5. Patchstack’s own framing is the honest one: unauthenticated file inclusion always, code execution when the host happens to line up, and plenty of hosts line up. The files it observed being dropped include wp-pear-rce-flag.php, poc87902.php and random luci_<random>.php and zeta_<random>.php names in /tmp and /var/tmp, with payloads split between harmless marker strings and short tags that execute a shell command on access. A file in /tmp is usually not web-reachable, so on its own it is proof of execution rather than a persistent backdoor. The same primitive writes somewhere more useful on the next request.
The clock on the diff
Patchstack’s assessment is that whoever built the first payloads was working from the diff rather than from an independent discovery, because the requests match the exact encoding the patch addresses. That is an inference from payload shape, not a proven provenance, and it is the kind of inference the timeline makes tempting.
| Time (UTC) | Event |
|---|---|
| Sep 22, 2026 | WordPress 7.1.2 released; GHSA-7hp8-65ch-5whp published; CVE-2026-87902 assigned; Patchstack adds a mitigation rule |
| Sep 22, 11:49 | First exploitation attempt observed, probing core files for the inclusion |
| Sep 22, 15:34 | First attempt to write a file to disk through pearcmd |
| Sep 23 | Public scanning tooling in circulation with a named Nuclei template; traffic volume peaks around midday |
| Sep 25 | CISA adds the CVE to the KEV catalog, due date September 28 |
Two details from that traffic are the ones an operator should keep. The first is that the scanning population arrived before any public proof of concept did. Two user agents in the traffic, cve-2026-87902-poc/1.0 and nuclei-cve-2026-87902/1.0, show that the technique had moved from a handful of operators working a patch diff to anyone who can point a template at a host list, inside 24 hours. The second is that most of the remaining traffic uses spoofed browser user agents, so the honest ones are a minority and user agent is not a filter. The sources moved from a small cluster to a few hundred addresses, which is why Patchstack does not recommend blocklisting individual hosts as a strategy. Blocking what the exploit looks like is a stopgap. Breaking the chain is a control.
CISA’s X post announcing the KEV addition on September 25 reads in full: “We added WordPress Core remote file inclusion vulnerability CVE-2026-87902 to our KEV Catalog. Visit https://t.co/myxOwap1Tf & apply mitigations to protect your org from cyberattacks. #Cybersecurity #InfoSe”. The catalog entry sets a due date of September 28, marks forensic triage as required, and records ransomware campaign use as unknown. That is a three day window from listing to deadline, for a bug that reaches back to a release older than most of the sites running it.
Three sources, three severities
The same bug carries three different framings, and the mismatch is instructive rather than a scandal:
- The advisory and the vendor score it Critical, CVSS 4.0 9.2, vector
AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N, mapped to CWE-98, improper control of filename for an include statement. - NVD rates it 8.1 on CVSS 3.1 with attack complexity High, and as of this writing still carries the status Undergoing Analysis.
- The KEV catalog names it a remote file inclusion in WordPress Core, which is the network-facing description, while the advisory calls it an unauthenticated path traversal in page template resolution leading to conditional RCE, which is the code-level description.
None of them is wrong. The CVSS 4.0 metric that matters here is Attack Requirements, which is Present: the attack needs the environment to cooperate with a specific theme layout and a PHP setting. Version 3.1 has no equivalent field, so it expresses the same doubt as attack complexity High and lands more than a point lower. If you rank your work by whichever number your scanner imports, you can talk yourself out of a three-day KEV deadline. The affected range and the observed exploitation are the parts that do not move.
The second half of the fix
The one-line fix restores the check the sibling branch already had:
// wp-includes/template.php, WordPress 7.1.2
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
The more interesting change is the one nobody asked for. 7.1.2 also introduces _wp_is_template_path_allowed(), a containment check that every resolved template path must pass regardless of which code path produced it: paths without .. are allowed, and anything else has to resolve, through realpath(), to somewhere inside the stylesheet directory, the template directory or theme-compat.
That second change says something the advisory does not. A single validation line was sufficient for the reported bug. Adding a check at the sink, where all template paths converge, means the security team treated template resolution as a class of problem instead of a bug report. It is the right call. It also suggests they were not confident the reported path was the only unvalidated one, which is exactly the right instinct when the bug you are fixing is a missing check next to a present one.
What to change, in order
- Patch to the branch release. 7.1.2, or the backport for your branch: 7.0.6, 6.9.9, 6.8.10 and downward through 4.7.37. If automatic background updates are enabled the update should already have happened, but check the version rather than assuming. Only the most recent major version is actively supported, so the backport is a bridge, not a destination.
- Turn off
register_argc_argvif you can. It does not fix the inclusion, and it does not stop an attacker enumerating your site. It breaks the pearcmd chain, which is the difference between an information leak and code execution on your host, and it costs you a setting most applications do not depend on. - Hunt the logs with the high-signal strings, not with the source list.
pagenamecontaining%2e%2eor%252e%252e, in the query string or the POST body (POST has overtaken GET in this campaign); apagenamevalue beginning withtemplates%2for anotherpage-directory name;pagenameandpage_idtogether on/or/index.php; any request containingpearcmd,+config-showor+config-create; and the two honest user agents. - Interpret a 200 correctly. Per Patchstack, OPML or RSS output returned from an ordinary page URL is the sign that a stage one probe succeeded, because the inclusion of
wp-links-opml.phporfeed-rss2.phpexecuted. That is a confirmed vulnerable window, not a blocked attempt, and the sites that saw it should review their historical logs on that basis. - Check the host, not just the perimeter. Unexpected
.phpfiles in/tmpand/var/tmp, including the names above, mean a stage three attempt succeeded. Treat that host as compromised rather than as having been scanned, because the same primitive can write somewhere that survives a reboot.
The check belongs where the paths converge
The uncomfortable truth in this one is not that a line was missing. It is that the missing line sat three lines below the correct implementation, and differences like that are invisible to review: the code reads as if it validates what it loads, because the nearest example of validating what it loads is right there.
The fix that holds is the one at the sink. Entry point validation is per-caller discipline, and per-caller discipline fails the moment someone adds a sibling branch, a new query variable or a second decode step, which is precisely what happened here. A containment invariant at the point of use has no such dependency: every path that reaches the loader is checked, including the ones nobody has written yet.
The second lesson is about the clock. A published fix is not only a remediation for the people who deploy it. It is a specification for everyone else, and this one was a working specification within hours: probing at 11:49 UTC, a write to disk at 15:34 UTC, a Nuclei template in circulation the next day. The measured distance is not between discovery and disclosure, or between disclosure and scanning. It is between a fix landing in a repository and that fix landing on your host. If your deploy loop is longer than a few hours for a security point release, that gap is now the exposure, and no blocklist built from last week’s indicators will close it.
Sources:
- GHSA-7hp8-65ch-5whp, WordPress Core, published Sep 22, 2026 (primary: affected range 4.7.0 to 7.1.1 across all branches and the patched version for each, CVSS v4 9.2 Critical with the full vector, preconditions covering the
page-theme directory and the readable local.phptarget with thepearcmd.phptransition, the named themes including Twenty Twelve, Twenty Fourteen, Neve, Hestia and Sydney, the Docker PHP image and cPanel below PHP 8.5, credit for Robert Ressl) - WordPress 7.1.2 Release, wordpress.org/news, Sep 22, 2026 (primary: release date, security release classification, backports to every branch eligible for security fixes currently through 4.7, responsible disclosure credit, release led by John Blackbourn)
- CVE-2026-87902, WordPress Core Remote File Inclusion, CISA Known Exploited Vulnerabilities Catalog (primary: date added Sep 25, 2026, due date Sep 28, 2026, CWE-98, forensic triage required, ransomware campaign use unknown; verified against the catalog JSON feed, catalog version 2026.09.25, 1,726 entries)
- CVE-2026-87902, NVD (primary: CVSS 3.1 8.1, published Sep 22, 2026, status Undergoing Analysis at time of writing)
- CVE-2026-87902: Attackers Started Probing WordPress Sites Hours After the Patch, Patchstack, Sep 22, 2026 with a Sep 23 update (primary: first exploitation attempt at 11:49 UTC on Sep 22, first file write attempt at 15:34 UTC, the three-stage pearcmd progression and the
config-showtoconfig-createtransition, the observed dropped file names, the user agentscve-2026-87902-poc/1.0andnuclei-cve-2026-87902/1.0, the source address spread and the advice not to blocklist, the detection indicators and the OPML or RSS response as the stage one success signal, the Nuclei template and the volume peak on Sep 23) - WordPress 7.1.2 security release: unauthenticated LFI to RCE, Patchstack, Sep 22, 2026 (primary: the vulnerable branch in
get_page_template()and the siblingvalidate_file()call, the fix as shipped in 7.1.2 including_wp_is_template_path_allowed(), the sanitiser behavior that forces percent-encoded traversal, thepage-prefix and.phpsuffix constraints, theregister_argc_argvdefault in the official PHP Docker image and in cPanel below PHP 8.5, disclosure timeline) - @CISACyber on X, KEV addition for CVE-2026-87902, Sep 25, 2026 (primary: announcement post quoted verbatim; 32 likes, 5 replies at capture; the post itself is the radar hit that surfaced this story)
- The Fix Was Public. The Patch Was Not., dennysentinel.com, Sep 26, 2026 (related: the same week’s patch-gap story, where the fix existed upstream and no stable user had it, in contrast to a fix that shipped and was exploited within hours)