Developer Tools

WordPress CVE-2026-87902: how to tell if you are exposed

CVE-2026-87902 touches every WordPress release since 4.7, but code execution needs a specific theme and PHP setup. Here is how to check yours, and your logs.

An amber path climbs from a walled enclosure up tiered glass levels to a blue core while beams scan it.
An amber path climbs from a walled enclosure up tiered glass levels to a blue core while beams scan it.

A 9.2 that needs reading closely

WordPress 7.1.2 shipped on September 22, 2026 to fix a flaw in how core picks a page template. In the release post's words: "An unauthenticated attacker can, under certain conditions, make page template resolution include a chosen readable local PHP file outside the active theme directories." The fix was backported to every branch still getting security releases, which currently means everything back to 4.7.

The advisory, GHSA-7hp8-65ch-5whp / CVE-2026-87902, rates it Critical at CVSS 4.0 9.2. Affected versions run from 4.7.0 through 7.1.1. Robert Ressl found it and reported it privately on July 20. The patch, the advisory and his proof-of-concept repository all went public on the same day. According to Patchstack, probing started less than five hours later.

Two things are true at once. Almost a decade of WordPress releases is affected, and no login is needed. But turning the bug into remote code execution takes conditions that many sites do not meet. Some headlines already say "exploited". So far the evidence shows reconnaissance and nothing more. You don't need to panic. You do need to find out, this morning, which kind of site you run. That's what this post walks through.

Why it becomes RCE on some hosts and not others

Patchstack's root-cause write-up traces the bug to get_page_template() in wp-includes/template.php. That function builds a candidate filename, page-{$pagename}.php, from the pagename query variable. A neighbouring branch passes its input through validate_file(). The pagename branch doesn't, so double-encoded traversal sequences get through. Patchstack sums up the scope: "Every branch back to 4.7 is affected, which is close to a decade of releases, and no account is needed to attack it."

On its own, that is a local file include. The advisory lists two more preconditions that turn it into code execution:

  1. The active theme has a top-level directory whose name starts with page-. The advisory names Twenty Twelve, Twenty Fourteen, Neve, Hestia and Sydney. In Ressl's write-up, he notes that the current default themes (Twenty Twenty-Three, Twenty Twenty-Four and Twenty Twenty-Five) have no such directory.
  2. The server has a readable .php file that can be abused once included. The advisory's example is PEAR's pearcmd.php with register_argc_argv enabled. That setting is on by default in the official PHP Docker images and in cPanel environments.

The second condition is why this bug matters more for self-hosted and containerised setups than the "old themes only" framing suggests. A lot of WordPress runs on the official PHP image, often from a Dockerfile someone wrote years ago and hasn't looked at since. Ressl puts it carefully: "Is every default installation exploitable? The evidence does not establish that." Treat that as a reason to check, not a reason to relax.

Four checks: are you exposed?

Run these from the WordPress root, or inside the container. Each one answers a single question.

1. Are you on a fixed version, and will you stay on one?

wp core version

The fixed releases are 7.1.2, 7.0.6, 6.9.9 and 6.8.10, with older backports going down to 4.7.37. Anything from 4.7.0 up to the fixed release on your branch is vulnerable. The Hacker News notes that sites with automatic background updates will get the patch without anyone acting. Don't assume you are one of those sites, though. Containerised installs often bake core into the image. A read-only filesystem blocks the update, and recreating the container from an old image rolls it back. If your version is still on the vulnerable list a day after release, auto-updates aren't working for you.

2. Does the active theme have a page-* directory?

wp theme list --fields=name,status
ls -d wp-content/themes/*/page-*/ 2>/dev/null

Compare the two outputs. If the active theme, or its parent if you run a child theme, shows up in the second list, you meet the first precondition. The advisory's named themes are a starting point, not a complete list. Commercial and custom themes that group page templates into folders can match too.

3. Is register_argc_argv on for web requests?

Don't check this with php -i on the command line. The CLI SAPI forces register_argc_argv on whatever your ini says, so the answer will always be "on" and will tell you nothing. Ask the web SAPI instead, then delete the file straight away:

echo '<?php echo ini_get("register_argc_argv") ? "on" : "off";' > argc-check-7f3a.php
curl -s https://your-site.example/argc-check-7f3a.php; echo
rm argc-check-7f3a.php

If it prints on and you don't need it, turn it off. On the official Docker image, the quickest route is to start from the production ini the image already ships, which sets it off. The other option is an explicit override:

RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
 && echo 'register_argc_argv = Off' > "$PHP_INI_DIR/conf.d/zz-argc.ini"

4. Is pearcmd.php on the box?

find / -name pearcmd.php -not -path '/proc/*' 2>/dev/null

The include is local, so what matters is whether the PHP process can read the file, not whether it is web-accessible. If PEAR is installed and nothing uses it, remove it. It is the named gadget in this chain, but it probably isn't the only one. That's why Ressl's advice also includes limiting what the PHP user can read and write.

If check 1 says you are patched, you are covered for this CVE. The other three still matter, because they tell you how bad the next file-include bug would be.

Have you already been probed?

Patchstack saw the first probe at 17:44 UTC on September 22. The requests put double-encoded traversal in pagename alongside a valid page_id, and most carried a Go-http-client/1.1 user agent. They targeted harmless core files such as wp-links-opml.php. That is someone checking whether the include works, not trying to run anything yet. Patchstack has not confirmed any successful payload execution.

Start broad. This catches a double-encoded dot-dot anywhere in the request line, in current and rotated logs:

zgrep -i '%252e%252e' /var/log/nginx/access.log*

Then narrow it down to the exact pattern Patchstack describes:

zgrep -iE 'pagename=[^ &]*%252e%252e' /var/log/nginx/access.log* \
  | grep -i 'Go-http-client/1.1'

Change the paths for Apache or your log shipper. Patchstack also suggests going back through older logs for OPML output returned from normal page URLs. In practice, that means looking for page requests with a response size or content type that matches wp-links-opml.php rather than your theme's HTML.

Until every site is patched, block traversal in pagename at the edge. Patchstack and Hadrian both recommend it. A blunt nginx version:

if ($args ~* "pagename=[^&]*(\.\.|%2e|%25)") {
    return 403;
}

Hadrian reproduced the include on an isolated 7.1.1 install. It also published a Nuclei template that sends three unauthenticated requests and compares the response bodies, which is a quick way to test a fleet from the outside.

The bug class: decode, then validate, then contain

This isn't really a WordPress-specific mistake. Any code that builds a file path from request input can fall into it, in two ways.

The first is validating before the last decode. %252e survives a check for .. and for %2e. It only becomes %2e, and then ., after later layers decode it again. If your check runs before the value reaches its final form, you have checked the wrong string.

The second is treating canonicalisation as containment. realpath() tells you where a path really points. It does not tell you whether that place is allowed. Containment is a separate comparison against a base directory, and the trailing separator matters. Without it, /themes/foo would also match /themes/foo-evil.

Here is the pattern written out:

function safe_template_path(string $baseDir, string $input): ?string
{
    // Decode once; if it still decodes, it was multiply encoded. Reject, don't loop.
    $decoded = rawurldecode($input);
    if ($decoded !== rawurldecode($decoded)) {
        return null;
    }

    // Allowlist the shape instead of blocklisting bad sequences.
    if (!preg_match('/^[a-z0-9-]+$/', $decoded)) {
        return null;
    }

    // Canonicalise, then contain: compare against the base with a trailing separator.
    $base = rtrim(realpath($baseDir), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
    $path = realpath($base . 'page-' . $decoded . '.php');
    if ($path === false || !str_starts_with($path, $base)) {
        return null;
    }

    return $path;
}

The allowlist does most of the work. A page slug has no reason to contain a dot, a slash or a percent sign, so refuse all three before you get anywhere near the filesystem.

What to do this morning

  • Update, then check wp core version on every install. Don't trust that auto-updates ran, especially on containers and pinned images.
  • Turn off register_argc_argv for web requests anywhere you don't need it, and start Docker builds from php.ini-production.
  • Remove unused PEAR entry points and tighten the PHP account's filesystem read and write access, as Ressl recommends.
  • Grep your logs for %252e%252e, and add a temporary edge rule blocking traversal in pagename for anything you can't patch today.
  • Audit your own code for the same shape: any path built from request input needs a full decode, an allowlist and a containment check, in that order.

The CVSS score tells you how bad this could get. The four checks tell you how bad it is on your servers.

Related reading

Comments (0)

No comments yet. Start the conversation.

Never shown, never emailed — stored only as a hash.