← back to blog
Threats & Exploits · Topic 7

WordPress Loaded a PHP File From Outside Its Own Theme Folder, Because One Value Skipped the Check

CVE-2026-87902, an unauthenticated page-template file inclusion in WordPress core that reaches conditional remote code execution - Threats and Exploits

Here's a claim worth being suspicious of: "it's core WordPress, the template code hasn't changed in years, so it's fine." CVE-2026-87902 is a decade-old code path that took a request parameter, built a filename out of it, and loaded the file, and for most of that decade nobody noticed the check three lines up was never applied to it. Send one unauthenticated request and WordPress would include a PHP file from wherever you pointed it.

On plenty of servers that inclusion turns into full code execution. I'll pull apart why, walk you through the exact spot in get_page_template() where the value slips through, and give you the log signals to tell whether anyone's already tried it on your site.

If you run WordPress, or you look after someone who does and forgot to mention it, this one's pointed at this week, not next patch cycle.

CVE-2026-87902 explained: the template slug next to it was checked with validate_file, the pagename value was not, so a traversal payload reaches the file loader
One branch was guarded, the branch beside it was not. Same function, three lines apart.

What shipped, and how fast it turned

WordPress 7.1.2 landed on 22 September 2026 as a security-only release with a single fix. The credit goes to Robert Ressl, who reported it privately through WordPress's HackerOne programme back in July. WordPress scored it CVSS 4.0 9.2, Critical, and classed it as CWE-98, improper control of a filename used in an include statement. That is the textbook name for PHP file inclusion.

The range is what makes it worth acting on tonight. Every branch from 4.7.0 up to 7.1.1 is affected, close to ten years of releases. WordPress backported the fix to every branch it still supports, all the way down to 4.7.37, so even an ancient site can take the security release without a major version jump. If you've been putting off an update because the site "still works", that's the site this is about.

Patch to active exploitation in hours, not weeks
01
22 Sep, 14:01 UTC

WordPress 7.1.2 ships. The advisory names the function and the conditions but not a working request.

▸
02
22 Sep, 11:49 UTC

Patchstack's firewall logs the first probes, built straight from the patch diff.

▸
03
22 Sep, 15:34 UTC

First attempt to write a file to disk through PEAR's pearcmd.php. Under four hours from patch to code execution.

▸
04
25 Sep

CISA adds it to the Known Exploited Vulnerabilities catalogue, remediation due 28 September.

Timeline from Patchstack's write-ups and the CISA KEV entry. The first probe timestamp predates the release note timestamp because the patch commit was public first.

By the next day Patchstack was seeing traffic more than ten times heavier, spread across a few hundred source addresses, and a named Nuclei template in circulation. Once there is a Nuclei template, this stops being a few people reading the diff and becomes anyone with a target list. Shadowserver put the number of still-exposed sites in the hundreds of thousands.

One thing to keep straight on the numbers. WordPress rates it 9.2 under CVSS 4.0, but the NVD record carries a CVSS 3.1 base of 8.1. Same bug, two scoring systems, and the gap is mostly how each one handles the "attack requirements" that make the full chain conditional. I'd trust the 9.2 as the one that reflects a real deployment.

The two branches, three lines apart

When WordPress draws a page, get_page_template() in wp-includes/template.php builds a list of candidate template filenames and hands them to the loader. Patchstack published the vulnerable code, and the whole bug fits on screen:

// 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";
}

The vulnerable function as published by Patchstack. Look at the two branches side by side.

Read the two if blocks next to each other. The first takes a template value and runs it through validate_file(), which is WordPress's own traversal check, before it accepts it. The second builds a candidate straight from pagename, the value that comes off the request, and never calls validate_file() at all. The guard existed. It was just sitting three lines above the place it was missing.

Two small details decide what you can actually reach. The filename's assembled as "page-{$pagename}.php", so your payload has to continue a directory that really starts with page-, and the target has to end in .php because the extension gets bolted on. That's why the practical precondition is a theme with a top-level folder like page-templates. Legacy default themes such as Twenty Twelve and Twenty Fourteen have one, and so do a few widely used third-party themes.

The clever part's the urldecode() call. WordPress runs the slug through its own sanitiser earlier, and that sanitiser deliberately keeps percent-encoded octets while it rewrites literal dots and cuts at literal slashes. A plain ../../ doesn't survive it. An encoded one does, and then get_page_template() decodes it back into real path characters right before building the filename. It's the same "sanitise, then decode again" trap you'll see in a lot of injection bugs, where the string gets checked in one shape and used in another.

The check wasn't weak and it wasn't missing from the file. It was applied to the value next door, and a guard on the wrong value is no guard at all.

From "load a file" to "run my code"

Including a local PHP file isn't the same as running code of your choosing. It runs whatever that file already does. So on its own this is a serious information-disclosure and site-integrity bug, and it doesn't hand you automatic RCE. Getting to arbitrary execution needs a second ingredient on the box, and the well-known one's PEAR's pearcmd.php.

Here's the chain Patchstack watched attackers build, in three stages. Each request names a real page id so WordPress resolves an actual page and the vulnerable template code even runs.

How the probes escalated
01
Is it live

Point the inclusion at a harmless core file like wp-links-opml.php. The response tells the attacker the host is vulnerable.

▸
02
Is pearcmd reachable

Aim at pearcmd.php and append +config-show. If the output comes back, PEAR is present and the argv trick works.

▸
03
Write a file

Swap in +config-create with attacker-chosen PHP and a path. PEAR writes it. That is code execution.

The three stages Patchstack observed, from reconnaissance to file write. Defanged: they did not publish a working request.

The register_argc_argv setting is what makes stage two work. When it's on, PHP hands the query string to the included script as $argv, so +config-show gets treated as a command by pearcmd.php. It's on by default in the official PHP Docker images and in cPanel environments running PHP below 8.5, so calling it a rare configuration undersells how often you'll find it. Attackers tried three install paths to cover most distributions:

/usr/local/lib/php/pearcmd.php
/usr/share/php/pearcmd.php
/usr/share/pear/pearcmd.php

The three pearcmd.php locations probed in the wild, as reported by Patchstack.

Stage three writes into /tmp and /var/tmp. A file there usually isn't reachable over the web, so on its own it proves execution rather than planting a lasting backdoor. That distinction matters less than it sounds, because the same primitive can write somewhere more useful, and a host that's run attacker PHP once is already compromised. Robert Ressl's own proof-of-concept lab confirmed the full chain ended with PHP running as the web-server account, www-data in his tests, and he was careful to say it never demonstrated root or a host escape. I'd keep that honesty in mind: the impact's bounded by what the PHP user can touch, which on a lot of shared hosts is still the whole site and its database credentials.

Why the fix does two things, not one

WordPress 7.1.2 shipped two changes, and the second one is the interesting tell. The first simply gives the pagename branch the same check its sibling 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 one-line fix: the decoded value now passes through validate_file() before it is trusted.

That alone closes the reported hole. But 7.1.2 also added a containment check, _wp_is_template_path_allowed(), that every resolved template path now has to pass no matter which code branch produced it. It resolves the real path and requires it to sit inside the stylesheet directory, the template directory, or theme-compat. A one-liner would've fixed the bug that got reported. Adding a check that treats template resolution as a whole class of problem tells you the security team wasn't confident the reported path was the only way in. That's the right instinct, and it's the difference between fixing a bug and fixing a bug pattern.

How to spot it in your logs

The good news for hunting is that the payload has a very specific shape. The traversal always arrives percent-encoded, because a plain one doesn't survive WordPress's sanitiser, so %2e%2e or the double-encoded %252e%252e inside a pagename value is a low-noise thing to grep for. A real page slug never has one, so you won't drown in false positives.

access log · published indicators
GET /?page_id=<valid id>&pagename=templates%252f<traversal>wp-links-opml stage 2 ?pagename=templates%2f<traversal>pearcmd&+config-show stage 3 ?pagename=templates%2f<traversal>pearcmd&+config-create+<php>+/tmp/<file>.php user-agent: nuclei-cve-2026-87902/1.0
Request shapes and a user agent published by Patchstack. The real payloads were withheld; these are defanged.

The signals worth searching for, straight from Patchstack's analysis:

  • A pagename parameter containing %2e%2e or %252e%252e, in the query string or the POST body. WordPress reads pagename from POST in preference to the query, and POST has overtaken GET as the more common method here.
  • pagename and page_id appearing together on the site root or /index.php. The two rarely turn up together in normal traffic, so the pairing is a strong signal.
  • Any request containing pearcmd, +config-show or +config-create.
  • The user agents cve-2026-87902-poc/1.0 and nuclei-cve-2026-87902/1.0, though most traffic spoofs a browser, so don't lean on the user agent as a filter.
  • On the host itself, unexpected .php files in /tmp and /var/tmp. Names seen in the wild include wp-pear-rce-flag.php, poc87902.php and luci_<random>.php.

One more, and it's the one people miss. If your access log shows OPML or RSS output coming back from an ordinary page URL, that's a stage-one probe that worked. A 200 carrying that content means the inclusion ran on your host, and it wasn't blocked. If your site was exposed before you patched, read your historical logs on exactly that basis.

Common mistakes: the mitigation that isn't a fix

If you can't patch this minute, the tempting stopgap is to block pagename values with a traversal sequence at the WAF or web server. That does work as a holding measure, because a genuine slug never has one. Just don't mistake it for the fix. It's a filter on one input shape, and the containment check WordPress added exists precisely because the team didn't trust that the reported input shape was the only one.

The mistake I'd flag hardest is telling yourself this is a plugin or theme problem because the RCE needs a page- theme directory. The file inclusion's in core, needs no plugin, and works against a stock install. The theme directory only decides whether inclusion becomes execution. So if you're thinking "my theme doesn't have that folder, I'm fine", you've fixed the wrong risk. Disabling register_argc_argv for web requests breaks the pearcmd route, but it won't repair the underlying inclusion either. Patch it. Everything else is just a bridge to patching.

The second mistake is treating each of the checks above as a one-off. This is the same triage trap that kept people exploitable during the PaperCut chain: a check that ran against the wrong object still counts as a check that passed, and a WAF rule that blocks one encoding still counts as a rule that fired. Neither means the underlying flaw is closed.

MITRE ATT&CK mapping

  • T1190 Exploit Public-Facing Application, the initial access through an unauthenticated HTTP request.
  • T1505.003 Server Software Component: Web Shell, where the file written to disk executes shell commands on access.
  • T1059 Command and Scripting Interpreter, the PHP payload starting an operating system command.
  • T1083 File and Directory Discovery, the stage-one probes that include harmless core files to fingerprint a vulnerable host.

Quick recap

  • CVE-2026-87902 is an unauthenticated file inclusion in WordPress core, CVSS 4.0 9.2 (NVD 3.1 8.1), CWE-98, affecting 4.7.0 through 7.1.1 and fixed in 7.1.2 with backports to 4.7.37.
  • The pagename branch of get_page_template() was never passed through validate_file(), unlike the template branch three lines above it, and a later urldecode() turned an encoded traversal into a real path.
  • Inclusion is unconditional and needs no login. Code execution needs a page- theme directory plus a reachable helper like pearcmd.php with register_argc_argv on, both common.
  • It was exploited within hours of the patch, is in the CISA KEV catalogue, and a Nuclei template is public.
  • Hunt for %2e%2e in pagename, pagename with page_id, and pearcmd with +config-create. A page URL returning OPML or RSS means a probe succeeded.

Check this today

Two five-minute checks. First, if you run WordPress, confirm you are on 7.1.2 or the backport for your branch, and check whether your active theme has a top-level directory whose name starts with page-, so you know which side of the inclusion-versus-execution line you sit on. Second, grep your access logs for pagename= with %2e%2e or %252e in it, and check /tmp and /var/tmp for stray .php files. A hit on the log or a file in /tmp means you investigate, not patch and move on.

If you've reproduced this in your own lab, or you've pulled the pearcmd chain apart more precisely than I have here, I'd be glad to compare notes on LinkedIn. I've read the write-ups closely but I haven't run this one, so if I've got a step in the wrong order I'd rather hear it.

References

FAQ

What is CVE-2026-87902?

An unauthenticated file inclusion in WordPress core, disclosed on 22 September 2026. A crafted pagename value makes get_page_template() load a readable PHP file from outside the active theme. WordPress scored it CVSS 4.0 9.2 (Critical) and classed it as CWE-98. Under certain server and theme conditions the inclusion reaches remote code execution.

Which WordPress versions are affected and fixed?

Every branch from 4.7.0 up to and including 7.1.1 is affected, roughly a decade of releases. The fix is in 7.1.2, and WordPress backported it to every supported branch down to 4.7.37, so an older site can take the security release without a major version jump.

Is CVE-2026-87902 being exploited?

Yes. Patchstack saw the first probes at 11:49 UTC on 22 September 2026, the same day the patch shipped, and a file-write attempt through pearcmd by 15:34 UTC. CISA added it to the Known Exploited Vulnerabilities catalogue on 25 September 2026 with a remediation date of 28 September.

Does every WordPress site get code execution from this?

No. File inclusion is always possible without a login, but code execution needs two extra things to line up: an active theme with a top-level directory whose name starts with page-, and a reachable helper like PEAR's pearcmd.php with register_argc_argv enabled. Both are common, so treat it as critical unless you have checked your own stack.

How do I check whether my site was already hit?

Search access logs for a pagename parameter containing %2e%2e or %252e%252e, for pagename and page_id together on the site root, and for any request with pearcmd or +config-create. On the host, look for unexpected .php files in /tmp and /var/tmp. A normal page URL returning OPML or RSS output means a probe succeeded.