DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-64638 (XSS2Shell): From a WordPress Login-Page XSS to Remote Code Execution

At a Glance

Item Detail
CVE ID CVE-2026-64638
Nickname XSS2Shell
Vulnerability class CWE-79 (Reflected XSS) → privilege-escalation chain
CVSS 3.1 7.5 (High) — AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
Affected WordPress Core, every release since 4.7
Fixed in 7.0.3 (2026-08-06), backported to branches down to 4.7 (e.g. 6.9.6, 6.8.7)
Discovered by pwn.ai
Disclosed 2026-08-07

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 output
Enter fullscreen mode Exit fullscreen mode

The 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 intact
Enter fullscreen mode Exit fullscreen mode

Slip 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 through
  • wp_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 endpoint
Enter fullscreen mode Exit fullscreen mode

At 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=1
Enter fullscreen mode Exit fullscreen mode

The 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... })
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fetch a CSRF nonce from the plugin-upload screen via the REST API.
  2. Craft a ZIP file containing a PHP webshell and submit it via POST /wp-admin/update.php?action=upload-plugin.
  3. 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
);
Enter fullscreen mode Exit fullscreen mode

esc_html() turns < and > into &lt; and &gt;, 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.


Detection

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[^>]*>'
Enter fullscreen mode Exit fullscreen mode

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.php whose log field 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.php with a return URL outside your own domain, immediately followed by POST /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.php by 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_MODS in wp-config.php to 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.

Top comments (0)