On 2026-09-22, WordPress shipped 7.1.2 together with a fan of branch backports — 7.0.6, 6.9.9, 6.8.10, 6.7.9 and further down to 4.7.37. The release closed CVE-2026-87902, an unauthenticated path traversal in core's page-template resolution that becomes local file inclusion and, under the right PHP configuration, remote code execution. CISA added it to the Known Exploited Vulnerabilities catalog on 2026-09-25. What makes it a good teaching case is not the bug but what tooling did and didn't see: source-level SAST was effectively blind to it, a dynamic oracle confirmed it in a single request, and the precondition that makes a site exploitable is far more common than the advisory language suggests.
codelake Research did not discover CVE-2026-87902. It was responsibly disclosed by Robert Ressl and fixed by the WordPress security team (GHSA-7hp8-65ch-5whp). This post documents a detection method and a breadth measurement — not a new vulnerability. No working exploit, payload string, or reproduction is published here.
Executive summary
The vulnerable path has existed since WordPress 4.7.0. When core assembles candidate template files from a request's pagename, a double-URL-encoded value slips past the single decode PHP has already applied and is appended as a template candidate without the validate_file() guard its sibling candidates receive. A crafted pagename therefore resolves to a file outside the theme directory — in the public proof, core's own wp-links-opml.php — which the template loader includes and executes.
The fix is a single added guard, backported independently to every supported branch. That is precisely the shape of change that defeats a naive version diff: the vulnerable and patched trees look almost identical, and a source-level taint analyzer sees the same $_GET → include pattern in both. The bug is the absence of a check, not the presence of a sink.
How the traversal works
get_page_template() in wp-includes/template.php builds its candidate list from the request's page name. When the raw value differs from its URL-decoded form, the decoded value is appended as a candidate of the form page-{decoded}.php. Most candidates are run through validate_file(), which rejects .. traversal — but this decoded candidate is not. A single decode by PHP leaves a second encoding layer intact, so the value only resolves to a traversal after core decodes it again inside the template lookup.
For the traversal to arm, the first path segment must be an existing directory in the active theme whose name starts with page- (for example twentytwelve's page-templates/), the theme must live directly under wp-content/themes/, and the request must be a page render (a published page_id). The target, wp-links-opml.php, ships in every WordPress release. On a patched branch, validate_file() rejects the candidate and the page renders its normal template instead.
Why source-diff SAST goes blind
We ran our source-level SAST (mosaic) across both the vulnerable and the patched core trees. It produced the same generic "request-influenced include → possible LFI" signal on both — because the discriminator between them is a runtime behavior (does validate_file() reject the candidate?), not a static shape. The sink is present in both trees; only one of them is actually exploitable.
A predecessor-version differ struggles for the same reason: the guard was added as many tiny, per-branch backport commits, so "vulnerable" and "patched" differ by a few scattered lines that read like ordinary hardening. When a fix adds a check rather than removing a dangerous call, byte- and shape-level analysis cannot reliably tell the two apart.
Confirming it dynamically
So we verified behavior instead of shape. Because the traversal makes core include wp-links-opml.php, a vulnerable response begins with an XML declaration and contains an OPML root element — output a normal page template never produces. That gives a clean, non-destructive oracle: against an unpatched 6.7.x install the dynamic check returned VULNERABLE; against the 6.7.9 backport, PATCHED. One request, no file written, no code of ours executed on the target.
The oracle reads a file that exists in every release and writes nothing. The conditional-RCE upgrade (chaining the inclusion to a writable PHP file) is deliberately out of scope for a battery that also runs against live customer installations — the inclusion itself already proves arbitrary PHP executes in the request context.
How common is the precondition?
The advisory framing makes the preconditions sound narrow: a classic (non-block) theme, a page-* directory, the theme directly under wp-content/themes/, a published page. It is tempting to conclude "most sites aren't affected." So we measured it.
In our sample of popular WordPress themes, roughly one in seven ships a top-level page-* template directory containing PHP — exactly the structure the traversal needs to arm. The precondition is the rule, not the exception. A theme carrying the directory does not by itself make a site exploitable — that still requires unpatched core, that theme active, and a published page — but the reflexive "you probably aren't affected" is wrong for a large fraction of classic-theme sites.
Takeaways
- Patch core. 7.1.2 or your branch's backport (6.7.9, 6.8.10, 6.9.9, 7.0.6, …) closes it. This is the only fix that matters, and it is theme-independent.
- Don't trust a version diff for guard-style fixes. When the patch adds a check, the vulnerable and patched trees look the same to static tooling.
- For missing-guard bugs, verify behavior, not shape. A one-request dynamic oracle separated vulnerable from patched where source analysis could not.
- Measure "narrow" preconditions before believing them. The classic-theme
page-*layout this exploit needs is widespread, not rare.