← All posts
report wordpresslfipath-traversaldastsastcve-2026-87902

When SAST Can't See the Patch

CVE-2026-87902 — a WordPress-core page-template path traversal to LFI, the backported guard that version diffs miss, and how common the exploit precondition really is

15 Oct 2026 7 min read By codelake Research

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.

Attribution & scope

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.

# conceptual — the resolved candidate, not a wire payload pagename → decodes to → templates/../../../../wp-links-opml candidate page-templates/../../../../wp-links-opml.php → resolves to → <docroot>/wp-links-opml.php (included & executed)

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.

The discriminator isn't a line of code — it's whether a guard runs. Static tools see the sink in both trees; only execution tells them 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.

Method

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.
Keep reading
All posts →
Stay current

New editions, in your inbox.

Get notified when codelake Research publishes a new report, threat brief or quarterly advisory roundup. No marketing — just the research.

We use your address only to send research updates. Unsubscribe anytime.