xss2shell (CVE-2026-64638): wp2shell’s little sister, now exposing your WordPress
xss2shell (CVE-2026-64638) affects every version of WordPress before 7.0.3 and can lead to a full site takeover. What the flaw means for your organization, how to check your exposure and how to protect yourself.

Why xss2shell deserves your attention right now
Barely three weeks after wp2shell, the WordPress core takes a second major hit. It carries a name, xss2shell, an identifier, CVE-2026-64638, and a starting point that is almost absurdly ordinary: the login page, the one nearly every site leaves open on the Internet. A single failed login attempt is enough to inject JavaScript into an administrator's browser. From there, the chain climbs all the way up to PHP code execution on the server.
The fix has been available since August 6, 2026 with WordPress 7.0.3. The full exploit, the one that reaches a shell, has not been published in ready-to-use form yet. But the mechanism is documented in detail, a detection template is already circulating, and the affected surface is north of 40% of the web. The window is short. This article breaks the chain down step by step, hands you a ready-to-run PoC to test your own exposure (by hand, then with Nuclei), and details the remediation.
XSS today, shell tomorrow: the flaw's two faces
The name spells out the whole trajectory: an XSS (cross-site scripting) that ends in a shell. One nuance is worth stating up front, because it is the difference between panic and methodical remediation.
The first step, the unauthenticated reflected XSS, is solidly established and broad. It hits a default WordPress install, with no plugin at all, as soon as an attacker reaches the login screen. The second part, going from that XSS to code execution, is real but conditional: it assumes an administrator opens a booby-trapped page at the wrong moment, and that a few default settings are in place. The two do not carry the same weight in probability, and a serious remediation plan accounts for that.
The flaw was discovered and reproduced by the pwn.ai research team, which remains the primary source to cross-check. The CVSS 3.1 score is 7.5 (high), and its vector spells out the nuance: AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H. The attack is remote and needs no privileges (PR:N), but it comes with high complexity (AC:H) and requires user interaction (UI:R), precisely because code execution needs an administrator to act unknowingly. One thing to keep in mind for what follows: the version alone does not prove exploitability, it is the reflection of the payload that confirms it.
Inside the bug: two HTML sanitizers that contradict each other
The whole flaw comes down to a disagreement between two pieces of code that, taken separately, do their job correctly. It is a classic of application security: a parser confusion. Two components read the same string and reach different conclusions about it.
strip_tags() versus wp_kses_post(): the space that changes everything
When a login fails on a username that does not exist, WordPress redisplays that username in an error message, on the login page. Before displaying it, the value goes through sanitize_user(), which in non-strict mode relies on wp_strip_all_tags(), itself built on PHP's native strip_tags().
Now strip_tags() has a little-known quirk: it only recognizes a tag when the opening angle bracket sits flush against the tag name. Slip a space in right after the <, and it lets everything through.
strip_tags('<area id=x>'); // "" → the tag is removed
strip_tags('< area id=x>'); // "< area id=x>" → the string survives intactThe surviving string is not dangerous yet. It becomes so at the next stage. WordPress renders the login error message by passing it through wp_kses_post(), its reference HTML sanitizer. And that one, unlike strip_tags(), treats < area as a perfectly valid <area> tag.
From injected HTML to JavaScript: DOM clobbering
Injecting an <area> does not directly yield JavaScript: wp_kses_post() forbids event attributes like onclick, and the <script> tag does not pass. So the attacker takes another route, a technique called DOM clobbering: instead of running code, they craft HTML elements whose identifiers collide with JavaScript variables that WordPress expects.
The login screen loads user-profile.js, a core script meant for the password-reset flow. On page load, that script looks for a password-generation button and triggers it automatically. By nesting the right id attributes into the injected tags, the attacker makes the ajaxurl variable, which the script believes holds a URL, point to their <area> element instead. When the script reads that variable as a string, JavaScript calls the element's toString() method, which simply returns its href attribute. The destination URL of the AJAX request is now the one the attacker wrote.
The REST bounce: _jsonp and execution inside the origin
What remains is to get arbitrary JavaScript to run in the site's origin. The attacker points the request at WordPress's REST API, abusing its JSONP support:
/?rest_route=/&_method=GET&_jsonp=<callback>&_envelope=1The REST API accepts a _jsonp parameter as long as it matches ^[a-zA-Z0-9_.]+$, then wraps its response in a function call: /**/<callback>({...}). Served with a Content-Type: application/javascript, that response is executed by jQuery via globalEval(). The decisive detail is the dot: the . characters allowed in the callback let the attacker target a specific method, for example window.opener.approve.click. They no longer just call alert, they operate a chosen element on the page.
From XSS to webshell: the full escalation chain
This is where the chain becomes genuinely dangerous, and also where it becomes conditional. The transition works by abusing, once again, perfectly legitimate WordPress features.
- SOME (Same-Origin Method Execution). The attacker opens the
authorize-application.phppage inside a logged-in administrator's session, then, thanks to the JSONP callback described above, triggers the click on the authorization button. WordPress then creates an application password and redirects to the attacker-controlled return URL, which picks up the identifier and the cleartext password along the way. - A brand-new service account. Armed with that application password, the attacker talks to the REST API over Basic authentication, with the administrator's rights, without ever having known the real password or the session cookie.
- Dropping the webshell. All that is left is to extract a nonce from the plugin-upload page, then send a booby-trapped ZIP archive via
POST /wp-admin/update.php?action=upload-plugin. The ZIP is unpacked intowp-content/plugins/, where PHP files are directly reachable and executable. The shell is in place.
The diagram below sums up the full sequence, in seven steps split across the two phases described above.
From the login page to code execution
- 1
Exposed login screen
wp-login.php is open on the Internet, with no account required.
- 2
Parser confusion
an injected tag survives strip_tags() but wp_kses_post() renders it as valid HTML.
- 3
DOM clobbering
injected identifiers hijack the ajaxurl variable of the user-profile.js script.
- 4
REST API bounce
the _jsonp parameter runs the attacker's JavaScript inside the site's origin.
- 5
Application-password theft
a logged-in administrator opens a booby-trapped page, WordPress creates and hands over the secret.
- 6
Administrator service account
the attacker talks to the REST API over Basic auth, with full rights.
Webshell dropped
a booby-trapped ZIP plugin is unpacked into wp-content/plugins/ and runs PHP code.
Am I vulnerable? Affected versions and the fix
On this point, the official advisory is blunt: the flaw affects all versions of WordPress. The fix shipped with 7.0.3 and was backported all the way to the 4.7 branch, as a courtesy to sites still on an older major version.
| WordPress version | Status | Fix |
|---|---|---|
| Any version < 7.0.3 | In the historically affected range, status to confirm against your branch's latest fix | 7.0.3, or the latest maintenance release on your channel (backported to 4.7) |
| 7.0.3 and above | Fixed with certainty | Already applied |
The fix is a model of restraint: it escapes the failed-login username with esc_html() at the interpolation site, in wp-includes/user.php, so the value is HTML-encoded before it ever reaches either sanitizer. The disagreement between strip_tags() and wp_kses_post() still exists elsewhere in PHP, but it no longer surfaces on this page.
Proof of concept: test your exposure in 2 minutes
The good news is that xss2shell can be tested without a destructive exploit. The first step, the reflected XSS, is confirmed by sending a harmless payload and watching whether it comes back escaped or not. No account creation, no write, no execution: you simply observe the reflection. Three steps are enough, a terminal and curl for the first two, nuclei to scan a whole estate.
Step 1: confirm the target really is WordPress
There is no point probing something that is not a WordPress login screen. Two markers present in the HTML of wp-login.php confirm it at a glance:
curl -s https://YOUR-SITE/wp-login.php | grep -o 'loginform\|wp-core-ui'If the command prints loginform and wp-core-ui, you have a standard WordPress login. If not, the target is not in scope for this test, or the login screen has been moved or protected upstream.
Step 2: the manual reflection test (curl)
The principle mirrors the public template. You POST a payload to wp-login.php containing an < area id=ajaxurl> tag (the space after the < is exactly what lets it survive strip_tags()), then you look for that tag in the response. If it comes back unescaped, the site is vulnerable.
Here, decoded, is the only field that matters, log: it holds the harmless payload, the rest of the body is a throwaway login.
log = < area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert>
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X
pwd = x
wp-submit = Log InIn practice, those values are sent URL-encoded in a single request:
curl -s -X POST https://YOUR-SITE/wp-login.php \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'log=%3C%20area%20id%3Dajaxurl%20href%3D%2F%3Frest_route%3D%2F%26_method%3DGET%26_jsonp%3Dalert%3E%3C%20div%20id%3Dcolor-picker%20class%3Dreset-pass-submit%3E%3C%20button%20class%3D%22wp-generate-pw%20color-option%22%3EX&pwd=x&wp-submit=Log+In' \
| grep -io '<area[^>]*id=["'"'"']*ajaxurl[^>]*>'Reading the result:
- The command prints a tag like
<area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert>: the payload came back unescaped, your site is vulnerable. Update it without delay. - The command prints nothing: the payload did not come back verbatim. That is a good sign, but not proof of a fix on its own. The same silence can come from a WAF that sanitises the field before WordPress, a redirect, a custom login screen or a plain network hiccup. To conclude, cross-check with the version (7.0.3 or your branch's latest maintenance release).
That is the whole test. We deliberately stop there: reproducing the full escalation on a production target is exploiting it for real.
Step 3: the automated scan with Nuclei
To check a whole estate rather than a single URL, the community published a Nuclei template (author: FLX | Nick Vidovic). It runs the same two checks: it first confirms the target is a WordPress login screen, then verifies that the payload comes back unescaped. If the template is already in your local repo:
nuclei -u https://YOUR-SITE -id CVE-2026-64638Otherwise, save the file below as CVE-2026-64638.yaml and point to it explicitly:
nuclei -u https://YOUR-SITE -t CVE-2026-64638.yamlid: CVE-2026-64638
info:
name: WordPress Core < 7.0.3 - Preauth Reflected XSS (XSS2Shell)
author: FLX | Nick Vidovic (greenhats)
severity: high
reference:
- https://pwn.ai/blog/xss2shell
- https://nvd.nist.gov/vuln/detail/CVE-2026-64638
classification:
cvss-metrics: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
cvss-score: 7.5
cwe-id: CWE-79
tags: cve,cve2026,wordpress,xss,rce
flow: http(1) && http(2)
http:
- method: GET
path:
- '{{BaseURL}}/wp-login.php'
matchers:
- type: word
part: body
words:
- 'loginform'
- 'wp-core-ui'
condition: and
internal: true
- raw:
- |
POST /wp-login.php HTTP/1.1
Host: {{Hostname}}
Content-Type: application/x-www-form-urlencoded
log=%3C%20area%20id%3Dajaxurl%20href%3D%2F%3Frest_route%3D%2F%26_method%3DGET%26_jsonp%3Dalert%3E%3C%20div%20id%3Dcolor-picker%20class%3Dreset-pass-submit%3E%3C%20button%20class%3D%22wp-generate-pw%20color-option%22%3EX&pwd=x&wp-submit=Log+In
matchers-condition: and
matchers:
- type: regex
part: body
regex:
- '<area[^>]*id=["'']?ajaxurl'
- type: status
status:
- 200A template that flags the target marks a vulnerable site. Conversely, no output only means the reflection was not observed: it is not the same as fixed. A WAF, a timeout, a redirect or a custom login screen also produce zero results. Only the version (7.0.3 or later) settles it with certainty.
What to do now: the remediation plan
In order of effectiveness:
- Update to WordPress 7.0.3, or to the latest fix on your branch if you have stayed on an earlier major version. This is the only remediation that actually closes the flaw.
- Restrict access to
wp-login.phpby IP, by upstream authentication or by a server rule. You cut the initial-access vector and, along the way, a good chunk of the noise from brute-force attacks. - Shrink the escalation surface if the patch cannot be applied right away: disable application passwords if you do not use them, set
DISALLOW_FILE_MODSto forbid installing plugins from the interface, and check that PHP is not executed from inactive plugin directories.
Spot attempts in your logs
You do not need to send anything to spot an ongoing exploitation. Three signatures speak for themselves in the logs:
- a
POST /wp-login.phpwhoselogfield contains an angle bracket<(URL-encoded%3C), possibly followed by a space or an encoded whitespace character (%09,%0a,%0d); - a request to the REST API carrying a
_jsonp=parameter whose callback contains a dot, the hallmark of the escalation phase; - an access to
authorize-application.phpwith a return URL outside your domain, followed by aPOST /wp-admin/update.php?action=upload-plugin.
On the indicators-of-compromise side, watch for recently created application passwords, unknown new plugins and unexpected administrator accounts.
Two flaws in three weeks: the lesson for security teams
Two critical WordPress flaws in three weeks, on entirely different vectors, sends a clear signal. wp2shell went through the REST API and a SQL injection. xss2shell starts from the login screen and a parser confusion. The common thread is not technical, it is organizational: both hit default installs, with no plugin at fault, and both spread within hours once public.
The annual pentest sees neither: a flaw disclosed on August 6 does not exist in a report dated last spring. The version scanner catches the easy part, a number in range, but misses the context and is fooled the moment the version is masked. And the incomplete perimeter stays everyone's Achilles heel: it is the old WordPress blog forgotten on a subdomain, the one nobody updates because nobody remembers it exists, that falls first. To find it, you first have to map your whole information system and inventory your external attack surface.
Detect xss2shell continuously with Flawfence
At Flawfence, the logic is the same as for wp2shell, and it did not have to be reinvented in a panic. The engine maps the perimeter, identifies every exposed WordPress instance, including forgotten Shadow IT, places its version against the known vulnerable ranges and checks the exposure of the relevant vectors, without ever sending a destructive payload.
Concretely, against xss2shell, Flawfence:
- discovers your exposed WordPress instances, across all your domains and subdomains, including the ones you had forgotten;
- places the version below 7.0.3 and flags the case of older branches to cross-check against their latest backported fix;
- probes the login screen with the reflection marker, which confirms the XSS when a vulnerable version lets it come back unescaped;
- monitors continuously, so that a newly appearing vulnerable subdomain, or a rolled-back version, raises an alert without waiting for the next audit.
Frequently asked questions about xss2shell
What is xss2shell?
xss2shell is the nickname for CVE-2026-64638, a WordPress core vulnerability. It exploits a disagreement between two HTML-sanitization functions (strip_tags() and wp_kses_post()) to plant an unauthenticated reflected XSS on the wp-login.php login screen, then chains it, under certain conditions, all the way to PHP code execution on the server.
Which WordPress versions are vulnerable to xss2shell?
According to the official advisory, the flaw affects all versions of WordPress older than 7.0.3. The fix arrived with 7.0.3, on August 6, 2026, and was backported to every maintained branch down to 4.7. In practice, any site running a version below 7.0.3 should be treated as affected until it has taken the latest maintenance release on its channel.
Is xss2shell being actively exploited?
At the time of writing, no full exploit leading to a shell is public, and no mass exploitation has been reported. But the mechanism is publicly documented, a detection template is already circulating, and the surface is vast. As with wp2shell, the window between disclosure and exploitation is measured in hours, not weeks. Patch without delay.
Is xss2shell as serious as wp2shell?
Both are critical, but differently. wp2shell led to an unauthenticated RCE in a single anonymous request, on a default install. xss2shell reaches the XSS without authentication just as easily, but code execution additionally requires an administrator to be logged in and lured onto a booby-trapped page, and certain settings to remain at their defaults. The CVSS 3.1 score for xss2shell, 7.5, reflects that conditional escalation.
How can I test xss2shell without risking my site?
The reflected XSS is confirmed with a harmless payload: the < area id=ajaxurl> marker from the Proof of concept section comes back unescaped on a vulnerable version, encoded on a patched one. It creates no account and writes nothing. Test it with curl or the Nuclei template, only against your own assets, or let the Flawfence scanner do it under self-declared ownership.
Is blocking wp-login.php with a WAF enough?
It is a useful mitigation that cuts the initial-access vector, but stays bypassable and does not fix the cause. The only complete remediation is updating to 7.0.3 or the latest fix on your branch.
In short
xss2shell is a reminder of an uncomfortable truth: the most dangerous flaws do not always come from exotic code, but from the disagreement between two functions that, each on its own, were doing their job well. A space after an angle bracket, two sanitizers that diverge, and the most ordinary login screen on the web becomes a front door.
The good news is the same as with wp2shell: detecting this class of flaw requires neither a destructive exploit nor a €20k pentest. It takes an exhaustive inventory of your perimeter and continuous monitoring that tests the real behavior of your assets. That is exactly what Flawfence does, for xss2shell as for the next flaw that does not yet have a name.
Check your exposure to xss2shell now, it will only take 5 minutes.
Let’s discuss your external exposure
Request a personalized Flawfence demo and discover your real exposure level.
