September 26, 2026
CVE-2026โ64638 (XSS2Shell): From a WordPress Login-Page XSS to Remote Code Execution
At a Glance
By Guidancewhite
7 min read
At a Glance
What makes XSS2Shell worth a deep dive isn't exotic technique โ it's the entry point. wp-login.php is reachable by anyone, no account required, and virtually every WordPress site leaves it exposed to the Internet by design. A single XSS there, chained through several features that were already sitting in Core, climbs all the way to PHP code execution on the server.
The chain splits cleanly into two phases, though, and they don't carry the same weight. Phase 1 (the reflected XSS) is unauthenticated and unconditional โ it fires against any default install. Phase 2 (escalation to a webshell) is conditional: it requires a logged-in administrator to open an attacker-controlled page. The CVSS vector's AC:H (high attack complexity) and UI:R (user interaction required) capture exactly that conditionality.
The Full Attack Chain
The first four steps need no special conditions and are reproducible by anyone. The last three assume one click from a logged-in admin. Let's walk through each step at the source-code level.
Step 1 ยท Parser Confusion: Two Sanitizers, Two Different Verdicts
When a login attempt fails, WordPress echoes the submitted username back in the error message (e.g. "Unknown username johndoe"). Before that value reaches the page, it travels through this path:
wp_signon()
โโ wp_authenticate()
โโ sanitize_user() // sanitization pass #1
โโ wp_strip_all_tags()
โโ strip_tags() // native PHP function
โโ wp_login() fails โ login_header()
โโ wp_admin_notice()
โโ wp_kses_post() // sanitization pass #2, right before outputwp_signon()
โโ wp_authenticate()
โโ sanitize_user() // sanitization pass #1
โโ wp_strip_all_tags()
โโ strip_tags() // native PHP function
โโ wp_login() fails โ login_header()
โโ wp_admin_notice()
โโ wp_kses_post() // sanitization pass #2, right before outputThe same string is sanitized twice โ once by strip_tags(), once by wp_kses_post() โ and the bug is that these two functions disagree on what counts as a tag.
strip_tags()'s blind spot: the space between < and the tag name
PHP's strip_tags() only recognizes something as a tag when the tag name sits flush against the opening <.
strip_tags('<area id=x>'); // "" โ recognized as a tag, stripped entirely
strip_tags('< area id=x>'); // "< area id=x>" โ the space defeats recognition, survives intacstrip_tags('<area id=x>'); // "" โ recognized as a tag, stripped entirely
strip_tags('< area id=x>'); // "< area id=x>" โ the space defeats recognition, survives intacSlip a single space right after the <, and strip_tags() decides this is plain text, not markup, and leaves it untouched. At this point the string isn't dangerous yet โ it has simply survived the first filter.
wp_kses_post()'s blind spot: its tokenizer ignores that same whitespace
Here's where it goes wrong. Right before this value is rendered, WordPress runs it through wp_kses_post(), its flagship HTML sanitizer. Unlike strip_tags(), wp_kses_post()'s own HTML tokenizer disregards whitespace before the tag name โ so it parses < area ...> as a perfectly ordinary <area> tag.
<area> sits on wp_kses_post()'s allowlist along with the id, href, class, and name attributes. So the value is never escaped โ it's rendered as live HTML.
To summarize the disagreement:
strip_tags(): "not a tag, leave it alone" โ passes it throughwp_kses_post(): "this is an allowlisted tag" โ renders it as real HTML
The exact same string is judged harmless by the first filter and judged valid HTML by the second. In that gap, an attacker plants an <area> element of their choosing on the login page โ no account required.
<script> tags and event attributes like onclick are still blocked by wp_kses_post(), so this step alone doesn't yet run JavaScript. That comes next.
Step 2 ยท DOM Clobbering: Hijacking a JS Variable With Nothing But HTML
The login page also loads user-profile.js, a script meant for the password-reset flow. On load, it looks for the password-generation button and, in doing so, references the global ajaxurl variable as a string.
DOM clobbering is a well-known technique for polluting JavaScript globals using only HTML. Browsers automatically expose elements with an id or name attribute as properties on window, provided no real variable with that name is already declared. So an injected <area id="ajaxurl" href="attacker-url"> alone is enough to make window.ajaxurl reference this DOM element instead of a string.
The moment user-profile.js tries to use ajaxurl as a string (say, concatenating it into a URL), the JS engine automatically calls .toString() on it. An <area> element's toString() is specified to return exactly its href attribute. The destination the script was about to hit is now whatever URL the attacker wrote.
Injected tag:
<area id="ajaxurl" href="/?rest_route=/&_method=GET&_jsonp=<callback>&_envelope=1">
What user-profile.js does (conceptually):
var url = ajaxurl + "?action=..."; // implicitly calls ajaxurl.toString()
// โ resolves to the attacker's href value instead of the real endpointInjected tag:
<area id="ajaxurl" href="/?rest_route=/&_method=GET&_jsonp=<callback>&_envelope=1">
What user-profile.js does (conceptually):
var url = ajaxurl + "?action=..."; // implicitly calls ajaxurl.toString()
// โ resolves to the attacker's href value instead of the real endpointAt this point the attacker can redirect one outgoing request to a URL of their choosing โ not yet code execution. That happens in the REST API.
Step 3 ยท REST API Reflection: One Dot in _jsonp Changes Everything
The attacker now points this "redirect one request" primitive at WordPress's REST API JSONP support:
GET /?rest_route=/&_method=GET&_jsonp=<callback-name>&_envelope=1GET /?rest_route=/&_method=GET&_jsonp=<callback-name>&_envelope=1The REST API validates the _jsonp value against ^[a-zA-Z0-9_.]+$. Notice the character that stands out: the dot (.). What looks like a standard callback-name filter (letters, digits, underscore) also allows dots โ which means <callback-name> can be a dotted object path like window.opener.approve.click.
The server responds with Content-Type: application/javascript:
/**/window.opener.approve.click({ ...REST API response data... })/**/window.opener.approve.click({ ...REST API response data... })The WordPress admin screens run jQuery, and jQuery passes responses like this through globalEval() without question. The result: an arbitrary object.method() call, chosen by the attacker, executes inside the site's own origin. This isn't a toy alert() popup โ window.opener refers to the window the admin had already open, so the attacker can programmatically click a specific button inside it.
Steps 1 through 3 are the unauthenticated, unconditional reflected XSS. They reproduce on any exposed login page regardless of who the visitor is.
Step 4 (Conditional) ยท SOME (Same-Origin Method Execution) Steals an Application Password
From here on, the chain requires an already-logged-in administrator to open an attacker-controlled page.
WordPress ships an "Application Password" feature so external tools can authenticate to the REST API. The authorization screen (authorize-application.php) generates a new password when the user clicks "Approve," then redirects to a pre-specified return URL carrying the username and cleartext password.
The attacker gets the admin's browser to open this authorization screen, then, using the JSONP execution primitive from Step 3, programmatically fires the click event on the "Approve" button. The admin never clicked anything โ but WordPress treats it as a legitimate click, issues the application password, and hands it straight to the attacker's return URL.
This class of attack is called SOME (Same-Origin Method Execution). It doesn't introduce a new vulnerability of its own โ it just triggers an existing, legitimate feature (a button click) at a time and in a way the attacker chooses.
Step 5 ยท Admin-Level REST API Access
An application password lets anyone authenticate over HTTP Basic auth using username:app-password, with no need for the real login password or session cookie, and with the full privileges of that account on the REST API. The attacker is now sitting at admin-level access to the API without ever having seen the real credentials.
Step 6 ยท Webshell Drop via Plugin Upload
The last step abuses WordPress's ordinary plugin-installation feature:
- Fetch a CSRF nonce from the plugin-upload screen via the REST API.
- Craft a ZIP file containing a PHP webshell and submit it via
POST /wp-admin/update.php?action=upload-plugin. - WordPress extracts the ZIP directly into
wp-content/plugins/.
PHP files inside a plugin directory are directly web-reachable and executable without the plugin ever being activated. No activation step is needed โ hitting the uploaded PHP file directly completes the chain to remote code execution.
How It Was Patched
The fix is deliberately narrow. Rather than reconciling the disagreement between strip_tags() and wp_kses_post(), WordPress escapes the value right before it's ever rendered:
// wp-includes/user.php, where the failed-login message is built
// Before the patch (conceptual reconstruction)
$message = sprintf(
__( '<strong>Error</strong>: %s is not a registered username.' ),
$username // interpolated without escaping
);
// After the patch
$message = sprintf(
__( '<strong>Error</strong>: %s is not a registered username.' ),
esc_html( $username ) // HTML-entity encoded before interpolation
);// wp-includes/user.php, where the failed-login message is built
// Before the patch (conceptual reconstruction)
$message = sprintf(
__( '<strong>Error</strong>: %s is not a registered username.' ),
$username // interpolated without escaping
);
// After the patch
$message = sprintf(
__( '<strong>Error</strong>: %s is not a registered username.' ),
esc_html( $username ) // HTML-entity encoded before interpolation
);esc_html() turns < and > into < and >, so it no longer matters what wp_kses_post()'s allowlist contains downstream โ there's nothing left that can be parsed as a real tag. The underlying disagreement between the two sanitizers still exists elsewhere in the codebase; it simply no longer surfaces at this output point.
The fix landed in 7.0.3 and was backported across 24 maintenance branches back to 4.7. Because the exact backported version differs per branch (6.9.x got 6.9.6, 6.8.x got 6.8.7, and so on), don't rely on a blanket "below 7.0.3" version check โ verify against the latest maintenance release for your specific branch, since that comparison alone can produce false positives on older branches.
Detectio
Confirm the reflection directly (non-destructive)
No account creation, no write operations โ just check whether the payload reflects unescaped. The space between < and the tag name is the key marker.
curl -s -X POST https://YOUR-SITE/wp-login.php \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'log=%3C%20area%20id%3Dajaxurl%20href%3D%2F%3Frest_route%3D%2F%26_method%3DGET%26_jsonp%3Dalert%3E&pwd=x&wp-submit=Log+In' \
| grep -io '<area[^>]*id=["'"'"']*ajaxurl[^>]*>'curl -s -X POST https://YOUR-SITE/wp-login.php \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'log=%3C%20area%20id%3Dajaxurl%20href%3D%2F%3Frest_route%3D%2F%26_method%3DGET%26_jsonp%3Dalert%3E&pwd=x&wp-submit=Log+In' \
| grep -io '<area[^>]*id=["'"'"']*ajaxurl[^>]*>'If the response contains an unescaped <area id=ajaxurl ...>, the target is vulnerable. No output doesn't prove a patched state on its own โ a WAF or reverse proxy could be stripping the value upstream. Cross-check against the actual version.
Three log signatures worth watching
- A
POST /wp-login.phpwhoselogfield contains a URL-encoded < (%3C) followed by an encoded whitespace character (%20,%09,%0a,%0d). No legitimate username needs an angle bracket. - A REST API request with a
_jsonp=parameter whose callback value contains a dot (.) โ a sign of an object-path call rather than a plain callback name. - Access to
authorize-application.phpwith a return URL outside your own domain, immediately followed byPOST /wp-admin/update.php?action=upload-plugin.
Don't scope detection rules to the literal string < area. Any tag on wp_kses_post()'s allowlist works just as well, and a tab (%09) or newline (%0a, %0d) is just as effective as a space. The rule needs to catch "an encoded < immediately before what looks like a tag name," not one specific tag.
Mitigations (Pending a Patch)
Patching remains the only complete fix. If you can't update immediately, these reduce the blast radius:
- Restrict access to
wp-login.phpby IP or upstream authentication, cutting off the initial entry point. - Disable Application Passwords if you don't use them, which neutralizes Step 4 (credential theft).
- Define
DISALLOW_FILE_MODSinwp-config.phpto block plugin installation from the admin UI, breaking Step 6 (webshell drop). - Ensure PHP cannot execute directly from inactive plugin directories. Since this chain never activates the uploaded plugin, closing this gap stops execution even if a webshell is dropped.
All of these mitigations interrupt escalation after the fact โ none of them touch the root cause, the reflected XSS in Steps 1โ3. A WAF rule that only blocks the literal < area string is trivially bypassed and shouldn't be treated as more than a stopgap.