Seven steps from a failed login to PHP
- Unauthenticated request
- Browser-side primitive
- Stolen admin credential
- Server-side code execution
A reflected cross-site scripting bug on a login form is the most triaged-away finding in web security. It lands in the report as medium, the remediation ticket says “encode the error message”, and it waits behind whatever is actually on fire this quarter.
CVE-2026-64638 is that finding, in WordPress Core, on a login page that answers on a very large share of the public web. Discovered and responsibly disclosed by the team at pwn.ai, it carries a CVSS 4.0 score of 8.9 — because between the reflected XSS and PHP running on the server sit six more steps, and every one of them was already shipping in core.
WordPress fixed it in 7.0.3 on 6 August 2026 and backported the patch across 24 maintenance branches, down to 4.7.34.
The one-sentence version. The XSS needs no account at all. The code execution needs one further thing the attacker cannot manufacture: a logged-in administrator who clicks once on a page the attacker controls.
That caveat matters for how you prioritise, and it is the part that gets dropped when this circulates as “unauthenticated WordPress RCE”. It is not zero-click. It is also not hard — the click is an ordinary one on an ordinary-looking page.
Am I exposed?
The only question that matters first is which point release you are on, and the answer is not “we’re on 6.8, which is old but fine”. The fix was backported, so 6.8.7 is patched and 6.8.6 is not.
| Branch | Last vulnerable | Patched release |
|---|---|---|
| 7.0 | 7.0.2 | 7.0.3 |
| 6.9 | 6.9.5 | 6.9.6 |
| 6.8 | 6.8.6 | 6.8.7 |
| 6.7 | 6.7.5 | 6.7.6 |
| 6.6 | 6.6.5 | 6.6.6 |
| 6.5 | 6.5.8 | 6.5.9 |
| 6.4 | 6.4.8 | 6.4.9 |
| 6.3 | 6.3.8 | 6.3.9 |
| 6.2 | 6.2.9 | 6.2.10 |
| 6.1 | 6.1.10 | 6.1.11 |
| 6.0 | 6.0.12 | 6.0.13 |
| 5.9 and below | down to 4.7.33 | down to 4.7.34 |
We published a passive checker that infers a site’s WordPress version from public endpoints only — no login attempts, no payloads, nothing a WAF should log as an attack:
It reads the generator tag, the feed, readme.html, the ?ver= strings on core assets and the REST index, cross-checks them against the table above, and tells you which signals disagreed. Scan only domains you own or are authorised to test.
Step 1 — Two parsers, one string
The injection point is the failed-login error. wp_authenticate_username_password() in wp-includes/user.php echoes the submitted username back into the page, after sanitisation. Two different sanitisers touch it, and they do not agree on what a tag is.
PHP’s strip_tags() requires the < to be immediately followed by the tag name. WordPress’s KSES layer is more forgiving, and treats a space after the bracket as whitespace inside a perfectly ordinary tag:
strip_tags('<area id=test>'); // '' — stripped
strip_tags('< area id=test>'); // '< area id=test>' — survives
// …and KSES then parses that survivor as a real <area> element.
<area> is on the KSES allowlist, along with the attributes id, class, href and name. That is a very short list. It is also, as it turns out, exactly enough.
This class of bug — a sanitiser and a parser disagreeing about the same bytes — is well documented as parsing differentials and is the same family as mutation XSS. The defensive lesson generalises well beyond WordPress: if two layers in your stack parse HTML, they have to be the same parser. Anywhere they are not, the allowlist is advisory.
Step 2 — Clobbering the DOM with an allowlist
No <script> is possible here — KSES would never allow it. So no script is used.
WordPress enqueues wp-admin/js/user-profile.js on the login page. That script exists for the password-reset screen, and it is not supposed to do anything on a login form. Two of its behaviours are reachable anyway:
- around line 620, on document ready, it runs
$('.reset-pass-submit').find('.wp-generate-pw').trigger('click') - around line 562, it binds a delegated click handler under
#color-pickerfor elements matching.color-option
Both are pure class and id lookups. The injected markup supplies all of them, plus one more element whose id shadows a global the script uses:
< area id=ajaxurl href=/test>
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X
The first line is DOM clobbering: an element with an id becomes a property of window, so window.ajaxurl — which the script reads to decide where to send its request — is now an HTMLAreaElement that the attacker controls. The remaining two lines make the script fire itself on page load, with no interaction at all.
The attacker has not run a single line of their own JavaScript. They have supplied nouns, and let WordPress’s own script supply the verbs.
Step 3 — Making the REST API hand back executable script
jQuery’s $.post() stringifies whatever it is given as a URL. Stringifying an HTMLAreaElement yields its href. So the clobbered global becomes the request target, and the attacker chooses it:
< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert&_envelope=1>
Three documented REST parameters are doing the work:
_jsonpwraps the response in a callback and serves it asContent-Type: application/javascript. jQuery sees that content type and runs the body throughjQuery.globalEval()._method=GEToverrides the verb, so a POST reaches a GET-only route._envelope=1wraps the real status inside a 200 response body, so an authentication failure does not stop the flow.
The callback name is validated against ^[a-zA-Z0-9_.]+$. The attacker now has arbitrary function invocation in the site’s origin, delivered by the site’s own REST API.
Notably, the original research reports that this survived a nonce-based Content Security Policy with strict-dynamic. That is consistent with the known behaviour of DOM clobbering as a CSP bypass primitive: the script that runs is one the page already trusted, and CSP has no opinion about which global it read a URL from.
Step 4 — The dot in the regex
^[a-zA-Z0-9_.]+$ permits dots. A dot means property traversal, and property traversal from a child window reaches its opener:
_jsonp=window.opener.approve.click
This is Same Origin Method Execution, described by Ben Hayak at Black Hat EU in 2014. The attacker cannot read the opener’s page — same-origin policy still applies across origins — but they do not need to. They only need to call one method on it, and the opener is the administrator’s authenticated session.
The mitigation for SOME has been the same for eleven years and is worth restating because this bug is a direct instance of it: a JSONP callback name must be an allowlist, not a character class.
Step 5 — Minting an application password
Now the pieces assemble into something the administrator does themselves.
The attacker’s page opens a blank child window, navigates the main window to the genuine /wp-admin/authorize-application.php — a real WordPress screen, on the real domain, with a real TLS certificate — and has the child fire the chain above with the callback aimed at the opener’s approve button.
One click from the administrator drives auth-app.js to completion. WordPress mints an application password and returns it in the redirect, in the query string:
/?site_url=…&user_login=admin&password=XXXXXXXXXXXX
The attacker now holds a durable administrator credential that survives a password change and is not covered by the site’s multi-factor authentication.
Steps 6 and 7 — From credential to PHP
The rest is ordinary authenticated abuse, which is exactly why it is so fast.
With HTTP Basic authentication and the stolen application password, the attacker publishes a page containing a <script> tag. On a single-site install, administrators hold the unfiltered_html capability, so nothing strips it:
POST /wp-json/wp/v2/pages
Authorization: Basic <base64 of admin:app-password>
{"title":"x","status":"publish","content":"<script>…</script>"}
That persisted script then scrapes the plugin-upload nonce and posts a ZIP to /wp-admin/update.php?action=upload-plugin.
Here is the part worth internalising even if you never run WordPress again: a plugin does not have to be activated to execute. The upload extracts files into /wp-content/plugins/, and that directory is served by the web server. Requesting the uploaded PHP file directly runs it. There is no activation step to audit, no admin action to review, no plugin list entry that looks wrong.
The published proof of concept is a few lines that return a header and shell out — enough to prove execution, and it is at that point a normal post-exploitation problem on your infrastructure.
Detection
If you are asking whether you were hit before you patched, these are the artefacts. They are cheap to grep for and they survive in ordinary access logs.
On the login endpoint — the parser differential leaves a very distinctive shape, because the space after the angle bracket is the whole trick:
POST /wp-login.php with log= containing "< area", "< div", "< button"
Any left angle bracket followed by a space in a submitted username is worth a look regardless of what follows it.
On the REST API — the global parameters are rarely used by legitimate traffic on a modern site:
_jsonp= any REST request carrying it at all
_jsonp=a.b.c a value containing a dot — this is the SOME tell, not a callback
_envelope=1 combined with _method=GET on a route that should have refused
On the credential flow — this is the highest-confidence signal, because it is the point where the attack stops being reconnaissance:
- an application password created within seconds of a request to
authorize-application.php - a redirect or referrer containing
site_url=,user_login=andpassword=together - any application password whose name you do not recognise, on any administrator account
On the filesystem and the upload path — the end of the chain:
POST /wp-admin/update.php?action=upload-pluginfrom an address that has never administered the site- new PHP files under
/wp-content/plugins/belonging to a plugin that was never activated - direct
GETrequests to a.phpfile inside a plugin directory, rather than toindex.php
If you find the last two, treat it as a confirmed compromise and work the incident: the administrator’s application password is still valid until it is explicitly revoked, and revoking the account’s login password does not revoke it.
Remediation
Patch. Move to the patched release on your branch from the table above. If you run managed hosting, confirm the point release rather than the branch — “we’re on 6.8” is not an answer to this question.
Then revoke. Patching closes the door; it does not evict anyone already inside. Audit and rotate application passwords for every administrator account, whether or not you found evidence. This step is skipped constantly and it is the one that decides whether the patch actually helped.
Hardening beyond the patch
These are IONSEC recommendations, not part of the vendor fix. Each one breaks a different link in the chain, which is the point — the reason this bug scored 8.9 is that no single link was load-bearing on its own.
Disable application passwords where they are not used. Most sites have never issued one. They are the pivot from browser-side script execution to a durable server-side credential, and turning them off removes the whole second half of the chain:
add_filter( 'wp_is_application_passwords_available', '__return_false' );
Turn off JSONP on the REST API. It exists for cross-origin consumers that predate CORS. If you do not have one, it is pure attack surface:
add_filter( 'rest_jsonp_enabled', '__return_false' );
Stop file modification from the dashboard. This blocks plugin and theme installation and editing outright, and it is the correct posture for any site deployed from version control:
define( 'DISALLOW_FILE_MODS', true );
Deny PHP execution where PHP should never run. /wp-content/uploads/ is the classic case, but the plugin directory deserves the same scrutiny — as step 7 shows, reachable-and-executable is the property that matters, not installed-and-activated.
Block the upload endpoint at the edge. /wp-admin/update.php?action=upload-plugin has exactly one legitimate source: your own administrators. Restrict it by source address at the CDN or WAF.
Watch for scripts on pages that should not have them. The persistence step depends on unfiltered_html. Alerting on a published page whose content contains a <script> tag catches step 6 before step 7 happens.
What this actually says about triage
The lasting lesson here is not about WordPress. It is that severity is a property of a chain, not of a bug.
Every individual component in this chain was known, documented and, in isolation, boring. A parser differential in a sanitiser. A password-reset script loaded on the wrong page. A JSONP callback validated by regex instead of an allowlist. An application password returned in a query string. A plugin directory that executes uploaded files. Each of those, filed on its own, is a low or a medium and reads like housekeeping.
Assembled, they are 8.9 and a shell.
When a reflected XSS comes back from a test, the useful question is not “how bad is this XSS”. It is: what is already loaded on this page, and what will it do if I hand it the right nouns?
References
- pwn.ai — XSS2Shell: WordPress Preauth XSS to RCE Chain (CVE-2026-64638) — https://pwn.ai/blog/xss2shell
- WordPress — GHSA-52p2-r8wf-jcrf, the official advisory with the full affected and patched version lists — https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-52p2-r8wf-jcrf
- WordPress News — WordPress 7.0.3 release, 6 August 2026 — https://wordpress.org/news/2026/08/wordpress-7-0-3-release/
- Patchstack — WordPress 7.0.3 Released: 12 Vulnerabilities Found and Fixed — https://patchstack.com/articles/wordpress-7-0-3-released-12-vulnerabilities-found-and-fixed/
- The Hacker News — New WordPress Pre-Auth XSS Could Lead to PHP Code Execution — https://thehackernews.com/2026/08/new-wordpress-pre-auth-xss-could-lead.html
- Search Engine Journal — WordPress Security Release 7.0.3 Fixes High Severity XSS Vulnerability — https://www.searchenginejournal.com/wordpress-security-release-7-0-3-fixes-high-severity-xss-vulnerability/584927/
- Ben Hayak — Same Origin Method Execution, Black Hat EU 2014, the primitive used in step 4 — https://blackhat.com/docs/eu-14/materials/eu-14-Hayak-Same-Origin-Method-Execution-Exploiting-A-Callback-For-Same-Origin-Policy-Bypass-wp.pdf
- Invicti — Same Origin Method Execution, including JSONP callback allowlisting as the fix — https://www.invicti.com/web-application-vulnerabilities/same-origin-method-execution-some
- PortSwigger Research — DOM Clobbering Strikes Back, the primitive used in step 2 — https://portswigger.net/research/dom-clobbering-strikes-back
- PortSwigger Research — Bypassing CSP via DOM Clobbering — https://portswigger.net/research/bypassing-csp-via-dom-clobbering
- TU Braunschweig — Bypassing HTML Sanitizers via Parsing Differentials, the bug class behind step 1 — https://www.ias.cs.tu-bs.de/publications/parsing_differentials.pdf
- Sonar — mXSS: The Vulnerability Hiding in Your Code — https://www.sonarsource.com/blog/mxss-the-vulnerability-hiding-in-your-code/
- WordPress Developer Resources — REST API global parameters (
_jsonp,_envelope,_method) — https://developer.wordpress.org/rest-api/using-the-rest-api/global-parameters/ - WordPress Developer Resources — Application Passwords integration guide — https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/
- Rapid7 — CVE-2026-63030: wp2shell, the separate WordPress Core RCE chain patched three weeks earlier — https://www.rapid7.com/blog/post/etr-cve-2026-63030-wp2shell-a-critical-remote-code-execution-vulnerability-in-wordpress-core/
Frequently asked questions
Is CVE-2026-64638 exploitable without any WordPress account?
The cross-site scripting half is fully unauthenticated — a crafted username in a single failed login attempt is enough to run attacker JavaScript in the site's origin. The escalation to PHP code execution is not unauthenticated: it additionally requires a user already logged in as an administrator to click once on a page the attacker controls.
Which WordPress versions are affected by CVE-2026-64638?
Every branch from WordPress 4.7 through 7.0.2 is affected. WordPress shipped fixes on 6 August 2026 in 7.0.3, 6.9.6, 6.8.7, 6.7.6, 6.6.6, 6.5.9 and eighteen further maintenance releases down to 4.7.34. Running an old major branch is not by itself a finding — running an unpatched point release on that branch is.
Is XSS2Shell the same thing as wp2shell?
No. wp2shell is a separate WordPress Core chain, CVE-2026-63030 combined with CVE-2026-60137, patched on 17 July 2026 in 7.0.2 and 6.9.5. XSS2Shell is CVE-2026-64638, patched three weeks later in 7.0.3. A site patched for one is not automatically patched for the other.
What should we look for in logs to detect exploitation attempts?
Search access logs for POSTs to wp-login.php whose username field contains a left angle bracket followed by a space, and for REST API requests carrying a _jsonp parameter — particularly values containing a dot, which indicate same-origin method execution rather than a simple callback. Then check whether any application password was created shortly after those requests.