WordPress 7.1.2 shipped on 22 September 2026 with one fix, and the security team rated it critical. An unauthenticated visitor could make WordPress include a PHP file from outside your theme, and on the wrong combination of theme and server that becomes remote code execution. The fix was backported to every branch down to 4.7, which tells you how old the underlying code path is.
Most coverage stops at “update now”. That advice is correct, but it leaves the questions a developer or an agency actually has to answer before lunch: is this site exposed at all, how do I prove it is patched, and what do I do for the fleet that cannot be updated today? This article answers those, with the patched code in front of you.
If you read our breakdown of Click2Shell, the 7.1.1 bug that needed one click from an admin, this one is different in the way that matters most: nobody has to click anything, and nobody has to be logged in.
What WordPress 7.1.2 fixes, in one paragraph
The advisory is GHSA-7hp8-65ch-5whp (CVE-2026-87902), reported by Robert Ressl. In the maintainers’ words, an unauthenticated attacker can make get_page_template() page-template resolution include a chosen readable local .php file outside the active theme directories, and if pre-conditions on both the server and the active theme are met, that can lead to RCE. The fix is a single commit on the 7.1 branch titled “Themes: Restrict path traversal in locate_template()“, and it touches one file, wp-includes/template.php.
Two conditions have to line up for the worst case. Understanding both is how you triage a portfolio in minutes instead of guessing.
How the bug works
When WordPress serves a page, it asks the template hierarchy which file to load. For pages, get_page_template() builds a list of candidate file names and hands it to locate_template(), which returns the first one that exists in the child theme, the parent theme, or the core theme-compat folder.
One of those candidates is built from the page name in the request. WordPress also tries a URL-decoded version of that name, so that pages with non-ASCII slugs still find their page-{slug}.php template. Here is the 7.1.1 code:
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}Nothing checks what the decoded name contains. If decoding produces path segments, the candidate stops being a file name and becomes a path. locate_template() then appends it to the theme directory and calls file_exists():
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}That is the whole class of bug: a value that was safe before decoding is used after decoding, and the only gate is whether the resulting path exists on disk.
Why the theme needs a directory starting with page-
The candidate always starts with the literal prefix page-. For a path to walk out of the theme, the first segment has to be a real directory inside the theme whose name begins with page-, otherwise the filesystem lookup fails at the first step and nothing happens. That is why the advisory’s first pre-condition is so specific: the active child or parent theme must contain a top-level directory whose name starts with page-.
That pattern is common. Theme authors have organised custom page templates in a page-templates/ folder for over a decade, because WordPress has supported templates in subdirectories since 3.4. The advisory names the legacy Twenty Twelve and Twenty Fourteen themes, plus popular third-party themes including Neve, Hestia and Sydney. It is not an exhaustive list, so check your own themes rather than scanning for those names.
Why “include a file” becomes code execution
Including a PHP file runs it. On its own, that only lets an attacker run PHP files that already exist on your server, which is bad but limited. The advisory’s second pre-condition is what turns it into full RCE: a readable local .php file that does something useful for an attacker when included in a web request.
The well-known candidate is PEAR’s pearcmd.php. When PHP’s register_argc_argv setting is on, a web request’s query string is exposed to scripts as command-line arguments, and pearcmd.php will happily act on them. That chain has been public in CTF write-ups for years. The advisory says the official php Docker image is affected, and so is the default cPanel configuration when PHP older than 8.5 is in use.
So the realistic worst case is a site on a cPanel host or a stock Docker image, running a theme with a page-templates folder, not yet on 7.1.2. That is not an exotic setup. It describes a lot of client sites.
Are you exposed? The two-part check
Treat exposure as two independent questions. If either answer is no, the RCE chain is broken, but you still update, because the file-inclusion part does not need the second condition.
Condition | What to check | Exposed if |
|---|---|---|
WordPress version | Core version on the site | Below 7.1.2, or below the patched release on an older branch |
Theme | Top-level directories in the active theme and its parent | Any directory name starts with |
Server |
| Setting is on and |
1. Confirm the core version and the fix itself
Version numbers can lie on hand-patched or Composer-built installs, so check for the new function the fix introduced as well as the version string:
wp core version
# The fix adds this private helper. true means the patched locate_template() is in place.
wp eval 'var_dump( function_exists( "_wp_is_template_path_allowed" ) );'Across a fleet, run the same thing through your SSH aliases or your management tool and collect the output in one place. A site that reports 7.1.2 but false for the function did not receive the patched template.php: a hand-edited version file, a partial deploy, or a build step that copied an old core. Composer builds deserve a second look this month for a separate reason, covered below.
2. Check the active theme and its parent
# Active theme and parent theme slugs
wp theme list --status=active --field=name
wp eval 'echo get_template(), PHP_EOL;'
# Any top-level directory starting with page- in either of them
find wp-content/themes/THEME-SLUG -maxdepth 1 -type d -name 'page-*'Check the parent theme too. The advisory says “active child or parent theme”, and most child themes inherit their page templates from the parent.
3. Check the server side in the right PHP context
This is where people get false reassurance. php -i on the command line reports the CLI configuration, and register_argc_argv is always on for the CLI. What matters is the web SAPI, usually PHP-FPM, which can have a different php.ini.
# PHP-FPM's own view of the setting (binary name varies by host: php-fpm, php-fpm8.3...)
php-fpm -i 2>/dev/null | grep -i register_argc_argv
# Is PEAR's command script anywhere readable?
find / -name 'pearcmd.php' -readable 2>/dev/nullIf you cannot run php-fpm -i, drop a temporary file that prints ini_get( 'register_argc_argv' ) into a protected path, request it once, and delete it. Do not leave it behind.
Triage a whole portfolio in one pass
An agency does not have one site, it has forty, and checking them one SSH session at a time is how a Friday disappears. WP-CLI aliases make the three checks above a single loop. Define the sites once in ~/.wp-cli/config.yml:
@client-a:
ssh: deploy@client-a.example.com/var/www/html
@client-b:
ssh: deploy@client-b.example.com/srv/site/publicThen run every check against every alias and print one line per site:
#!/usr/bin/env bash
# 7.1.2 triage: version, patch present, risky theme folders, web SAPI argc setting.
for site in $(wp cli alias list --format=json | jq -r 'keys[] | select(. != "@all")'); do
version=$(wp "$site" core version 2>/dev/null)
patched=$(wp "$site" eval 'echo function_exists( "_wp_is_template_path_allowed" ) ? "yes" : "NO";' 2>/dev/null)
folders=$(wp "$site" eval '
$dirs = array_unique( array( get_stylesheet_directory(), get_template_directory() ) );
$hits = array();
foreach ( $dirs as $d ) {
foreach ( glob( $d . "/page-*", GLOB_ONLYDIR ) ?: array() as $p ) {
$hits[] = basename( $d ) . "/" . basename( $p );
}
}
echo $hits ? implode( ",", $hits ) : "none";' 2>/dev/null)
printf '%-14s %-8s patched=%-4s page-dirs=%s\n' "$site" "$version" "$patched" "$folders"
doneAnything with patched=NO is your update list. Anything that is also listed with a page-* directory goes to the top of it, and gets the server check from step 3 before you move on. The loop deliberately does not test register_argc_argv, because wp eval runs under the CLI where the setting is always on; that one has to be checked in the PHP-FPM context on each server, not per site.
Keep the output. When a client asks next month whether they were affected, a dated line that says “7.1.0, page-templates present, updated 23 September” is a better answer than a memory.
What the patch changes
The fix has two layers, which is the right design: reject the dangerous input where it is created, and also verify the result where the file is loaded.
First, the decoded page name is only used if it passes validate_file(), the core helper that rejects .. segments and other traversal shapes:
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}Second, locate_template() no longer trusts file_exists() alone. Every candidate goes through a new private function, _wp_is_template_path_allowed(). A path without .. passes immediately. A path with .. is resolved with realpath(), and it is only accepted if the real location sits inside the child theme, the parent theme, core’s theme-compat folder, or (for themes installed in a subdirectory) the direct parent of the theme folder:
$real_path = realpath( $path );
if ( false === $real_path ) {
return false;
}
$real_path = trailingslashit( wp_normalize_path( $real_path ) );
foreach ( $directories as $directory ) {
$real_directory = realpath( $directory );
if ( false === $real_directory ) {
continue;
}
if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
return true;
}
}
return false;The second layer matters beyond this bug. locate_template() is a public function that themes and plugins call with their own template names, and some of those names come from request data. After 7.1.2, a template name containing .. is only located if it resolves inside the theme, no matter which caller built it. That closes a whole class of theme and plugin mistakes, not just the one path in get_page_template().
Notice what the fix does not do: it does not reject every ... A theme that legitimately locates ../shared/header.php inside its own tree still works, because the check is about where the file really lives, not what the string looks like. That is why the change is safe to backport all the way to 4.7, a branch from 2016.
How to update to WordPress 7.1.2 safely
Dashboard and automatic updates
Most sites with automatic background updates for minor releases will already be on WordPress 7.1.2. Confirm rather than assume: a disabled cron, a WP_AUTO_UPDATE_CORE set to false, or a host that pins versions will all leave a site behind silently. The wp core version check above is the source of truth.
WP-CLI across many sites
wp core update --minor
wp core update-db
wp eval 'var_dump( function_exists( "_wp_is_template_path_allowed" ) );'--minor keeps each site on its current branch, which is what you want for a security release: a 6.9 site gets 6.9.9, not an unplanned jump to 7.1.
Sites on older branches
The fix was backported to every branch still eligible for security fixes, down to 4.7. The releases tagged on 22 September include 7.0.6, 6.9.9, 6.8.10, 6.7.9, 6.6.9 and 6.5.12; check the release archive for the exact version on older branches. The WordPress project is also clear that only the latest version is actively supported, so treat a backport as a reprieve, not a plan.
Composer-managed sites
If you build WordPress with Composer through johnpbloch/wordpress-core, check the installed tree, not just the lock file. The 7.1.1 and 7.0.5 tags of that package were published without the wp-includes directory (issue #40, still open at the time of writing), so some teams pinned themselves to 7.1.0 last week to avoid a broken deploy. The 7.1.2 tag was published on 22 September and does contain wp-includes, so moving straight from 7.1.0 to 7.1.2 is the clean path:
composer require johnpbloch/wordpress-core:7.1.2 --update-with-dependencies
test -f web/wp/wp-includes/template.php && grep -c "_wp_is_template_path_allowed" web/wp/wp-includes/template.phpAdjust the path to wherever your install puts core. A count of 1 or more means the patched file is on disk. Commenters in the same thread point out that the package is effectively unmaintained and that WPackagist’s documentation now points to roots/wordpress, which is worth evaluating the next time you touch your build, not in the middle of a security update.
Docker images
If your images are built FROM php and you ship WordPress inside them, update the WordPress layer and rebuild. The advisory calls out the official php image specifically, so this is not the place to wait for a scheduled rebuild. While you are in the Dockerfile, the mitigation below is one line.
If a site cannot be updated today
Sometimes a client site is frozen for a launch, a legal hold, or a plugin that only works on an old version. You can break the RCE chain without touching core, and you should, because these mitigations remove the second pre-condition entirely:
- Turn off
register_argc_argvfor the web SAPI. Setregister_argc_argv = Offin the PHP-FPMphp.ini(or a pool’sphp_admin_flag) and reload FPM. Web requests have no legitimate use for it. - Remove PEAR from web servers that do not need it. Most WordPress hosts never use it at runtime. If something does, make sure its directory is not readable by the web server user.
- Add a WAF rule for traversal in page names. Block requests where the page name, in the path or the
pagenamequery variable, contains encoded path segments. Wordfence published a PSA the same day; check whether your WAF vendor has a rule for this advisory and that it is enabled, rather than writing one from scratch.
These are the same controls our WordPress security hardening checklist recommends for every site, so treat today as the day you apply them fleet-wide rather than only on the frozen ones.
What not to do: do not rename the theme’s page-templates folder. Every page that uses a custom template stores the path, such as page-templates/full-width.php, in its _wp_page_template meta. Renaming the folder silently switches those pages back to the default template.
Checking whether anyone tried
The window between disclosure and patching is when scanners arrive. On any site that was on an unpatched version after 22 September, look through the access logs for page requests whose path or pagename parameter contains percent-encoded dots or slashes, and for requests that combine a page URL with PEAR-style arguments in the query string. A hit is not proof of compromise, but on a site that also met both pre-conditions it is a reason to check for new PHP files in writable directories, unexpected admin users and modified wp-config.php, and to restore from a known-good backup if anything looks off. Our backup strategy guide covers what “known-good” should mean before you need it.
What theme and plugin developers should take from this
The lesson is older than WordPress: validate after the last transformation, not before it. The page name was a perfectly safe string until urldecode() turned it into something else, and every check that ran on the raw value became meaningless. If your code decodes, normalises, or joins paths, the validation has to happen on the final value, as close as possible to where it is used.
Three concrete habits follow from the patch:
- Never pass request data into
locate_template(),get_template_part()orincludewithout an allowlist. Map user input to a fixed set of template names instead of building a file name from it. 7.1.2 now protectslocate_template(), but a directincludein your plugin gets no such help. - Use
validate_file()for simple cases andrealpath()containment for anything that can contain... The new_wp_is_template_path_allowed()is private, so do not call it, but its approach (resolve the real path, then require it to start with an allowed directory) is the pattern to copy. - Remember that your folder names are part of the attack surface. Nobody writing a
page-templatesfolder ever expected it to matter for security. Structure is not neutral once a bug elsewhere can walk through it.
It is also worth noticing how this one surfaced: a single external researcher, and a fix released for every supported branch back to 4.7 on the same day. Pair that with the six-hour security hold now applied to every plugin release on WordPress.org and the picture is of an ecosystem that is getting faster at response. That speed only protects the sites that actually take the update.
Questions we have been asked this week
Are block themes affected?
The bug lives in PHP template resolution, and the theme pre-condition is a top-level directory whose name starts with page-. Block themes keep their templates as HTML files in templates/ and parts/, so most of them do not have such a directory. “Most” is not “all”, though, and hybrid themes that ship classic PHP page templates alongside block templates are common. Run the directory check instead of reasoning from the theme type.
We were on 7.1.1 for a few days. Were we hacked?
Being unpatched is not the same as being compromised. If the theme had no page-* directory, the traversal could not leave the theme in the first place. If it did, but the server had register_argc_argv off or no readable PEAR, the known route to code execution was not available. Only sites that met every condition need the incident checklist above: logs, new PHP files in writable directories, unexpected admins, and a modified wp-config.php.
Does a security plugin cover this?
A firewall rule can block the request pattern, and that is a useful layer while you schedule updates. It is not a substitute for the fix, because a signature matches the request shapes its authors thought of, while the patch removes the behaviour. Treat the WAF as the thing that buys you a day, and the update as the thing that ends the problem.
The short version for your team channel
- WordPress 7.1.2 (and backports down to 4.7, including 7.0.6 and 6.9.9) fixes an unauthenticated path traversal in page template resolution.
- Full RCE needs a theme with a top-level
page-*directory and a server whereregister_argc_argvis on with PEAR readable. cPanel on PHP below 8.5 and the stockphpDocker image qualify. - Verify with
wp eval 'var_dump( function_exists( "_wp_is_template_path_allowed" ) );', not just the version string. - Composer users on
johnpbloch/wordpress-core: go straight to 7.1.2, which ships complete. - Cannot update yet: turn off
register_argc_argvfor PHP-FPM, remove PEAR, and enable your WAF’s signature. Do not rename theme folders.
If you look after a portfolio of client sites and want a second pair of hands for security updates like this one, the team behind attowp builds and maintains WordPress sites every day at Wbcom Designs.





No comments yet