Vulnerability research
WordPress: how template names escaped their directory
CVE-2026-87902 turns a decoded WordPress page name into a template path, enabling local PHP inclusion and conditional code execution; with attacks publicly reported, update to 7.1.2 or the appropriate branch fix and investigate files left before patching.

In this article
WordPress released 7.1.2 on September 22 to fix a template-resolution vulnerability reachable without authentication. A visitor-supplied page name was decoded and incorporated into a template filename. With a suitable theme layout, that path could leave the theme directory and cause PHP to include another PHP file on the server. CISA added CVE-2026-87902 to its Known Exploited Vulnerabilities catalog on September 25. Administrators now have both a patching task and a reason to investigate exposure before the update.
The current upstream update target is WordPress 7.1.2. Prioritize internet-facing sites running affected core versions whose active child or parent theme contains a top-level directory beginning with page-. The official advisory gives page-templates as an example and names Twenty Twelve, Twenty Fourteen, Neve, Hestia and Sydney among themes with relevant layouts. The eventual impact also depends on which local PHP files the web-server account can read and what those files do when included. Theme names alone are an incomplete update criterion; all affected cores belong in the repair plan.
Our recommendation is to run patch deployment and historical investigation in parallel on matching sites. The patch prevents new out-of-directory template selection. Files already written, account changes and exposed credentials need separate treatment. The underlying error is useful to understand beyond WordPress: a name prepared for a database query later acquired filesystem meaning through decoding and concatenation.
1 How a page name reaches template selection
To display a page, WordPress first queries its content and then chooses a template to render it. A classic theme can assign a custom template to an individual page, provide a file based on the page name or ID, and finally fall back to page.php. A page named “关于,” for example, can use page-关于.php. This gives theme authors a straightforward way to arrange a particular page differently.
The decoding step was added to support that feature. In 2016, commit e7b0581 introduced decoded template candidates for non-ASCII names and updated the related tests with an emoji-containing name. WordPress 4.6.1 only concatenated the original page name; 4.7.0 contained the new branch. In an ordinary Chinese example, %e5%85%b3%e4%ba%8e decodes to “关于,” allowing a matching Chinese filename on disk. The compatibility requirement was concrete. The decoded value needed another check before being used as a path.
The request entry point accepts pagename and page_id. In the vulnerable 7.1.1 snapshot, commit a940fbc1e63a7e24d31a28e707e545ad4873cb84, lines 319–336 of WP::parse_request() collect permitted query variables. Either GET or POST can supply them. Supplying the same variable in both with different values produces a variable-mismatch error and HTTP 400. That condition matters when following the input path; the two sources do not unconditionally overwrite one another.
The two fields then do different jobs. The page-name branch of WP_Query::get_posts() looks up the page and normalizes its name. Later, at lines 2276–2280, a page_id meeting the branch conditions replaces the SQL condition with an ID-based selection, while pagename remains in the query variables. A request can therefore select real page content while leaving a request-supplied name for template resolution. The page must be accessible, and front-page settings, the posts page, attachments and plugin changes can affect the branch actually taken.
Why did name normalization allow the later path change? sanitize_title_with_dashes() preserves valid percent-encoded octets to support non-English names in URLs. Literal dots and other characters are processed, while encoded forms can remain in the string. The query stage handles a name. A subsequent urldecode() in the template stage turns encoded content into characters with path semantics. The same value passes through two uses with different safety requirements.
The candidate is created in get_page_template(), lines 471–504 of wp-includes/template.php. An explicitly assigned custom template enters the list first, followed by the decoded name, original name, page ID and generic template. The relevant old branch is small.
// WordPress 7.1.1, excerpt from get_page_template()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
The fixed page- prefix and .php suffix constrain the path. For traversal through this branch to work, resolution first needs an existing directory whose name starts with page-; parent-directory components can then take it elsewhere. This explains the theme-directory prerequisite in the advisory. A different, higher-priority template that already exists can also change the outcome of a particular request. Inventory needs the core version, active theme directories and the effective template hierarchy together.
2 An existing file is handed to PHP
get_query_template() passes the candidate list into template lookup. In 7.1.1, locate_template() tries the active child theme, the parent theme and the core theme-compat directory in order. It appends the candidate name to each directory and calls file_exists(). The first existing file becomes the result. There is no directory-containment check at this point.
The filesystem resolves parent-directory components. Prepending the theme directory therefore leaves open the question of where the path ends. The attacker also needs a local PHP file that actually exists and is readable by the web-server account. A missing file, denied permissions or an earlier successful template candidate can prevent the request from reaching the intended target.
Execution happens in lines 114–132 of wp-includes/template-loader.php. After the template_include filter, the loader calls realpath(), checks the file type, readability and accepted extension, and reaches include $template. Canonicalizing the path here produces its resolved location without comparing it to the theme directories. PHP executes the file with ordinary include semantics; output and side effects depend on the selected file.
pagename supplied in the request
→ query variables retain the encoded page name
→ get_page_template() decodes it and builds a candidate
→ locate_template() finds an existing local file
→ template-loader.php checks file properties
→ include executes that PHP file
Block templates can affect this route. locate_block_template() may select a block template and return the template canvas, or preserve an existing PHP candidate. Theme support declarations, template priority and filters all participate. A classic PHP theme provides the direct path explained here, with the vendor's directory conditions retained. Merely knowing that a site uses a block theme is insufficient to close its version and configuration check.
The further execution chain described in the official advisory uses pearcmd.php on the server. This is PEAR's command-line entry point. When register_argc_argv is enabled in the web PHP environment, request content can enter the argument structure it consumes. The advisory identifies the official PHP Docker image and the default cPanel configuration with PHP earlier than 8.5 as relevant environments. Individual images and hosts may have changed those settings. Inspect the PHP-FPM pool or Apache module serving the site; a CLI configuration printed in a terminal describes that CLI process.
This gives two useful levels of impact. The immediate capability is selecting and including a local PHP file. Turning it into the attacker's chosen code execution requires a suitable file and environment. Disabling register_argc_argv interrupts the described PEAR argument chain. The core patch is still needed to repair out-of-directory template inclusion, and the behavior of other local files depends on the actual installation.

Scroll sideways to read the diagram.
3 The patch checks the name and its destination
Fix commit 170944a, merged on September 22, changes only wp-includes/template.php and adds two complementary checks. The following references use the pinned 7.1.2 snapshot, 0a106cde38df869e196a83eb4975ba38a4e5837b. First, immediately after decoding, lines 491–498 require validate_file() to return zero before adding the decoded candidate.
// WordPress 7.1.2, excerpt from get_page_template()
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename
&& 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
validate_file() normalizes path separators and rejects parent-directory traversal forms recognized by its rules, Windows drive paths and other disallowed inputs. Ordinary Chinese names can still pass. Placing the check after decoding lets it inspect the characters about to enter a filesystem path, closing the gap between the earlier name handling and the later file use.
The second check is in shared lookup. After finding an existing candidate, the new locate_template() requires _wp_is_template_path_allowed() to approve it before returning the path. A rejected candidate leaves the loop free to try subsequent template names, allowing an ordinary fallback template to be selected.
The new helper has a specific scope. Lines 713–760 first look for ..-style directory components in a normalized path. An existing path without such a component passes immediately. When deeper checking is required, the helper resolves the path with realpath() and compares it with the real paths of allowed directories. A trailing separator is retained for the comparison, preventing a different directory with the same textual prefix from being accepted as a child.
The allowed set contains the active child theme, parent theme and wp-includes/theme-compat. If a theme identifier itself names a subdirectory, the code also permits the corresponding theme's direct parent directory for compatibility. This adds a defined restriction to template traversal. The fast path, compatibility locations and trusted plugin filters retain their own semantics. Describing the change as confinement of every PHP template to the active theme would overstate its guarantee and produce the wrong acceptance criteria for custom themes.
We also compared the same file in 4.7.36 and 4.7.37, 6.8.9 and 6.8.10, and 7.0.5 and 7.0.6. Each pair adds post-decoding validation and the lookup-time path check. Older branches use constants and string operations compatible with their PHP requirements. Fixed versions for the remaining branches come from the vendor's release table; we did not independently inspect every branch. The fix commit itself adds no test files, and the name-compatibility tests introduced in 2016 are not a dynamic verification record for this security repair.
4 Update the core and contain requests upstream
WordPress 7.1.2 is both the current upstream release and this branch's first fixed version. Courtesy patches are available for older branches back to 4.7, with the full mapping in the GHSA advisory. The table covers the current branch and selected older branches. A site already on its branch's fixed version should not be flagged by a coarse rule treating every version below 7.1.2 as vulnerable.
| Upstream branch | Affected range | First fix in that branch |
|---|---|---|
| 7.1 | 7.1.0–7.1.1 | 7.1.2, current upstream target |
| 7.0 | 7.0.0–7.0.5 | 7.0.6 |
| 6.9 | 6.9.0–6.9.8 | 6.9.9 |
| 6.8 | 6.8.0–6.8.9 | 6.8.10 |
| 6.1 | 6.1.0–6.1.13 | 6.1.14 |
| 4.7 | 4.7.0–4.7.36 | 4.7.37; other branches are listed in the advisory |
Receiving this patch does not renew an old branch's maintenance commitment. The release announcement says that only the most recent WordPress version is actively maintained; older fixes are a courtesy to users still on those versions. Compatibility needs may determine which backport is deployed first, while the longer-term target should remain a maintained release. WordPress 4.6 and earlier are also unsuitable rollback targets merely because they predate this decoding branch.
Distribution-managed installations need their package maintainer's status as well. As checked on September 27 at 06:26 UTC, Debian lists bookworm's 6.1.9+dfsg1-0+deb12u1, trixie's 6.8.7+dfsg1-0+deb13u1 and forky's 7.1+dfsg1-1 as vulnerable; sid's 7.1.2+dfsg1-1 is fixed. Ubuntu lists six releases from 16.04 through 26.04 as Needs evaluation. These records can change and should be checked again during deployment. Mixing a sid package into a stable system, or allowing an upstream updater to overwrite package-managed files, introduces additional maintenance uncertainty.
Sites installed from upstream packages with an established backup and update process can use the dashboard or WP-CLI. Record the core version, active theme, PHP environment and deployed path; confirm recoverable code and database backups; then check theme and plugin compatibility in a matching copy. The following are administrator instructions. Distribution packages and managed hosting should use their respective update channels.
wp core version
wp core update --version=7.1.2
wp core version
wp core verify-checksums --version=7.1.2 --include-root
After updating, confirm that every instance serving requests has the new files. Account for old code retained by PHP workers or OPcache, and check the nodes behind the load balancer. A management container reporting 7.1.2 only identifies that container's installation. If the upgrade fails, keep temporary restrictions in place and recover to a deployment or branch version verified to contain this fix. Preserve vulnerable images only as isolated investigation material.
When an update cannot be completed immediately, our temporary recommendation is to restrict dynamic WordPress requests at the reverse proxy or load balancer, serving static pages if necessary. The business cost is direct: logins, forms, shopping and dynamic content may be unavailable. This reduces exposure before requests reach PHP. A maintenance plugin inside WordPress requires separate confirmation that it ends the request before the affected flow; the appearance of a maintenance page says little about where processing stopped.
As an additional short-term measure, disable register_argc_argv in the actual web PHP configuration, reload the relevant service and verify that it took effect. This interrupts the published PEAR argument chain. Switching to a verified theme without the relevant directory layout can also remove this entry point, but may alter page templates, components and site features, so compatibility checks come first. WAF rules can buy time for deployment. They need to handle GET, POST and multiple encoding layers; matching one literal URL string leaves gaps between the rule and the application's parsed value. These measures can be withdrawn after the core patch is deployed and the functional and path checks below pass.
5 Test ordinary pages and investigate earlier changes
Acceptance starts with templates the site actually uses. Open an ordinary page, a page addressed by ID, a Chinese-named page and a page assigned a custom template, checking both content and layout. Sites using child themes, nested theme directories or block templates should also check their existing priority and fallback behavior. The patch preserves normal decoding and compatibility locations. Breaking all non-English pages after an update is a regression that needs attention.
Teams maintaining custom packages can add a harmless out-of-directory fixture in an isolated copy and inspect the template lookup function's return value directly. A candidate containing parent-directory components should be rejected when it resolves outside the allowed set; ordinary candidates inside a theme should still return the expected path. Use only purpose-built test directories, never production configuration, system utilities or credential files as targets. Include a missing target, parent-theme fallback and nested-theme compatibility directories, so a single rejection case does not become the whole acceptance test.
wp core verify-checksums compares core files with WordPress's published checksums and runs before WordPress is loaded. Select the checksum set for the installed version and locale; --include-root also reports extra files in the root directory. Plugins, themes, uploads, the database and other host locations need separate inspection. Matching core checksums answers the core-file question only. If host compromise is suspected, preserve evidence and inspect a controlled copy with trusted tools, avoiding continued reliance on the host's existing management programs.
There is a concrete reason to investigate the earlier exposure. Patchstack's current observation report records probes on September 22 and describes requests attempting PHP file writes through PEAR in its update the next day. The September 25 CISA KEV entry establishes known exploitation. These sources support urgent handling. They provide no victim rate for all WordPress installations, and we have not used them to estimate successful compromises.
Log review can look for unusual requests carrying pagename, queries also using page_id, encoded or repeatedly encoded parent-directory components, and paths mentioning pearcmd. Examine proxy access logs and legitimately available application request records; ordinary access logs may omit POST parameters. Preserve original request values and decode copies for analysis, retaining the encoding layers as evidence. Correlate an HTTP 200 with responses, file changes and later activity, and distinguish edge-blocked requests from those that actually reached PHP.
On the host, prioritize web-account-writable locations, temporary directories, theme and plugin directories, uploads and unexpected PHP files. Correlate file timestamps with deployments and requests, then review newly created administrators, scheduled jobs, configuration changes and subsequent outbound activity. Confirmed file drops or other evidence of control call for isolating the instance, rebuilding code from trusted sources and replacing site and database credentials according to their actual exposure. Removing one suspicious file leaves unanswered what it did while running.
Scores need attribution too. The WordPress GHSA assigns 9.2 using CVSS 4.0. NVD was Undergoing Analysis at the research cutoff; its displayed CVSS 3.1 score of 8.1 comes from CISA ADP. The original CNA description uses a broad range below 7.1.2, while the vendor's branch table identifies the older fixed releases more precisely. Environment conditions, known exploitation and repair availability already give administrators a useful priority. Mixing score systems or misnaming their source makes that decision harder.
This article follows pinned source through query variables, name normalization, template selection, file inclusion and both checks, with three older-branch backports inspected separately. We ran no full WordPress, theme or PEAR exploitation experiment and sent no test requests to external sites. Attack activity is attributed to the public observations above. Whether a particular site reached the vulnerable branch or acquired an unexpected file still requires that site's configuration and records.
The code-review lesson has a specific location: recheck data when its use changes. A name normalized for a query is later decoded, given a prefix and suffix, interpreted by the filesystem and finally executed by PHP. Following those interpretations in order explains both why the theme layout matters and which result should be verified after the patch.
Research basis
Research basisPinned source and patch analysis with vendor guidance and attributed attack observations; no WordPress exploitation experiment was run. Official status checked through September 27, 2026, 06:26 UTC.
SourceWordPress, CISA, Patchstack and distribution records
Evidence confidence High
6Evidence and sources
6.1Timeline
- Decoded template names introduced
Commit e7b0581 supports non-ASCII queried names; the branch is present in WordPress 4.7.
- 7.1.2 and older-branch fixes released
WordPress publishes GHSA-7hp8-65ch-5whp with decoded-name validation and template-path checks.
- Attack observation updated
Patchstack updates its account of the previous day's probes with requests attempting PHP file writes through PEAR.
- Known exploitation recorded by CISA
CVE-2026-87902 enters KEV; exposed sites should investigate activity before the patch alongside remediation.
6.2Sources and material
- WordPress advisory, prerequisites and complete branch-fix tablehttps://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp
- WordPress 7.1.2 release and maintenance scopehttps://wordpress.org/news/2026/09/wordpress-7-1-2-release/
- The 2016 template-name compatibility changehttps://github.com/WordPress/wordpress-develop/commit/e7b058117ae831898a39517516b11be7d39cf33d
- The template.php security fixhttps://github.com/WordPress/wordpress-develop/commit/170944a34b6f4d76decffb5c40b5b068dc7779d8
- Patchstack observations of probing and file-write attemptshttps://patchstack.com/articles/cve-2026-87902-attackers-started-probing-wordpress-sites-hours-after-the-patch/
- Canadian Centre for Cyber Security advisory AV26-952https://www.cyber.gc.ca/en/alerts-advisories/wordpress-security-advisory-av26-952
- CISA KEV catalog datahttps://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
- Original CNA and CISA ADP recordhttps://cveawg.mitre.org/api/cve/CVE-2026-87902
- NVD status, score attribution and change historyhttps://nvd.nist.gov/vuln/detail/CVE-2026-87902
- Debian package repair statushttps://security-tracker.debian.org/tracker/CVE-2026-87902
- Ubuntu package evaluation statushttps://ubuntu.com/security/CVE-2026-87902
- PHP register_argc_argv configuration semanticshttps://www.php.net/manual/en/ini.core.php#ini.register-argc-argv
- WP-CLI core checksum scope and optionshttps://developer.wordpress.org/cli/commands/core/verify-checksums/