WordPress page-template traversal: when file inclusion becomes RCE
On September 22, 2026, the WordPress security team shipped WordPress 7.1.2, a security release closing a path traversal bug in core page-template resolution. The advisory, CVE-2026-87902, is rated critical (CVSS v4 9.2) and describes an unauthenticated attacker making get_page_template() include “a chosen readable local .php file outside the active theme directories”. The request that triggers it looks like an ordinary front-end page view with a crafted pagename.
This is a local file inclusion bug in WordPress core. On its own it does not give an attacker a shell. Turning it into remote code execution takes two preconditions, one in the active theme and one in the PHP environment, which sets it apart from wp2shell in July, a chain its discoverers said needed nothing beyond a stock install. Attackers still started probing for it the same day the fix shipped.
Fixed releases go back to 4.7
Every WordPress release from 4.7.0 through 7.1.1 is affected. The fix ships in 7.1.2 and was backported to every branch back to 4.7, so each branch has its own patched release: 7.0.6, 6.9.9, 6.8.10, 6.7.9, and so on down to 4.7.37; the advisory lists them all.
WordPress.org recommends updating immediately, and sites with automatic background updates should have updated themselves. Confirm it anyway, on every install you own, including staging and the ones nobody has logged into for a year.
How the bug works
When WordPress works out which theme file should render a page, get_page_template() in wp-includes/template.php builds a list of candidate filenames from the pagename query variable, including a urldecode()d copy of it. Before the fix, that decoded value went straight into a "page-{$pagename}.php" candidate without passing through validate_file(), WordPress’s own helper that returns 1 when a path contains directory traversal. Decoding is what turned a traversal-shaped slug into a real path, as Patchstack’s technical breakdown shows.
From there the candidate went to locate_template(), which checked that the file existed but not where it lived. In his write-up, Robert Ressl, who found the bug, says the final loader checks type, suffix and readability “but does not prove that the resolved path remains inside an allowed theme directory.” A user note on the locate_template() reference had warned in 2017 that the function “does not prevent directory traversal attacks”.
The patch does two things. get_page_template() now uses the decoded pagename only when validate_file() returns 0, and locate_template() gained _wp_is_template_path_allowed(), which resolves any candidate with a .. path segment through realpath() and rejects it unless the real location sits inside the active theme’s stylesheet directory, its template directory, or wp-includes/theme-compat/. The second check covers any template name that reaches locate_template(), whatever query variable or encoding produced it.
Two conditions for code execution
File inclusion only becomes code execution, the advisory says, when both of these are true.
The active theme, or its parent, has a top-level directory whose name starts with page-.
A folder such as page-templates/ (or a symlink to one) is enough. The traversal payload is concatenated onto a page- prefix before it reaches the filesystem, so a real directory matching that prefix has to exist for the ../ segments to walk out of. The advisory names the legacy Twenty Twelve and Twenty Fourteen default themes and the third-party themes Neve, Hestia, and Sydney as examples that ship one. A theme with no page-* directory does not offer this route, even on an unpatched core.
A readable .php file with a useful side effect exists on the server.
In the attacks seen so far that file is pearcmd.php, PEAR’s command-line tool. It becomes an arbitrary-file-write primitive when PHP’s register_argc_argv directive is On, because PHP then fills $_SERVER['argv'] from the query string on web requests. An attacker passes PEAR commands such as config-create through the URL to write a new PHP file into /tmp, then includes that file through the same traversal. pearcmd.php is simply the best-known target; any PHP file that does something useful when included would serve.
Whether the setting is On depends on where your PHP came from. Up to 8.4 the compiled-in value of register_argc_argv is On and the bundled php.ini-production turns it Off, so a runtime with no php.ini, which is how the official Docker image ships unless you copy one in, gets On. The advisory says the official php Docker image is affected, and so is the default cPanel configuration when PHP before 8.5 is in use. PHP 8.5 changes the compiled default to Off and deprecates the query-string behaviour.
Ressl is careful about both conditions. The current default themes he inspected have no top-level page-* directory, so his lab adds an empty one to Twenty Twenty-Five, “an explicit test prerequisite, not evidence of an exploitable untouched default installation”; and PEAR “is not a WordPress dependency”.
When the advisory reached Hacker News, one of the first comments called it “a somewhat situational WordPress RCE that impacts only a couple themes”, while other commenters answered that WordPress’s own theme handbook calls a page-templates/ folder “a common convention” and that developers routinely ship heavily edited copies of the core themes. Nobody has published a measurement of how many sites meet both conditions, and custom themes are the ones you will have to check yourself.
What is confirmed about exploitation
Ressl reported the bug privately through WordPress’s HackerOne program on July 20, 2026, and published a proof of concept and reproducible lab the day the advisory and fix went public. Patchstack’s exploitation write-up records the first blocked attempt at 11:49 UTC on September 22, and the first attempt to write a file through pearcmd at 15:34 UTC the same day. Public scanning tools followed on September 23.
In Patchstack’s data the traffic always arrives percent-encoded, often double-encoded, because WordPress’s slug sanitiser rewrites literal dots and truncates at literal slashes; “a plain ../../ payload does not survive it.” A typical first request looks like GET /?page_id=<valid page id>&pagename=templates%252f<traversal>wp-links-opml, including a harmless core file to confirm the traversal works. Follow-ups go looking for pearcmd.php in the usual PEAR paths and drop PHP files into /tmp or /var/tmp. WordPress reads pagename from the POST body in preference to the query string, and POST has since overtaken GET.
By September 24, The Hacker News reported that Previdian’s honeypots had caught attackers pulling a web-shell uploader from a public GitHub repository. Previdian’s Ryan Dewhurst still expects “mass-exploitation attempts, but relatively few actual compromises”, because the preconditions are narrow and auto-updates are on by default.
Where a static public site helps
On a live WordPress site the vulnerable lookup runs on ordinary front-end traffic: any published page reached through pagename with a valid page_id. The main exception is a page whose assigned custom template file exists in the active theme. There is no setting to turn the lookup off.
When you publish with Staatic, WordPress stays where the editing happens and the public site is served as pre-rendered HTML and assets. No WordPress process answers anonymous requests there, so a crafted pagename is never parsed. The pearcmd.php write and the follow-up request to the planted file both need PHP reachable from the public site, and a host that runs no PHP for that site has nothing to execute.
If you use the Local Directory method and serve the static site from the same server as WordPress, that server still runs PHP for the origin, and restricting who can reach the WordPress install is what closes the door there.
Where it does not help
A static public site does nothing for the origin. It does not patch get_page_template(), remove a page-templates/ folder, or change php.ini, and an editorial install that anyone can reach is exposed to the full advisory. Commenters in the same Hacker News thread made the fair counterpoint that WordPress can be updated from the command line without giving the site write access to its own code, and that operators should either upgrade quickly or keep a restrictive WAF in front. Static delivery does not replace that.
The payloads seen so far are PHP files dropped in /tmp or /var/tmp through pearcmd, plus the web-shell uploader Previdian saw fetched in, which can write anywhere the web server can. If any of that ran before you patched, the templates and content WordPress feeds into the next build are suspect. Restore the origin from a clean restore point (Staatic Cloud users can lean on regular WordPress backups), check it, and only then publish, using the Reviewing guide to inspect what goes out.
What to check on each install
- Update to the fixed release for your branch (7.1.2, or the backport down to 4.7.37) and confirm the running version on each install. The patch fixes the root cause whatever your theme contains.
- List the root of the active theme, and of the parent theme if there is one. A folder starting with
page-means the inclusion was reachable before you patched, and code execution too if steps 3 and 4 also turn something up; that decides how hard to look in step 5. - Read
register_argc_argvfrom a web request, not fromphp -i. The CLI always registersargvand may load a differentphp.ini, so usephpinfo()orini_get('register_argc_argv')served by your real web runtime. Set it toOffunless something depends on it. - Check for
pearcmd.phpin/usr/local/lib/php/,/usr/share/php/and/usr/share/pear/. If you do not use PEAR, removing it takes away the target the observed attacks rely on. If your PHP is host-managed, ask the host about this and step 3. - Search for traces. Look for unexpected
.phpfiles in/tmp,/var/tmpand web-writable directories such aswp-content/uploads, includingwp-pear-rce-flag.php(the public proof of concept’s default),poc87902.php,luci_<random>.phporzeta_<random>.php. In access logs, look forpagename=values containing%2e%2eor%252e%252e(either case), or starting withtemplates%2for anotherpage-directory name, next to apage_id=; forpearcmd,config-showorconfig-create; and for the user agentscve-2026-87902-poc/1.0andnuclei-cve-2026-87902/1.0. POST payloads will not show up in ordinary access logs, and a 200 response carrying OPML or RSS output from a normal page URL means the inclusion ran.
Who gets to send the request
Everything this bug needs apart from the request itself lives on the origin, and the request is the one part you can stop accepting from strangers. Keep WordPress on a host that only your team can reach and serve a static build to everyone else. You still have to patch; what changes is who can probe you in the gap before you do.
The Preparation guide covers restricting access to the WordPress host before your first publish.