Back to blog

XSS2Shell: Pre-auth XSS in WordPress Login Page (CVE-2026-64638)

Marius Horatau
Written by
Marius Horatau
Published on

In WordPress 6.4 through 7.0.2, malformed input such as < b> can pass through the username sanitizer as text and later be normalized into a working <b> element. This parser differential is reachable through unauthenticated login errors and can be chained into reflected XSS.

XSS2Shell: Pre-auth XSS in WordPress Login Page (CVE-2026-64638)

WordPress shipped 7.0.3 on 6 August 2026 with twelve security fixes in it. The first one on the list is a “pre-auth reflected XSS on login screen with PHP code execution potential”, and the next day pwn.ai published the research behind it: XSS2Shell, CVE-2026-64638.

The core idea behind this issue can be illustrated with these two payloads:

<b>test</b>     →  test              tags stripped
< b>test</ b>   →  <b>test</b>       rendered

Type < b>test</ b> into the username box on an affected login page and WordPress normalizes it into a valid <b> element. This parser differential creates an HTML injection issue. Reaching XSS requires several additional WordPress behaviors, so I reproduced the complete path in a lab and verified each stage of the chain.

What is XSS2Shell?

It is a reflected cross-site scripting vulnerability in WordPress core, on wp-login.php. An attacker needs no account or session to submit the payload, and the vulnerable path exists on a default installation. Exploiting another user still requires their browser to open the crafted login request. The RCE extension has additional prerequisites covered below.

This is what the payload that triggers the XSS looks like:

< 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

And this is the full request:

POST /wp-login.php HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Content-Length: 225

log=%3C%20area%20id%3Dajaxurl%20href%3D%2F%3Frest_route%3D%2F%26_method%3DGET%26_jsonp%3Dalert%3E%3C%20div%20id%3Dcolor-picker%20class%3Dreset-pass-submit%3E%3C%20button%20class%3D%22wp-generate-pw%20color-option%22%3EX&pwd=x

The root cause of CVE-2026-64638 is a parser differential combined with a missing output escape. Two sanitizers process the same string using different HTML grammars.

When you submit a username that does not exist, WordPress tells you so and quotes your value back:

Error: The username notauser is not registered on this site.

That message is built in wp-includes/user.php with sprintf, and the username goes straight into an HTML string:

return new WP_Error(
    'invalid_username',
    sprintf(
        __( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site. …' ),
        $username
    )
);

On the way in, the username goes through sanitize_user(), which calls wp_strip_all_tags(), which is PHP’s strip_tags(). On the way out, the assembled sentence goes through wp_kses_post(), WordPress’s own HTML sanitizer.

strip_tags() considers a string to be a tag only when < is immediately followed by a letter. KSES tolerates whitespace between the two. The value < b> is therefore text to the first mechanism and a <b> element to the second. KSES processes the completed notice immediately before it is rendered.

Here are some examples of output of each individual mechanism:

sanitize_user( '< area id=test>' );   // '< area id=test>'   nothing to strip
wp_kses_post( '< area id=test>' );    // '<area id="test">'  repaired into an element

sanitize_user( '<area id=test>' );    // ''                  stripped

KSES intentionally repairs some malformed allowed markup. In this case, it removes the whitespace and emits a clean, quoted element. The input &lt; b&gt; is not valid HTML when strip_tags() processes it, but it becomes valid HTML when KSES parses the completed notice. This is a parser differential issue rather than a traditional filter bypass.

This is the line, in wp-includes/kses.php:

// It's seriously malformed.
if ( ! preg_match( '%^<\s*(/\s*)?([a-zA-Z0-9-]+)([^>]*)>?$%', $content, $matches ) ) {
    return '';
}

The \s* after the angle bracket permits optional whitespace before the tag name. The comment before the if statement gives us a clue about what the author was thinking: this branch handles malformed input. So the purpose of this part was to repairing user content if the content is malformed and reached that point.

Which WordPress versions are affected by CVE-2026-64638

pwn.ai’s write-up says the flaw “has been present since the earliest versions of WordPress”. The advisories cover many branches, and security fixes were backported to 4.7. Comparing the 6.3.7 and 6.4 release tarballs shows that the KSES tokenizer is identical in both versions. The relevant change is not the \s*; it is how the login page renders errors.

// 6.3.7, wp-login.php
echo '<div id="login_error">' . apply_filters( 'login_errors', $errors ) . "</div>\n";

// 6.4, wp-login.php
wp_admin_notice(  );   // which does: echo wp_kses_post( … )

In 6.3, the error is echoed directly. Malformed input that survives strip_tags() reaches the browser as text because strip_tags() and the browser both treat < b> as text. KSES does not parse the completed message on this path.

WordPress 6.4 moved login errors onto the admin-notice helper. This consistency and accessibility refactor placed KSES after the tag-stripping stage and introduced the exploitable parser sequence.

WordPress versionLogin error pathStatus
6.3 and earlierError rendered without KSESNot exploitable through this path
6.4 – 7.0.2Completed error parsed by KSESExploitable
7.0.3 and branch security releasesUsername escaped before interpolationFixed

If you are triaging by version number alone you will flag installs that were never reachable. If you are on 6.4 or later and unpatched, patch.

From HTML injection to XSS

Rendering HTML markup is not the same as executing script. KSES continues to remove disallowed elements and event-handler attributes, so a direct XSS payload does not survive. The original research combines six existing WordPress behaviors to turn allowlisted markup into script execution. It is a really creative chain, similar in structure to the WP2Shell chain that we analysed two weeks earlier.

About the “2shell” part

The original write-up keeps going after triggering the XSS into showing how to turn it into a RCE.

I think the “2shell” part is a bit of a stretch though. The escalation needs a logged-in single-site administrator to open your link, Application Passwords enabled, and plugin uploads allowed. I agree that’s a realistic set of conditions on a lot of WordPress sites and this can be abused at scale, but it’s a phishing-shaped precondition rather than “send one request, get a shell” as we’ve seen in WP2Shell.

It’s a critical and a cool bug either way. The attacker needs no account, the vulnerable code is in WordPress core, and script execution inherits the victim’s access to the site. The final impact depends on the victim’s session and the site’s configuration.

How to fix CVE-2026-64638

Patch. Update to 7.0.3 or the security release for your branch. No configuration change fixes the reflected XSS.

The fix itself is one function call:

__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site. …' ),
esc_html( $username )

The fix does not change either sanitizer. strip_tags() still ignores < area, and KSES still repairs it. WordPress now escapes the username at the point where it enters HTML, so the two parsers no longer need to classify the value identically.

If you cannot patch immediately, you can reduce the RCE escalation path without fixing the XSS: disable Application Passwords, set DISALLOW_FILE_MODS, or block PHP execution from inactive plugin directories. Treat these controls as defense in depth, not as substitutes for the security update.

Reproduce it yourself

A WordPress login page showing an error banner containing an injected button, a password field auto-filled by WordPress's own script, and attacker-controlled output appended to the page
CVE-2026-64638 in action

One of the core techniques that made this vulnerability exploitable is DOM clobbering. Learning about DOM Clobbering is not the same as watching a variable you invented turn into an HTMLAreaElement. So I created a lab that runs a real WordPress 7.0.1 app in your browser, no setup, and you build the payload one property at a time.

It’s 19 steps, about 40 minutes. You’ll go through every important step and understand what happens behind the scenes. You find the reflection, work out the limitations of both sanitizers, and use the WP internals in a way that no one intended to trigger the JS code execution. It’s a cool bug and I had a lot of fun build the lab and learning about it.

Try the XSS2Shell lab

Next up

WP2Shell: Pre-Auth WordPress RCE (CVE-2026-63030), Reproduced

WP2Shell is an unauthenticated remote code execution chain in WordPress core, combining CVE-2026-63030 and CVE-2026-60137. What it is, how to fix and detect it, and how to reproduce the full pre-auth RCE in a hands-on lab.

© 2026 Uphack.io ✦ Theme inspired by Aria

RSS Theme