October 13, 2026
CVE Decoded #4: CVE-2026–87902 (WordPress)
This is my attempt to dumbdown CVE and explain them the way I understand them.
By Saijalpreet Kaur
6 min read
Basic Details
- CVE ID: CVE-2026-87902
- Published: September 22, 2026
- Severity: Critical, CVSS Score: 9.2
- Affected Software: WordPress Core (versions 4.7.0 to 7.1.1)
- Platform: Web, PHP, Open Source
- Vulnerability Type: Path Traversal / Local File Inclusion (LFI) leading to RCE
- CWE: 98, 22
Issue in one line
A sneaky double-encoded URL parameter fooled WordPress into escaping its theme folder and executing arbitrary PHP code on the server, with zero login required.
What is WordPress?
Everyone knows WordPress, yeah?? Still in case, it is a massive, open-source content management system that runs everything from tiny personal blogs to massive corporate news sites.
When someone visits a page on a WordPress site, the software has to figure out how to display it. It looks at your request and searches through its active theme folder to find the right "template" file to build the page.
Whenever someone visits a page on a WordPress site, say abc.com/about WordPress has to figure out how to draw that page on the screen. To do that, it looks for a page template: a PHP code file that knows how to render that specific type of content.
Analogy
So, I love theme parks, let's say, Disney World or Universal.
Now, the park is split into themed zones, and right now we are standing in Pirate Cove (in WordPress terms, the active "Theme").
Inside Pirate Cove, there's a prop booth where guests can request items to play with. The booth attendant has one golden rule:
Only hand out props from inside the Pirate Cove prop closet. Never leave the closet. Never wander into backstage employee zones. (remember we are discussing path traversal here)
To enforce this, whenever a guest asks for something like "Sword", the system automatically tacks on a label and searches for prop-sword.
Now imagine a park security guard standing at the entrance checking visitors. You hand the guard an order slip written in double-scrambled code (like invisible ink layered over pig Latin). The guard glances at it, sees no weapons or obvious contraband, and waves you through.
Once you reach the prop booth, the attendant feeds your slip into an internal translation machine. Suddenly, the scrambled letters unscramble into a secret escape route:
Step out the back of the prop closet, slip through the employee fire exit, walk down to the main room, and grab the master maintenance manual.
Because of a clever routing quirk in the park's layout, the attendant doesn't realize they've walked outside the closet. They march straight into the high-voltage control room and pick up the manual.
And here's thing with PHP: the server doesn't just read the manual, it immediately executes every single instruction written inside it.
That's exactly how CVE-2026-87902 turned a harmless page request into a full server takeover.
Now Technical explanation
At its heart, this bug combines two classic web vulnerabilities: Double URL Encoding and Path Traversal leading to Local File Inclusion.
1. The Double-Encoding
Computers represent web addresses using URLs. If you type special characters into a URL, your browser translates them into safe code:
- A dot . becomes
%2e - A slash / becomes
%2f
So a path traversal sequence like ../ (which tells a computer "go up one folder") looks like %2e%2e%2f.
Security firewalls and sanitizers know this trick. If a request comes in containing ../ or %2e%2e%2f, web application firewalls scream and block it immediately.
BUTTTT What if an attacker encodes the percent sign % itself?
A %sign in URL encoding is %25.
So if you encode %2e:
- % becomes
%25 2estays2e- Together, it becomes
%252e
When the outer security layers look at %252e, they think: Oh, that's just a normal string with some text and a percent sign. Nothing dangerous here! It passes right through.
- The Trap Inside
get_page_template()
Once the request slips past the front door, it reaches WordPress's internal template resolution function: get_page_template().
Deep inside wp-includes/template.php, WordPress wanted to make sure it could handle friendly page names with non-English characters or spaces. So it ran urldecode() on the incoming page slug:
php
$decoded_slug = urldecode( $pagename );php
$decoded_slug = urldecode( $pagename );When urldecode() ran on %252e, %25 turned back into %.
And in subsequent query processing, that unpacked directly into . and / (../)
3. The Prefix Bypass
Normally, WordPress prepends the word page- to custom template names before searching for them in the active theme directory:
page- + [slug] + .phppage- + [slug] + .phpIf an attacker sent a raw traversal like ../../../../tmp/shell, WordPress would look for page-../../../../tmp/shell.php, which wouldn't match.
SOoo, the attacker did this
pagename=templates/../../../../tmp/shellpagename=templates/../../../../tmp/shellWordPress glued them together:
page- + templates/../../../../tmp/shell.php
= page-templates/../../../../tmp/shell.phppage- + templates/../../../../tmp/shell.php
= page-templates/../../../../tmp/shell.phpBecause page-templates was a real directory inside the theme, the operating system accepted the path, traversed all the way back up to the server root, and located /tmp/shell.php outside the theme.
4. Turning File Reading into RCE
In PHP, there is a massive difference between reading a file and including a file.
When WordPress finds a template file, it doesn't just read the text to display it, it calls PHP's include() or require(). In PHP, including a file means executing every line of code inside it.
Attackers didn't even need to upload their own file beforehand. Many standard PHP server distributions come pre-installed with helper tools, like PEAR's pearcmd.php. By passing command-line arguments through HTTP query parameters (leveraging register_argc_argv), attackers commanded pearcmd.php to write a small web shell to /tmp/, and then used the traversal bug to execute it.
Just like that: unauthenticated Remote Code Execution on the server.
The Attack, Step by Step
-
Find a WordPress Site- The attacker finds any WordPress site running an affected version (4.7.0–7.1.1) with a theme using standard template folders.
-
Send the Double-Encoded Payload- They send an unauthenticated HTTP GET request targeting a valid page ID with a crafted, double-encoded pagename query parameter.
-
Bypass the Firewalls- The
%252e%252estring sails right past perimeter filters because it looks like harmless encoded characters. -
Trigger the Internal Decode- WordPress's
get_page_template()decodes the parameter into real../directory traversal characters. -
Escape the Theme Directory — The path bridges through the theme's
page-templates/directory, climbs out ofwp-content/themes, and points directly to a local PHP gadget file. -
Code Execution- PHP includes the target file and executes it with the privileges of the web server. Game over.
Before and after the fix
The Vulnerable Code (WordPress <= 7.1.1)
php
// File: wp-includes/template.php (Vulnerable)
function get_page_template() {
$id = get_queried_object_id();
$template = get_page_template_slug();
$pagename = get_query_var( 'pagename' );
if ( ! $pagename && $id ) {
// … grabs pagename from post object …
}
//FLAW 1: Blindly decoding the slug without verifying where it leads
$decoded_slug = urldecode( $pagename );
$templates = array();
if ( $decoded_slug ) {
//FLAW 2: Assembling candidate paths with no boundary validation!
// If $decoded_slug starts with "templates/../../../../", it escapes the theme!
$templates[] = "page-{$decoded_slug}.php";
}
$templates[] = 'page.php';
// locate_template() checks file_exists() across themes, but never verified
// whether the file was actually INSIDE the allowed directory!
return locate_template( $templates );
}php
// File: wp-includes/template.php (Vulnerable)
function get_page_template() {
$id = get_queried_object_id();
$template = get_page_template_slug();
$pagename = get_query_var( 'pagename' );
if ( ! $pagename && $id ) {
// … grabs pagename from post object …
}
//FLAW 1: Blindly decoding the slug without verifying where it leads
$decoded_slug = urldecode( $pagename );
$templates = array();
if ( $decoded_slug ) {
//FLAW 2: Assembling candidate paths with no boundary validation!
// If $decoded_slug starts with "templates/../../../../", it escapes the theme!
$templates[] = "page-{$decoded_slug}.php";
}
$templates[] = 'page.php';
// locate_template() checks file_exists() across themes, but never verified
// whether the file was actually INSIDE the allowed directory!
return locate_template( $templates );
}Zero validation. It assumed that because it appended page- at the front, nobody could possibly escape into the rest of the filesystem.
The Patched Code WordPress 7.1.2: Commit fdeab470f4b4
To fix this, the WordPress security team implemented a two-layered defense:
php
// File: wp-includes/template.php (Patched)
function get_page_template() {
$id = get_queried_object_id();
$template = get_page_template_slug();
$pagename = get_query_var( 'pagename' );
$decoded_slug = urldecode( $pagename );
// FIX 1: Run core's validate_file() on the decoded string!
// validate_file() checks for "../", Windows drive letters, and invalid characters.
// If it detects ANY directory traversal attempt, validate_file() returns > 0.
if ( $decoded_slug && validate_file( $decoded_slug ) !== 0 ) {
$decoded_slug = ''; // Wipe out the poisoned slug completely
}
$templates = array();
if ( $decoded_slug ) {
$templates[] = "page-{$decoded_slug}.php";
}
$templates[] = 'page.php';
return locate_template( $templates );
}php
// File: wp-includes/template.php (Patched)
function get_page_template() {
$id = get_queried_object_id();
$template = get_page_template_slug();
$pagename = get_query_var( 'pagename' );
$decoded_slug = urldecode( $pagename );
// FIX 1: Run core's validate_file() on the decoded string!
// validate_file() checks for "../", Windows drive letters, and invalid characters.
// If it detects ANY directory traversal attempt, validate_file() returns > 0.
if ( $decoded_slug && validate_file( $decoded_slug ) !== 0 ) {
$decoded_slug = ''; // Wipe out the poisoned slug completely
}
$templates = array();
if ( $decoded_slug ) {
$templates[] = "page-{$decoded_slug}.php";
}
$templates[] = 'page.php';
return locate_template( $templates );
}And in locate_template(), they added a strict boundary check using _wp_is_template_path_allowed():
php
// File: wp-includes/template.php
function _wp_is_template_path_allowed( $template_path ) {
$real_template_path = realpath( $template_path );
// FIX 2: Resolve canonical realpath and ensure it NEVER leaves allowed theme directories
$allowed_paths = array(
realpath( get_stylesheet_directory() ),
realpath( get_template_directory() ),
);
foreach ( $allowed_paths as $allowed ) {
if ( $real_template_path && str_starts_with( $real_template_path, $allowed . DIRECTORY_SEPARATOR ) ) {
return true; // File is legitimately inside the theme folder!
}
}
return false; // Traversal blocked!
}php
// File: wp-includes/template.php
function _wp_is_template_path_allowed( $template_path ) {
$real_template_path = realpath( $template_path );
// FIX 2: Resolve canonical realpath and ensure it NEVER leaves allowed theme directories
$allowed_paths = array(
realpath( get_stylesheet_directory() ),
realpath( get_template_directory() ),
);
foreach ( $allowed_paths as $allowed ) {
if ( $real_template_path && str_starts_with( $real_template_path, $allowed . DIRECTORY_SEPARATOR ) ) {
return true; // File is legitimately inside the theme folder!
}
}
return false; // Traversal blocked!
}By combining validate_file() at input time with realpath() verification at resolution time, any sneaky path tricks are stopped in their tracks.
SOOO Was it bad?
Lol it was a 9.2 on CVSS, it was a nightmare.
-
No Authentication Needed: The attacker doesn't need an account, a password, or a subscriber login. Anyone who can view your website can trigger it.
-
Massive Attack Surface: WordPress is everywhere. Automated threat actor bots scour the internet for unpatched sites 24/7.
-
Exploitation in the Wild: Within hours of the security advisory going live on September 22, 2026, security honeypots and telemetry detected active automated scanning and exploitation attempts worldwide.
The Fix
Update it to patched version
More
- Update WordPress Core Immediately: Upgrade to WordPress 7.1.2
- Check PHP Configuration: In your
php.ini, ensure register_argc_argv = Off`. This kills the common pearcmd.php command-line argument injection gadget. - Disable Unused PHP Modules: Remove or lock down legacy CLI helpers like
pearcmd.phpif they exist in your server environment. - Audit Your Access Logs: Check your web server access logs around September 2026 for incoming requests containing %252e or suspicious
pagename=arguments.
What I Learned From This One
Researching CVE-2026–87902 blew my mind in three specific ways:
This CVE really highlights how dangerous it is when different components of a server trust each other too much. The WordPress flaw was just a path traversal- it let you read files. But because PHP is designed to execute the files it includes, and because the server had old utilities sitting around in the background, a simple read flaw escalated into a complete server takeover.
It shows that securing an application isn't just about fixing the app itself. It's about hardening the entire environment it runs in so that when the app inevitably makes a mistake, the blast radius is contained.
Wanna Go Deeper?
- https://nvd.nist.gov/vuln/detail/CVE-2026-87902
- https://wordpress.org/news/category/security/
- https://github.com/WordPress/WordPress/security/advisories/GHSA-7hp8-65ch-5whp
- https://github.com/WordPress/WordPress/commit/fdeab470f4b4062462cf8ccdc788f258683c2d6f
- https://owasp.org/www-community/attacks/Path_Traversal
This is part of my CVE Decoded series, where I pick a CVE, learn it from scratch, and explain it as simply as I can. I'm not an expert.