WordPress 7.1.2 is a security-only release with a single fix, and that fix is CVE-2026-87902. Every release from 4.7.0 through 7.1.1 is affected, which is close to a decade of versions. The attacker needs no account and no cookie: one crafted request can make WordPress include another PHP file on the server. Exploitation attempts showed up on the same day the patch shipped.
This post walks through the vulnerable code, explains why it works in plain terms, and then looks at what the patch changes.
1. Overview
| Item | Detail |
|---|---|
| CVE | CVE-2026-87902 |
| Component | WordPress Core, get_page_template() in wp-includes/template.php
|
| Type | Path traversal → Local File Inclusion → conditional RCE |
| CWE | CWE-98 (improper control of filename for include); some sources list CWE-22 |
| CVSS 4.0 | 9.2 (Critical) |
| Authentication | None |
| Affected | 4.7.0 – 7.1.1 |
| Fixed in | 7.1.2, 7.0.6, 6.9.9, 6.8.10 (backported as far as 4.7.37) |
| Reported by | Robert Ressl |
The one-sentence version:
The template filename built from the request's
pagenamevalue was never run through a path check.
2. Background: how WordPress picks a page template
When WordPress renders a page, it has to decide which theme file draws it. get_page_template() does that in two steps:
- It builds a list of candidate filenames, ordered by priority (something like
page-about.php, thenpage.php). - It hands the list to the template loader, which looks for each candidate in the theme directory and
includes the first one that exists.
The important detail is that some of those candidates come from the request. pagename holds the page slug from the URL. So user input ends up inside a file path, which is exactly the situation where validation is mandatory.
3. The vulnerable code
This is the relevant part of get_page_template() in WordPress 7.1.1 and earlier, as published by Patchstack:
// 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";
}
3.1 The first block: a candidate that is validated
if ( $template && 0 === validate_file( $template ) ) {
$templates[] = $template;
}
$template is the template slug an admin picked for the page. Before it goes into the candidate list it must pass validate_file(), WordPress's path-safety helper. Roughly, it flags things like ../ and returns non-zero for unsafe input, so 0 === validate_file(...) means "safe".
So WordPress knew this kind of value could be dangerous and guarded it.
3.2 The second block: a candidate that is not
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
Line by line:
| Code | Meaning |
|---|---|
if ( $pagename ) |
Continue only if the request carries a pagename
|
urldecode( $pagename ) |
Turn URL-encoded sequences such as %2f back into real characters (/) |
$pagename_decoded !== $pagename |
True when the value contained encoded characters |
"page-{$pagename_decoded}.php" |
Build a candidate filename from the decoded value and add it to the list |
There it is: the validate_file() call that protects $template three lines earlier is missing here. The guard existed; it just wasn't applied to the branch that needed it.
3.3 Why the ../ survives: time-of-check vs time-of-use
You might object that WordPress sanitizes slugs before they get here. It does, and that is what makes this interesting. The sanitizer behaves like this:
-
Literal dots and slashes are rewritten or cut off, so a plain
../../does not survive. -
Escaped octets such as
%2fand%2eare deliberately preserved.
So a payload that is percent-encoded passes sanitization looking harmless. Later, get_page_template() calls urldecode(), and the encoded characters turn back into a real ../:
- Sanitizer sees an encoded string and lets it through
-
urldecode()restores the real../ - The restored value is used in a file path with no validation
That is a classic decode-after-check bug. It also explains why real attack traffic uses double encoding (%252f and friends): the request pipeline decodes once, and urldecode() decodes a second time, leaving the raw characters at the point of use.
3.4 What limits the attacker
Controlling part of the path is not the same as reading any file. The code always produces a filename of this shape:
page- + (attacker value) + .php
That gives two constraints:
-
The
page-prefix. The value has to continue a real directory whose name starts withpage-, so the active theme needs a top-level directory such aspage-templates. Legacy default themes and a number of popular third-party themes have one. Conceptually, the payload continuespage-templates/and then climbs out of the theme directory. -
The
.phpsuffix. The extension is appended automatically, so the target must be a readable.phpfile.
One more detail is worth knowing: real exploit requests almost always send a valid page_id alongside pagename. Without a page_id that resolves to a real page, the query returns nothing, WordPress serves a 404, and the template step (and therefore the vulnerable code) never runs. It is a precondition for reaching the sink.
4. The attack flow
Figure 1 shows the four stages a single request goes through. The colored strip on each card marks how much attention that stage deserves.
-
Attacker request (red). A valid
page_idplus an encodedpagename. No login, no cookie, which is what makes the bug so dangerous. -
Slug sanitizing (amber). The sanitizer strips literal
../but keeps%2fand%2e. Amber means "looks like a defense, but incomplete". -
get_page_template()(blue). The code from section 3.urldecode()restores a real../, and the resultingpage-...phpcandidate enters the list withoutvalidate_file(). -
Template loader (green). The loader opens the candidate. If it points at a readable
.phpoutside the theme, that file getsincluded and executed.
The "Key points" box at the bottom repeats the three lessons: validation was missing on exactly one candidate, the check and the use happen at different moments, and page_id must accompany pagename.
5. From LFI to RCE
File inclusion always works. Code execution only works when the server happens to line up. Including a local file runs what that file already does, not code the attacker wrote.
So the attacker needs a file that does something useful just by being included. The well-known candidate is PEAR's pearcmd.php. It handles command-line arguments, and when PHP runs with register_argc_argv enabled, the web request's query string is exposed to the script as $argv and gets treated like command-line arguments. pearcmd includes a file-creation feature, so the result can be a PHP file with attacker-chosen content written to disk.
register_argc_argv is on by default in the official PHP Docker images and in cPanel environments on PHP below 8.5, so calling it an unusual configuration undersells how common it is.
Figure 3 shows four preconditions and two possible outcomes.
-
Theme layout (blue). The active theme needs a top-level
page-directory. -
File inclusion (amber). With the
page-prefix and.phpsuffix, any readable PHP file can be included. This is the vulnerability itself. -
A useful target (amber). A file like
pearcmd.phpmust exist on the server. -
register_argc_argv(red). Once this is on, the query string becomes command input, and risk jumps.
The bottom row is the result. Outcome A (preconditions 1–2 only) means a core file runs at the wrong URL, which is enough for an attacker to fingerprint a vulnerable site. Outcome B (all four) means remote code execution through a file write.
6. The patch
WordPress shipped two changes.
6.1 Fix 1: add the missing check
// wp-includes/template.php, WordPress 7.1.2
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
The only change is && 0 === validate_file( $pagename_decoded ). The decoded value is now validated, so if urldecode() restored a ../, the candidate never enters the list. This closes the exact gap from section 3.3.
6.2 Fix 2: a containment check for every template path
// wp-includes/template.php, new in WordPress 7.1.2
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;
}
// Otherwise resolve the real path and require it to sit inside an allowed theme directory.
$real_path = realpath( $path );
// ...
}
Step by step:
-
wp_normalize_path( $path )converts separators so the rest of the function sees a consistent/. - The regular expression
#(?:^|/)\.\.[. ]*(?:/|$)#looks for a..path segment:-
(?:^|/): at the start of the string or right after a/ -
\.\.: two dots -
[. ]*: optionally followed by more dots or spaces, which catches sneaky variants -
(?:/|$): ending at a/or at the end of the string
-
- If there is no
..segment, the function returnstrueimmediately. Ordinary paths pass here. - If there is one, it resolves
realpath()and requires the result to sit inside an allowed directory. According to Patchstack, those are the stylesheet directory, the template directory, andtheme-compat(the part elided as// ...above).
Fix 1 alone would have closed the reported bug. Adding Fix 2 shows the WordPress security team treating template resolution as a class of problem instead of a single defect: whichever code path produced the path, it must now end up inside an allowed theme directory. That reads as a hedge against similar bypasses elsewhere.
Figure 2 compares the same request before and after the patch.
-
Left (before).
pagenameis received, decoded, and included. The red strip on the third card shows that this candidate had no validation at all, so the fourth card ends with a.phpfile outside the theme being executed. -
Right (after). The third card is layer 1: the decoded value goes through
validate_file()and a../removes the candidate. The fourth card is layer 2: the final path is checked against the allowed theme directories. Green marks the points where the request is now stopped.
7. What attackers did next
Things moved fast after the patch. Patchstack's timeline:
| When | What |
|---|---|
| 2026-09-22 | WordPress 7.1.2 released, advisory GHSA-7hp8-65ch-5whp published |
| 2026-09-22 11:49 UTC | First exploitation attempt seen, probing core files (built from the patch diff) |
| 2026-09-22 15:34 UTC | First attempt to write a file to disk via pearcmd |
| 2026-09-23 | Public scanners and a named Nuclei template in circulation; traffic more than 10x the first evening |
Attack traffic falls into three stages:
-
Is the inclusion live? Point the include at an ordinary core file such as
wp-links-opml.phpand see if it executes. -
Is pearcmd reachable? Check that pearcmd exists and the
$argvtrick works. -
Write a file. Drop a PHP file into
/tmpor/var/tmp. Some payloads only write a marker string (building a list of vulnerable hosts); others write a tag that runs a shell command when the file is accessed.
A file in /tmp is not normally reachable over the web, so on its own it is proof of execution rather than a persistent backdoor. But the same write primitive can target more useful locations, so from the attacker's side the host is already compromised.
8. Detection and mitigation
8.1 Update first
Patched releases exist for every supported branch: 7.1.2, 7.0.6, 6.9.9 and 6.8.10, with backports down to 4.7.37. Older sites can take the fix without a major version jump.
8.2 Gauge your exposure
- Does the active theme have a top-level directory starting with
page-? - Is
register_argc_argvenabled? If it is off, the pearcmd route to RCE is closed and you are left with file inclusion. - Does the server have
pearcmd.phpor other readable PHP entry points that behave usefully when included?
These checks estimate your RCE exposure. They do not tell you whether the core flaw is present; only your version does.
8.3 If you cannot update right away
- Reject
pagenamevalues that contain traversal sequences. A real page slug never does, so this is safe for normal traffic. - Disable
register_argc_argv. It does not fix the inclusion, but it breaks the pearcmd chain and turns code execution back into a file-inclusion problem.
8.4 Log-hunting indicators
- A
pagenamecontaining%2e%2eor%252e%252e, in the query string or the POST body - A
pagenamebeginning withtemplates%2for anotherpage-directory name -
pagenameandpage_idtogether on the site root or/index.php - Any request containing
pearcmd,+config-showor+config-create - User agents
cve-2026-87902-poc/1.0andnuclei-cve-2026-87902/1.0(most traffic spoofs browser user agents, so do not rely on this alone) - OPML or RSS content returned from an ordinary page URL means a stage-one probe worked; treat any pre-patch exposure window accordingly and review historical logs
- Unexpected
.phpfiles in/tmpor/var/tmpmean a file-write attempt succeeded, and the host should be treated as compromised




Top comments (0)