DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-87902: Unauthenticated LFI (and Conditional RCE) in WordPress Core, Explained Line by Line

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 pagename value 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:

  1. It builds a list of candidate filenames, ordered by priority (something like page-about.php, then page.php).
  2. 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";
}
Enter fullscreen mode Exit fullscreen mode

3.1 The first block: a candidate that is validated

if ( $template && 0 === validate_file( $template ) ) {
    $templates[] = $template;
}
Enter fullscreen mode Exit fullscreen mode

$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";
}
Enter fullscreen mode Exit fullscreen mode

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 %2f and %2e are 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 ../:

  1. Sanitizer sees an encoded string and lets it through
  2. urldecode() restores the real ../
  3. 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
Enter fullscreen mode Exit fullscreen mode

That gives two constraints:

  1. The page- prefix. The value has to continue a real directory whose name starts with page-, so the active theme needs a top-level directory such as page-templates. Legacy default themes and a number of popular third-party themes have one. Conceptually, the payload continues page-templates/ and then climbs out of the theme directory.
  2. The .php suffix. The extension is appended automatically, so the target must be a readable .php file.

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_id plus an encoded pagename. No login, no cookie, which is what makes the bug so dangerous.
  • Slug sanitizing (amber). The sanitizer strips literal ../ but keeps %2f and %2e. Amber means "looks like a defense, but incomplete".
  • get_page_template() (blue). The code from section 3. urldecode() restores a real ../, and the resulting page-...php candidate enters the list without validate_file().
  • Template loader (green). The loader opens the candidate. If it points at a readable .php outside the theme, that file gets included 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 .php suffix, any readable PHP file can be included. This is the vulnerability itself.
  • A useful target (amber). A file like pearcmd.php must 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";
}
Enter fullscreen mode Exit fullscreen mode

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 );
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Step by step:

  1. wp_normalize_path( $path ) converts separators so the rest of the function sees a consistent /.
  2. 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
  3. If there is no .. segment, the function returns true immediately. Ordinary paths pass here.
  4. 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, and theme-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). pagename is 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 .php file 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:

  1. Is the inclusion live? Point the include at an ordinary core file such as wp-links-opml.php and see if it executes.
  2. Is pearcmd reachable? Check that pearcmd exists and the $argv trick works.
  3. Write a file. Drop a PHP file into /tmp or /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_argv enabled? If it is off, the pearcmd route to RCE is closed and you are left with file inclusion.
  • Does the server have pearcmd.php or 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 pagename values 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 pagename containing %2e%2e or %252e%252e, in the query string or the POST body
  • A pagename beginning with templates%2f or another page- directory name
  • pagename and page_id together on the site root or /index.php
  • Any request containing pearcmd, +config-show or +config-create
  • User agents cve-2026-87902-poc/1.0 and nuclei-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 .php files in /tmp or /var/tmp mean a file-write attempt succeeded, and the host should be treated as compromised

Top comments (0)