XSS2Shell: Technical Analysis
A pre-auth reflected XSS in WordPress Core, patched in 7.0.3. Two sanitizers disagree about what a tag is, and a username walks through the gap. This is the breakdown: gdb on the PHP tokenizer, the real affected range (which every public writeup got wrong), the JS gadget the patch did not touch, and a detector.
WordPress 7.0.3 shipped on August 6, 2026 with twelve security fixes. One of them is CVE-2026-64638, nicknamed XSS2Shell by the team at pwn.ai who found it. A failed login on any WordPress site between 5.8 and 7.0.2 lets an unauthenticated attacker put attacker chosen markup into the login page.
Two things up front, because most of the coverage blurred them.
The reflected XSS is one request. A single POST to wp-login.php. No auth, no user interaction, no setup.
The RCE is not. It is a chain that needs a logged in administrator to load the page in a real browser. curl will never give you code execution here, because curl does not run JavaScript. Anyone selling you a one liner for the RCE half is selling you the XSS half and rounding up.
Timeline
2021-06-08 r51126 username first reflected into the login error (WP 5.8)
2023-09-21 r56654 login errors move to wp_admin_notice(), gaining wp_kses_post() (WP 6.4)
2026-08-06 r63076 esc_html() added. WP 7.0.3 released, backports down to 5.8.14
2026-08-07 public disclosure, CVE-2026-64638 assigned, CVSS 8.9
Nineteen days from the previous release to this one. The fix itself is one line.
The Bug in 30 Seconds
You type a username that does not exist. WordPress says so, and puts your username in the message:
sprintf(
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site.' ),
$username
);
That string gets rendered through wp_kses_post(), which allows a list of safe HTML elements. Before that, the username went through sanitize_user(), which calls wp_strip_all_tags(), which calls PHP’s strip_tags().
So there are two sanitizers between your input and the page. The bug is that they do not agree on what a tag is.
Two Tokenizers, One Disagreement
PHP’s strip_tags() refuses to open a tag when the byte right after < is whitespace.
KSES has no such rule. It parses the tag anyway and normalises the whitespace away.
input < a id="x">
strip_tags() < a id="x"> still text, nothing stripped
wp_kses_post() <a id="x"> now it is a tag
Your string crosses the first boundary as text and arrives at the second one as markup.
Without the space, the same payload dies:
input <a id="x">
strip_tags() (empty) tag opened, tag removed
One space. That is the whole vulnerability.
Reading the Code
Before touching a debugger, read the thing. These are the questions worth asking, in the order you actually ask them.
Which function builds the string?
wp_authenticate_username_password(), in wp-includes/user.php. Lines 183 to 192 at 7.0.2:
183 if ( ! $user ) {
184 return new WP_Error(
185 'invalid_username',
186 sprintf(
187 /* translators: %s: User name. */
188 __( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site. ...' ),
189 $username
190 )
191 );
192 }
Line 188 is a format string that already contains HTML. Line 189 is the argument, unescaped. The whole defect is those two lines sitting next to each other, and line 189 is the one that becomes esc_html( $username ) in 7.0.3.
Who calls it, and with what?
It is a filter callback, registered in wp-includes/default-filters.php:503:
add_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
So the caller is whatever fires authenticate. On a login POST that is wp_signon(), at wp-includes/user.php:109.
What sanitizes the username on the way in?
This is the part I got wrong on my first pass, and it is the interesting bit.
wp_signon() reads $_POST['log'] and only unslashes it. wp_authenticate_username_password() receives a $username and prints it. Neither one calls wp_strip_all_tags(). For a while I was convinced the whole tokenizer story was wrong.
It is not. The strip lives in the pluggable shim between them, wp-includes/pluggable.php:689:
function wp_authenticate( $username, $password ) {
$username = sanitize_user( $username );
No second argument. $strict defaults to false.
What does non strict actually skip?
sanitize_user(), wp-includes/formatting.php:2149:
$username = wp_strip_all_tags( $username );
$username = remove_accents( $username );
$username = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', $username );
$username = preg_replace( '/&.+?;/', '', $username );
if ( $strict ) {
$username = preg_replace( '|[^a-z0-9 _.\-@]|i', '', $username );
}
The strict branch is the one that deletes <, >, = and ". It gets skipped. Every other call in Core that reaches storage or a rendered page passes true. This one does not.
What is the path from there to the screen?
POST /wp-login.php log=<payload>
wp-login.php:1322 wp_signon()
user.php:109 wp_authenticate( ... )
pluggable.php:689 sanitize_user( $username ) tokenizer 1
user.php:186 sprintf( '...<strong>%s</strong>...', $username )
wp-login.php:282 wp_admin_notice( $errors, [...] )
functions.php:9200 echo wp_kses_post( wp_get_admin_notice( ... ) ) tokenizer 2
Two things to notice on the way out. wp_get_admin_notice() at functions.php:9156 drops the message into its wrapper with no escaping at all:
$markup = sprintf( '<div %1$sclass="%2$s"%3$s>%4$s</div>', $id, $classes, $attributes, $message );
And nothing in Core hooks login_errors. grep -rn "add_filter( *'login_errors'" src/ returns nothing. So the wp_kses_post() at functions.php:9200 is the only control in the entire path.
Is that sink escaping?
No, and this is the root cause in one sentence. wp_kses_post() is an allowlist filter, not an escaper. Its job is to let safe HTML through. Handing it raw attacker input and expecting escaping is a category error. What 7.0.3 adds is the escaper that was never there.
So why does < a id=x> come back out as a tag?
Two regexes in wp-includes/kses.php.
The tokenizer, line 1209:
(<[^>]*(>|$)|>) # Tag-like spans of text.
Anything from < to > is a candidate. Nothing requires the next byte to be a letter.
Then the element extractor, line 1383:
if ( ! preg_match( '%^<\s*(/\s*)?([a-zA-Z0-9-]+)([^>]*)>?$%', $content, $matches ) ) {
return '';
}
Look at <\s* before the tag name. KSES explicitly permits whitespace there. PHP’s strip_tags() explicitly refuses it. That single \s* is the divergence, and everything else follows from it. The name gets captured into $matches[2], the whitespace is gone, and the element is re-emitted normalised.
Why does alert(1) not work?
Two more checks, and both of them hold.
Element allowlist, kses.php:1396:
// They are using a not allowed HTML element.
if ( ! isset( $allowed_html[ strtolower( $elem ) ] ) ) {
return '';
}
script is not a key in the post context allowlist, so the element is dropped. Its text content survives, which is exactly why < script>alert(1)</script> renders the literal string alert(1) and nothing happens.
Attribute allowlist, kses.php:1481:
foreach ( $attrarr as $arreach ) {
if ( wp_kses_attr_check( $arreach['name'], $arreach['value'], $arreach['whole'],
$arreach['vless'], $element, $allowed_html ) ) {
$attr2 .= ' ' . $arreach['whole'];
}
An attribute is kept only if the allowlist for that element names it. No on* handler appears anywhere in that list. So < img src=x onerror=alert(1)> comes back as <img src="x">.
KSES is not broken here. It is doing precisely what it was written to do. It was just never meant to be the only thing standing between an unauthenticated string and the page.
Then what do you actually have?
An unauthenticated attacker who can place allowlisted elements, carrying chosen id, class, href and data-*, into the login DOM.
That is not script execution. It is a foothold in the DOM of a page that happens to ship JavaScript written for a different screen. Which is the next question, and it is the one that turns this from defacement into an 8.9.
Proving It With gdb, Not With Docs
The PHP docs do not describe this behaviour. Source reading is better but still second hand, so I broke on the function.
php_strip_tags_ex is an exported dynamic symbol, which means you can break on it even though Debian ships a stripped PHP binary. No DWARF, so read the SysV AMD64 argument registers directly.
$ gdb -q -nx -batch -x gdb-strip-tags.gdb --args php -r 'echo strip_tags("< area id=x>");'
===== ENTRY to php_strip_tags_ex =====
rdi (rbuf) = < area id=x>
rsi (len) = 12
r8 (allow_tag_spaces)= 0
input bytes:
0x7ffff6e69860: 0x3c 0x20 0x61 0x72 0x65 0x61 0x20 0x69
===== RETURN =====
rax (result length) = 12
buffer now = "< area id=x>"
Buffer unchanged. Now drop the space:
input bytes:
0x7ffff6e69838: 0x3c 0x61 0x72 0x65 0x61 0x20 0x69 0x64
===== RETURN =====
rax (result length) = 0
buffer now = ""
Length zero, first byte zeroed. The only difference between the two runs is buf[1]: 0x20 versus 0x61.
r8 is allow_tag_spaces and it is 0 in both runs, which confirms userland strip_tags() always disables the space allowance.
Now the instruction that decides it:
php_strip_tags_ex+233: call __ctype_b_loc@plt
php_strip_tags_ex+254: testb $0x20,0x1(%rax,%rdx,2)
The glibc ctype table is two bytes per entry. Offset 0x1 picks the high byte, and $0x20 on the high byte is bit 0x2000, which is _ISspace. So that instruction is isspace(p[1]).
Break on it and read the flags:
input < area id=x> rdx = 0x20 ctype hi = 0x60 eflags 0x202 [IF] ZF=0
input <area id=x> rdx = 0x61 ctype hi = 0xd6 eflags 0x246 [PF ZF IF] ZF=1
0x60 & 0x20 is nonzero, so isspace is true and the branch that emits a literal < gets taken. 0xd6 & 0x20 is zero, so the tokenizer enters the tag state and eats it.
Source, registers, instruction, flag. Four levels, same answer:
PHP treats
<as a tag opener unless the next byte isisspace().
Reproducing It
Two containers, same image, loopback only. One patched by hand with the upstream hunk, since Docker Hub has no 7.0.3 image yet.
wp-vuln: wordpress:7.0.2-apache 127.0.0.1:8081
wp-patched: wordpress:7.0.2-apache 127.0.0.1:8082 + esc_html()
$ curl -s -X POST http://127.0.0.1:8081/wp-login.php \
--data-urlencode 'log=< a id="pass1" href="#zzz">CLICKME</a>' \
--data-urlencode 'pwd=x' --data 'wp-submit=Log+In' \
-b 'wordpress_test_cookie=WP+Cookie+check' \
| grep -o '<div id="login_error".*</div>'
Vulnerable:
<div id="login_error" class="notice notice-error"><p><strong>Error:</strong>
The username <strong><a id="pass1" href="#zzz">CLICKME</strong> is not registered
on this site. ...
Patched:
The username <strong>< a id="pass1" href="#zzz">CLICKME</strong>
And in a real browser, via Playwright against the vulnerable host:
document.getElementById('pass1') -> {tag: 'A', id: 'pass1', href: '#zzz', text: 'CLICKME'}
node is inside #login_error -> True
It is a real element node, not a string that looks like one.
What You Do Not Get
The code said script and on* are dropped. Here it is against the running instance, which is where the “XSS to RCE” headline picks up its footnote:
< a id=x>t</a> -> <a id="x">t live
<a id=x>t</a> -> t stripped
< script>alert(1)</script> -> alert(1) kses.php:1396
< img src=x onerror=alert(1)> -> <img src="x"> kses.php:1481
< area id=pass1> -> <area id="pass1"> live
Reading matches running. No alert(1).
So the primitive is allowlisted DOM with attacker chosen id, class and href. That is a good primitive. It is not script execution.
Script execution has to come from JavaScript that is already on the page.
The Gadget
WordPress loads wp-admin/js/user-profile.min.js on the login screen. Not on action=resetpass. On the default login page, where nobody is logged in and no profile is being edited.
$ curl -s http://127.0.0.1:8081/wp-login.php | grep -o 'wp-admin/js/[a-z-]*'
wp-admin/js/password-strength-meter.min.js
wp-admin/js/user-profile.min.js
Its localized data is there too. typeof userProfileL10n returns object.
Two things in that file matter.
One. The ownership guard is vacuously true.
526 user_id = $( 'input#user_id' ).val();
527 current_user_id = $( 'input[name="checkuser_id"]' ).val();
541 if ( user_id === current_user_id ) {
Neither input exists on the login page. Evaluated live in Chromium:
jQuery('input#user_id').val() -> undefined
jQuery('input[name="checkuser_id"]').val() -> undefined
... === ... -> true
undefined === undefined is true. A check that compares two DOM reads without testing that either exists is not a check.
Two. There is an unconditional trigger on ready.
620 if ( $( '.reset-pass-submit' ).length ) {
621 $( '.reset-pass-submit button.wp-generate-pw' ).trigger( 'click' );
622 }
The condition is the presence of a class. That is it.
And KSES allows class and id on every element it permits, including button:
button allowed=YES class,id,type,value,name,disabled,data-*,style
a allowed=YES href,class,id,data-*,style
div allowed=YES class,id,data-*,style
input allowed=no
form allowed=no
input and form are refused, which is exactly why the chain has to drive Core’s own script instead of posting its own form.
Four preconditions: an injection that controls id and class, a script loaded outside its page, a ready handler that fires on selector presence, and a guard comparing two undefineds. Each one is defensible alone. The vulnerability is the intersection.
What Was Actually Patched
- $username
+ esc_html( $username )
That is it. Plus the same treatment for $email and a couple of esc_url() calls swept up in the same pass.
What did not change:
$ git diff --stat 7.0.2..7.0.3 -- src/wp-includes/formatting.php
$ git diff --stat 7.0.2..7.0.3 -- src/js/_enqueues/admin/user-profile.js
$ git diff --stat 7.0.2..7.0.3 -- src/wp-includes/rest-api/class-wp-rest-server.php
# all empty
The tokenizer divergence is untouched. The gadget is untouched. The fix escapes the one sink Core owned and leaves the class open.
Run the sanitizer comparison at 7.0.2 and at 7.0.3 and you get byte identical output. Ten of nineteen test constructs still disagree.
There is also no regression test. The only test file in the entire release belongs to a different advisory.
The Affected Range, and Why Everyone Got It Wrong
Two claims went around. “Ships in code since 4.7.” And “the exploitable range is 6.4 through 7.0.2.”
Both are wrong, and the code settles it.
$ for t in 4.7.0 5.5.0 5.7.0 5.8.0 6.3.0; do
git show "$t:src/wp-includes/user.php" | grep -q 'is not registered on this site' \
&& echo "$t REFLECTS" || echo "$t static"
done
4.7.0 static
5.5.0 static
5.7.0 static
5.8.0 REFLECTS
6.3.0 REFLECTS
4.7 emits '<strong>ERROR</strong>: Invalid username.' with no %s at all. There is nothing to inject into. Check the backport if you do not believe it: 4.7.34 still has the static string and received no esc_html fix, because it was never vulnerable.
The other end:
<= 6.3 echo '<div id="login_error">' . $errors . "</div>" no filter at all
>= 6.4 wp_admin_notice() -> echo wp_kses_post( ... )
So 5.8 through 6.3 are affected and are strictly worse. No KSES in the path means a plain <script> works. No gadget, no chain, no admin required.
The real picture:
<= 5.7 not affected
5.8 - 6.3 affected, unfiltered, plain script injection
6.4 - 7.0.2 affected, KSES constrained, needs the gadget
The vendor’s own numbers agree. The 7.0.3 release note lists 7 of 12 vulnerabilities for 5.7 and earlier, and 8 of 12 for 5.8 and later. Exactly one issue becomes applicable at 5.8, and 5.8 is exactly where the reflection was introduced. 5.8.14 and 6.3.9 both shipped esc_html().
Calling 6.4 the start of the range names the hardest window to exploit as the only one that counts.
Hunting for More
If the divergence survived, other call sites might be reachable. I went looking.
First, a triage rule, measured rather than assumed. Does a browser tokenize < a like strip_tags does?
'< a id=spaced>' -> element created? False
'<a id=unspaced>' -> element created? True
Chromium agrees with strip_tags and disagrees with KSES. So a raw echo of stripped output is not exploitable this way. Only a KSES sink is. That cuts the search a lot.
Then the callers. There are 31 direct wp_strip_all_tags() call sites, not the couple hundred I first guessed.
esc_attr() wrapped safe
raw echo into HTML not exploitable by this vector
CSS / email / JSON not an HTML sink
HTML API set_attribute escaped by the Tag Processor
sanitizer wrappers the actual surface
The wrappers are where it lives. Measured inside a running WordPress:
sanitize_user -> '< a id="pass1">x' | then wp_kses_post -> <a id="pass1">x
sanitize_text_field -> '< a id="pass1">x' | then wp_kses_post -> <a id="pass1">x
sanitize_textarea_field -> '< a id="pass1">x' | then wp_kses_post -> <a id="pass1">x
esc_html -> '< a id=...' | inert
sanitize_title -> 'a-idpass1x' | inert
sanitize_text_field() does not stop it either. It calls wp_pre_kses_less_than() first, but that helper only escapes a < whose match contains no >, so < a id=x> sails through.
Core is clean at 7.0.3. All five authenticate callbacks, register_new_user() and retrieve_password() are static or escaped.
What is not clean is the shape. wp_authenticate() still hands every authenticate filter callback a non strict sanitize_user() result. A plugin that registers one of those and prints $username in an error, which is the most natural thing such a callback does, reproduces this bug exactly. The assumption “it went through sanitize_user(), so it is safe to print” is the thing that was never fixed.
I did not find a specific vulnerable plugin, and I did not scan a plugin corpus. Nothing here goes to disclosure.
Detector
libfree.py probes rather than fingerprints. One POST with a benign marker, nothing executed, nothing written.
$ python3 libfree.py -f targets.txt
TARGET STATUS VERSION NOTE
----------------------------------------------------------------------------------
! http://127.0.0.1:8081 VULNERABLE 7.0.2 probe reflected as live markup
+ http://127.0.0.1:8082 PATCHED 7.0.2 probe reflected escaped
. http://127.0.0.1:9999 REFUSED - nothing listening on that port
~ https://127.0.0.1:8081 TLS ERROR - handshake failed: RECORD_LAYER_FAILURE
. http://127.0.0.1:8090 LOGIN DISABLED - HTTP 404, wp-login.php not exposed
. http://does-not-resolve-xyz.invalid NO DNS - hostname does not resolve
. http://127.0.0.1:8081/nonexistent-subdir NOT WORDPRESS - HTTP 200, no WordPress login form
7 checked 1 vulnerable 1 patched 5 not determined
not determined: 1 login disabled, 1 no dns, 1 not wordpress, 1 refused, 1 tls error
The first two both report 7.0.2 and only one is patched. A version check would have called both vulnerable, which is the whole reason to probe instead. That case is common, plenty of hosts backport without bumping the banner.
A host it could not reach never gets reported as safe. Anything that is not conclusively vulnerable or patched goes into not determined with the reason attached, so a scan of a few hundred sites tells you which ones you still have to look at by hand.
Source: github.com/christbowel/xss2shell
Only scan what you own or are authorised to test.
Fix
Update to 7.0.3, or your branch’s patched release: 6.9.6, 6.8.7, 6.7.6, 6.6.6, 6.5.9, 6.4.9, 6.3.9, 6.2.10, 6.1.11, 6.0.13, 5.9.14, 5.8.14.
If you maintain a plugin that hooks authenticate, do not print $username raw. It has been through sanitize_user() and that is not enough.
Takeaways
The sanitizer that looks safest is the one to check. sanitize_user() reads like a security boundary and is not one.
Severity is a property of the sink, not the injection point. The same byte is a full script XSS at 6.3 and a gadget puzzle at 6.4. WordPress silently downgraded a live vulnerability in 2023 without knowing, and the industry then mistook that boundary for the start of the bug.
A guard that compares two DOM reads without an existence check is not a guard. undefined === undefined is true, and it will keep being true.