CSP stops XSS2Shell: inside the WordPress pre-auth XSS to RCE chain (CVE-2026-64638)

No injected <script> tag. Nothing loaded from an attacker-controlled origin. A PHP shell at the end of it. A CSP kills this chain on the login page, and can tell you when someone tries. That's exactly what we're for.

WordPress shipped an emergency security release on 6 August 2026 for a pre-auth reflected XSS on the login screen that chains all the way to PHP code execution. It's been named XSS2Shell, it's tracked as CVE-2026-64638, it scores 8.9 on CVSS 4.0, and it was found by the team at pwn.ai.

Patch to WordPress 7.0.3. WordPress is backporting the fix, where necessary, to all branches still eligible to receive security fixes (currently as far back as 4.7) but those backports were described as in progress and shipping "as they become ready" when the release went out. If you're sitting on an older branch, verify your version actually carries the fix rather than assuming it does. Sites with automatic background updates were picked up shortly after release; locked-down and managed installs need a manual bump. Go and do that first, then come back.

What I want to know, of course, is: could a Content Security Policy have stopped this? It turns out, the answer is yes, it could have!

One scoping note before we start: the walkthrough below is against WordPress 7.0.2. Older supported branches have different surrounding login-page rendering code, so the specific call path differs even where the underlying weakness doesn't.

The bug: two parsers that disagree

The root cause isn't a missing sanitiser. It's two sanitisers with different ideas about what an HTML tag is.

When you submit a username that doesn't exist, WordPress builds an error message containing it. The username first passes through sanitize_user(), which calls wp_strip_all_tags(), which ultimately relies on PHP's strip_tags(). And PHP only treats < as the start of a tag when it's immediately followed by a letter, so a single space defeats it:

strip_tags( '<area id=test>' );   // ''
strip_tags( '< area id=test>' );  // '< area id=test>'

sanitize_user() does more than strip tags, and the payload is shaped to survive the rest of it: it drops percent-encoded octets, kills anything matching &.+?;, and consolidates runs of whitespace into one. That's why the payload carries no semicolons and exactly one space after each bracket. It comes out the other side untouched.

Then it hits a second parser. The failed-login error object travels from wp_signon() back up to wp-login.php, into login_header(), and on into wp_admin_notice(), a function introduced in WordPress 6.4.0 that runs its message through wp_kses_post(). KSES has its own tokeniser, and that one is perfectly happy to read < area as an <area> element. <area> is on the post allowlist, and id, class and href are all permitted attributes.

Neither parser strips it. Both think the other one handled it. The username lands in the DOM as live markup.

The gadget chain

Here's the part I find pretty tricky. The payload contains no <script> tag and no event handler. It's three inert HTML elements:

log=< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert>
    < div id=color-picker class=reset-pass-submit>
    < button class="wp-generate-pw color-option">X

Everything after that is WordPress's own JavaScript being turned against itself.

  1. Clobber. <area id=ajaxurl> becomes window.ajaxurl via HTML named-property access, replacing the variable that should hold admin-ajax.php.
  2. Auto-fire. user-profile.js is enqueued on the login page for the password-reset flow, and it runs $('.reset-pass-submit').find('.wp-generate-pw').trigger('click') — clicking the attacker's button for them. The click bubbles into a delegated #color-picker handler guarded by user_id === new_user_id. On the login page both are undefined, undefined === undefined is true, and the guard opens.
  3. Stringify. That handler calls $.post( ajaxurl, ... ). jQuery stringifies the clobbered element, HTMLHyperlinkElementUtils.toString() returns the attacker's href, and the request goes to /?rest_route=/&_method=GET&_jsonp=alert.
  4. Eval. The REST index needs no auth, _method=GET sidesteps the POST path, and _jsonp survives WordPress's ^[a-zA-Z0-9_.]+$ callback check. The response returns as Content-Type: application/javascript, jQuery sniffs the dataType as script, and its converter runs jQuery.globalEval() on the body.

Script execution in the site's origin, no account, no user interaction.

Escalation to RCE needs a logged-in single-site administrator to visit an attacker page and click once. From there it's window juggling: navigate the opener to authorize-application.php, re-run the XSS in a child window with the JSONP callback set to window.opener.approve.click (dots pass the regex), and the admin's own session approves an Application Password which is handed straight to the attacker via success_url. That password authenticates to the REST API, publishes a page containing <script> (single-site admins have unfiltered_html), and that script scrapes the plugin-upload nonce and posts a ZIP. WordPress extracts it to wp-content/plugins/, where PHP files are reachable by URL without the plugin ever being activated.

So, does CSP stop it?

Step 4 is the potential location to interrupt this. Everything before it is inert HTML that no CSP directive governs, there's no directive for DOM clobbering, and the $.post() is same-origin, so a typical connect-src 'self' waves it through. You could drop 'self' and stop it there, but that isn't a practical policy for a WordPress install.

But step 4 is an inline script, so I went and checked the jQuery source. WordPress 7.0.x registers jQuery 3.7.1, and in jQuery's source at src/ajax/script.js:

converters: {
	"text script": function( text ) {
		jQuery.globalEval( text );
		return text;
	}
}

One argument. No options object. So globalEval hands { nonce: undefined } to DOMEval, which does script.text = code and appends the element, and sets no nonce attribute on it.

That means the exploit's script is a nonce-less, inline script, and it is blocked by:

  • script-src 'self' - inline needs an explicit allowance and there isn't one
  • script-src 'nonce-...' - the generated element carries no nonce
  • require-trusted-types-for 'script' - HTMLScriptElement.text is a Trusted Types sink, so that assignment throws, unless a default policy accepts and converts the string

Block step 4 and the whole chain dies pre-auth, before an administrator is ever involved.

The bit that should bother you

Add 'strict-dynamic' without Trusted Types, and the exploit runs again...

jQuery itself is trusted by the nonce. It then creates a non-parser-inserted <script> with document.createElement(), and 'strict-dynamic' allows that dynamically created script. The new element does not inherit the nonce — CSP's trust-propagation semantics admit it regardless.

The surprising part isn't that old CSP beats new CSP. It's that adding one keyword flips this exact gadget from blocked to allowed. That's the script gadget problem in its purest form, and this bug is about the cleanest demonstration of it I've seen in the wild: the attacker never injects a <script> tag, they rearrange the furniture until the framework runs their code for them.

The spec is ahead of us here. CSP Level 3 documents nonce or hash plus 'strict-dynamic' as Strict CSP, and then, in the same section, adds:

Note: While 'strict-dynamic' allows ease of deployment (as described in § 8.2 Usage of 'strict-dynamic'), it should be avoided when possible.

XSS2Shell is what that sentence looks like in practice.

Trusted Types is what closes it back up, because it guards the sink rather than the source. Realistically, no WordPress install is running require-trusted-types-for 'script' today, but this is the argument for why you'll want to.

One more place CSP wins: publishing that <script> into a page in the escalation phase is also blocked under a nonce policy, since stored post content has no nonce. That severs the bridge to the plugin upload even if phase one somehow succeeded.

And to be straight about where it doesn't help, the Application Password leaves via a top-level navigation to success_url, which form-action doesn't cover and navigate-to never shipped to cover. Once the attacker holds that credential, the requests come from their server, not your visitors' browsers, and CSP has no reach there.

Report-only would have told you

Even if you're not in a position to enforce anything, this is a good argument for a policy in report-only mode on your login page. A representative report should look something like this:

  • effective-directive: script-src-elem
  • blocked-uri: inline
  • document-uri: /wp-login.php
  • source-file: jquery.min.js

An inline-script violation on an unauthenticated login page, sourced from jQuery, is an unusually high-signal indicator of attempted exploitation. Note the distinction: if your policy blocked the script, you have evidence somebody tried, not evidence you were breached. If you're collecting reports with us, that's a filter worth setting up.

Be clear-eyed about the limits, though. This chain never loads an external script, so script monitoring that keys on newly-loaded resources won't see step 4, it's inline evaluation from start to finish. The reporting layer is what catches this one.

What to do

  1. Update to WordPress 7.0.3, or the patched release on your branch.
  2. If you can't patch immediately, reject POSTs to /wp-login.php where the log parameter contains < or %3C at your edge. Valid usernames never contain a bracket.
  3. If you run a nonce-based CSP with 'strict-dynamic', understand that it did not protect you here, and look at Trusted Types.
  4. Put a report-only policy on your login page if you haven't got one.

Patching is the fix. CSP was defence-in-depth, but the kind of defence-in-depth that decides whether an unauthenticated stranger gets a shell on your box or a violation report in your dashboard.

Would you know if someone tried this against your site?

Report URI gives you actionable security visibility, helping you identify high-signal events such as unexpected inline script execution, even when no external malicious resource is loaded.

Start with a Content Security Policy in Report-Only mode, send your reports to Report URI, and see what is happening in your visitors’ browsers without risking disruption to your site.

Create your Report URI account and start collecting reports today.

Read more