Skip to content
Attack Surface Management

XSS2Shell (CVE-2026-64638): WordPress Pre-Auth XSS to RCE Detection with Nuclei

xer0dayz · · 28 min read

On August 6, 2026, WordPress shipped 7.0.3 along with backports to every branch it still supports, all the way down to 4.7. Twenty-four releases in a single day. The headline issue in that wave is XSS2Shell, tracked as CVE-2026-64638: a pre-authentication reflected cross-site scripting flaw on the login screen that can be chained into PHP code execution. It was found and responsibly reported by the team at pwn.ai, whose write-up is the authoritative technical source.

This advisory is three things. First, a plain-English explanation of what XSS2Shell actually is, including the part most coverage gets wrong. Second, a ready-to-run Nuclei detection template that stays accurate across all 24 branches instead of over-reporting on a naive version check. Third, a walkthrough of how to find every affected WordPress instance across an attack surface with Sn1per.

One thing to get straight up front, because it drives how urgently you should treat this. The XSS needs no account. The escalation to code execution does not stand alone: it needs an administrator with a live session to open an attacker-controlled page. That is a real and meaningful gap, and it is exactly why the CNA scored this 8.9 High rather than in the nines. If you patched wp2shell (CVE-2026-63030) last month and are wondering whether this is the same class of emergency, it is not. More on that below.

XSS2Shell at a glance

  • CVE: CVE-2026-64638, CWE-79 (reflected cross-site scripting). Also tracked as GHSA-52p2-r8wf-jcrf. Note this is a repository-level advisory: it resolves at that URL but was never ingested into the GitHub Global Advisory Database, so github.com/advisories/GHSA-52p2-r8wf-jcrf returns 404.
  • Impact: pre-authentication reflected XSS on /wp-login.php, escalatable to PHP code execution against a logged-in administrator.
  • CVSS: 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 from the HackerOne CNA. Patchstack independently scores it 7.1. NVD has not completed its own analysis: the record is still in Received status, and the 8.9 shown there is the CNA score carried over as a Secondary metric, not an NVD Primary score.
  • Affected: every WordPress branch from 4.7.0 through 7.0.2 received the fix. The exploitable range is narrower: 6.4.0 through 7.0.2.
  • Fixed: 24 releases on August 6, 2026. 7.0.3 on the current branch; see the full table below for yours.
  • Exploited in the wild: no confirmed exploitation, and not in CISA KEV as of August 10, 2026.
  • Fix now: update Core. If you cannot, set DISALLOW_FILE_MODS and disable Application Passwords to break the escalation.

What is XSS2Shell (CVE-2026-64638)?

XSS2Shell is a reflected XSS in the WordPress login screen’s failed-login error message. When you submit a username that does not exist, or the wrong password for one that does, WordPress builds an error notice that includes the username you typed. On WordPress 5.8 and later, three of those messages in wp-includes/user.php interpolated the submitted value into HTML without escaping it: two in wp_authenticate_username_password() (the invalid-username branch and the incorrect-password branch) and one in wp_authenticate_email_password(). The attacker-controlled value is the log POST parameter. The fix wraps each one in esc_html().

On 4.7 through 5.7 there are only two such sinks, because the unknown-username error on those branches is a static string that does not reflect anything you typed. The reflecting variant, “The username %s is not registered on this site,” first appears in 5.8. The same commit also hardened two non-login outputs in register_new_user() with esc_url() and esc_attr(), so the full change in user.php is five escaping fixes, not three.

The interesting part is why it was ever exploitable, because WordPress does sanitize that value. This is not a missing sanitizer. It is a parser differential between two sanitizers that disagree about what counts as a tag.

The submitted username passes through sanitize_user() in non-strict mode, which calls wp_strip_all_tags(), which is built on PHP’s strip_tags(). PHP’s parser does not treat a < followed by whitespace and then a tag name as the start of a tag, so a construct written that way is not stripped. It survives as ordinary-looking text. Later, when the error notice is rendered, it passes through the KSES tokenizer in wp_kses_post(). KSES is more permissive about whitespace, normalizes that same construct into a real element, and because the element in question is on the KSES allowlist, it is emitted as live markup.

So the value survives the stripping stage precisely because strip_tags() does not think it is a tag, and then becomes a tag because KSES does. The second sanitizer re-materializes what the first one let through. That is the whole bug, and it is a genuinely elegant find.

How the exploit chain reaches PHP

The XSS alone is a reflected script execution on a login page, which is not by itself catastrophic. Getting from there to code execution takes a chain, and the researchers documented it in full. In outline, and deliberately without payloads:

The injected element carries id and name attributes, which makes it addressable as a named property on window. That is DOM clobbering: it lets an attacker overwrite a global variable that WordPress’s own login-page JavaScript reads. The canonical element is <area>, and the choice is not arbitrary: <area> is on the KSES allowlist, and HTMLAreaElement.toString() returns the element’s href, so clobbering the ajaxurl global with one yields a usable URL rather than a useless object. WordPress enqueues its user-profile script on the default login form, and that script both reads a bare ajaxurl and auto-clicks certain elements on load, so the attacker never needs to supply a <script> tag.

The clobbered global redirects a jQuery $.post() to the REST API using _jsonp with a dotted callback name. Because the REST server returns application/javascript and jQuery evaluates it, that “callback” becomes a Same-Origin Method Execution primitive: it invokes a method path in the administrator’s authenticated window, specifically the approve submit button on the Application Passwords authorization screen. That delivers a valid Application Password for the admin account to the attacker.

Getting from that credential to PHP takes one more hop than most summaries admit, and the detail matters. An Application Password cannot be used against /wp-admin/update.php at all: wp_authenticate_application_password() bails unless the request is a REST or XML-RPC request, and the plugin-upload branch separately enforces a plugin-upload nonce. So the attacker uses the credential over REST to publish a page containing their own JavaScript, navigates the administrator’s browser to that same-origin page, and lets that script scrape the plugin-upload nonce from the plugin-install screen before POSTing the ZIP. Files under wp-content/plugins/ are directly web-executable, so that is the PHP execution.

We are deliberately not publishing a working exploit here. Everything below is detection only. That is the same line we took on wp2shell and on our earlier SharePoint advisory: explain the flaw, then ship a safe detection you can run at scale.

What “pre-auth” does and does not mean here

“Unauthenticated XSS to PHP code execution” is a fair description, and it is what both the reporters and Hadrian use: the attacker needs no account at any point in the chain. What is not fair is compressing that to “unauthenticated zero-click RCE,” which is how it has been shorthanded in several write-ups.

The CNA’s own vector settles it. It carries UI:A, which in CVSS 4.0 is the strongest interaction requirement: a targeted user must perform specific, conscious interactions. There is no known path to code execution without an administrator, already logged in, visiting a page the attacker controls.

Several other gates have to be open too, and one of them is easy to overlook. Application Passwords, which the chain steals, are only available when wp_is_application_passwords_supported() returns true, and that function is is_ssl() || 'local' === wp_get_environment_type(). A WordPress site not served over HTTPS cannot hand out an Application Password at all, so the chain dead-ends at the XSS. The wp_is_application_passwords_available filter, which several security plugins set to false, breaks it the same way. Plugin upload has to be permitted as well.

The honest phrasing is: a pre-auth XSS that can be escalated to RCE against an administrator who takes the bait. The XSS half is genuinely unauthenticated and trivial to reach. The RCE half is a targeted phish against an HTTPS site with Application Passwords enabled.

The contrast with last month’s WordPress Core bug is worth drawing explicitly, because the two are a month apart and easy to conflate:

  wp2shell (CVE-2026-63030) XSS2Shell (CVE-2026-64638)
CVSS 9.8 Critical (CVSS 3.1) 8.9 High (CVSS 4.0); Patchstack says 7.1
User interaction None (UI:N) Required (UI:A)
CWE CWE-436 interpretation conflict CWE-79 cross-site scripting
In CISA KEV Yes, added 2026-07-21 No, as of 2026-08-10
Fixed in 7.0.2 / 6.9.5 7.0.3 and 23 backports

One clarification on that last row, because it is easy to get wrong: the 6.8 branch was never affected by CVE-2026-63030 at all. WordPress 6.8.6 shipped the same day but fixed only the companion SQL injection, CVE-2026-60137. The route-confusion half reached 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1 only.

Patching one did not patch the other. If you updated to 7.0.2 in July, you are still exposed to this one.

CVE-2026-64638 affected versions: all 24 WordPress branches

This is the part almost no published advisory answers properly. Nearly every write-up stops at “fixed in 7.0.3, backported to 4.7,” which does not tell an operator on 6.2 what to install. Worse, it invites a detection rule of the form “flag anything below 7.0.3,” which is wrong: a fully patched 6.8.7 site is numerically below 7.0.3 but is not vulnerable.

Every branch has its own fix floor. The table below is cross-checked against api.wordpress.org/core/stable-check/1.0/, the authoritative machine-readable source for whether a given WordPress version is considered insecure.

Branch Fixed version Branch Fixed version
7.0 7.0.3 5.8 5.8.14
6.9 6.9.6 5.7 5.7.16
6.8 6.8.7 5.6 5.6.18
6.7 6.7.6 5.5 5.5.19
6.6 6.6.6 5.4 5.4.20
6.5 6.5.9 5.3 5.3.22
6.4 6.4.9 5.2 5.2.25
6.3 6.3.9 5.1 5.1.23
6.2 6.2.10 5.0 5.0.26
6.1 6.1.11 4.9 4.9.30
6.0 6.0.13 4.8 4.8.29
5.9 5.9.15 4.7 4.7.34

Two traps in that table.

5.9 is 5.9.15, not 5.9.14. WordPress released 5.9.14 on August 6 and then replaced it the same day because of a problem with the package. The stable-check API still reports 5.9.14 as insecure. Several summaries of this CVE list 5.9.14 as the fixed version. If you standardized on that, you are one release short.

Anything below 4.7 gets nothing, ever. Those branches are past end of life and no patch was issued. Per WordPress.org’s own stats, roughly 1% of all installs sit there permanently.

Why WordPress 6.3 and earlier got a patch but are not exploitable

Here is a nuance we have not seen published anywhere except in passing by Hadrian, and which we verified directly against Core source rather than taking on trust.

WordPress backported the esc_html() change all the way to 4.7, and the release notes for every one of those branches credit the pwn.ai login-screen XSS. That reads as “all 24 branches are exploitable.” They are not.

The chain depends on the KSES re-normalization step, and that step only reaches the login screen from WordPress 6.4. In 6.4, login_header() switched to rendering errors through wp_admin_notice(), and that function does echo wp_kses_post( wp_get_admin_notice( $message, $args ) ). On 6.3 and earlier, login_header() echoes the error directly:

echo '<div id="login_error">' . apply_filters( 'login_errors', $errors ) . "</div>\n";

No KSES pass, so nothing re-normalizes the construct back into a tag, so it stays inert text that a browser renders as literal characters. You can confirm the boundary yourself: wp_admin_notice appears in wp-login.php from 6.4 onward and is absent in 6.3.9 and every branch below it.

There is a second, independent reason the old branches are safe, which makes the boundary over-determined rather than a single point of failure. On 4.7 through 5.7 the unknown-username error does not reflect the submitted value at all, so on those branches the attacker-controlled sink only fires for a username or email that actually exists.

The practical consequence: the patched range and the exploitable range are different sets. 4.7.0 through 7.0.2 got the fix; 6.4.0 through 7.0.2 could actually be exploited through the WordPress Core login screen. A detection rule that treats them identically over-reports across seventeen branches. Our template reports them separately.

One caveat, because the underlying flaw is broader than the login page. The sanitize_user() and wp_kses_post() differential is itself version-independent. Any plugin or theme that takes a sanitize_user() value and renders it through wp_kses_post(), or hooks login_errors and wraps the message in an admin-notice helper, re-creates the same sink on an older branch. The 6.4 boundary applies to Core’s own login screen, not to every possible path in your install. Patch regardless of branch.

Is my WordPress site vulnerable to CVE-2026-64638?

On a host you control, the authoritative check is WP-CLI from the WordPress root:

wp core version

Compare the result against your branch’s row in the table above. An available update is not an installed update, so check the running version rather than the dashboard’s intent.

From outside, a stock install advertises its version in several public places:

# Generator meta tag on the home page
curl -s https://example.com/ | grep -i 'name="generator"'

# RSS feed generator - survives hardening more often than the meta tag
curl -s 'https://example.com/?feed=rss2' | grep -i 'generator'

# OPML generator
curl -s https://example.com/wp-links-opml.php | grep -i generator

Two warnings about external version checks. Hardening plugins strip these markers routinely, so silence means “unknown,” not “clean.” And markers go stale: while researching this post we found a site whose homepage still advertised ?ver=6.9.4 from cache while its own feed reported 7.0.3. Never decide on a single marker.

Do not try to test this by submitting a marker string to the login form. It does not work, and we verified why. esc_html() is a no-op on any harmless alphanumeric value, so a patched and an unpatched host return byte-identical output for a benign username. The only input that behaves differently is the injection construct itself, which is the exploit. There is no safe middle ground. A login probe would also fire wp_login_failed on every attempt, and roughly eight million installs run Wordfence, Limit Login Attempts, All In One WP Security or Solid Security, several of which lock out usernames as well as IPs.

XSS2Shell Nuclei template for CVE-2026-64638

The template below is the one shipped in Sn1per’s curated set. It is GET-only and sends no login POST, no XSS payload, and no exploit traffic, so it is safe against production.

It works in two tiers. The primary signal is a content oracle rather than a version string. The file wp-admin/js/inline-edit-post.min.js changed in every one of the 24 releases on August 6, swapping a concatenated <option> build for new Option(...). We verified that across all 24 fixed tags and their immediate predecessors: the token is present in 24 of 24 fixed builds and absent in every predecessor. Because it is a static file rather than a version banner, it cannot be stripped by a hardening plugin or spoofed by a filter, and in a live survey it was readable on sites that had removed every version marker. Be clear about what it proves: it is a co-shipped change in the same release train, not the esc_html() fix itself, so it answers “does this build carry its branch’s August 6 release,” which is the question that matters operationally.

The second tier is per-branch version comparison, used to classify the finding and as a fallback when the oracle is blocked.

id: CVE-2026-64638

info:
  name: WordPress Core < 7.0.3 - Pre-Auth Reflected XSS to PHP Code Execution (XSS2Shell)
  author: xer0dayz
  severity: high
  description: |
    WordPress Core is affected by "XSS2Shell" (CVE-2026-64638), a pre-authentication
    reflected cross-site scripting flaw on the login screen that can be escalated to PHP
    code execution against a logged-in administrator. The reflected value is the "log"
    POST parameter submitted to /wp-login.php. On WordPress 5.8 and later three
    failed-login error messages in wp-includes/user.php interpolated it into an HTML
    error notice without escaping: wp_authenticate_username_password() (invalid-username
    and incorrect-password branches) and wp_authenticate_email_password()
    (incorrect-password branch), all fixed by wrapping the value in esc_html(). On
    4.7-5.7 there are only two, because the unknown-username error is a static string on
    those branches and reflects nothing.

    Escalation to RCE additionally requires Application Passwords to be available, which
    means the site must be served over HTTPS: wp_is_application_passwords_supported() is
    is_ssl() || 'local' === wp_get_environment_type().

    The root cause is a parser differential, not a missing sanitizer. sanitize_user() in
    non-strict mode calls wp_strip_all_tags() -> PHP strip_tags(), which does not treat
    "<" followed by whitespace and a tag name as a tag, so the construct survives
    stripping. The KSES tokenizer in wp_kses_post() then re-normalizes that same
    construct into a real, allowlisted element, re-materializing live markup.

    This template is NON-DESTRUCTIVE and GET-only. It sends no login POST, no XSS
    payload, and no exploit traffic. There is deliberately no "benign marker" login
    probe: esc_html() is a no-op on any harmless alphanumeric value, so patched and
    unpatched hosts return byte-identical output. The only input that discriminates is
    the injection construct itself, which would be the exploit. A login POST would also
    fire wp_login_failed on every attempt and risks lockout on the ~8M installs running
    Wordfence, Limit Login Attempts, AIOS or Solid Security.

    DETECTION STRATEGY - two tiers, in precedence order:

    1. Content oracle (authoritative). GET /wp-admin/js/inline-edit-post.min.js. That
       file changed in every one of the 24 branch releases shipped on 2026-08-06,
       replacing a concatenated <option> build with new Option(...). Presence of
       "new Option(" therefore means the build carries the 2026-08-06 security release
       for its branch; absence means it does not. Verified against all 24 fixed tags and
       their predecessors. This is a release-level oracle - it is a co-shipped change in
       the same release train, not the esc_html() fix itself - but it answers exactly
       the question that matters, and unlike a version string it cannot be stripped or
       spoofed by a hardening plugin.

    2. Version fallback, used ONLY when the oracle is unreachable (403/404/WAF). Reads
       patch-level version markers anchored to core-only sources.

    TWO RANGES - do not conflate them:

    (a) EXPLOITABLE: 6.4.0 - 7.0.2. The KSES step only reaches the login screen from
    WordPress 6.4, when login_header() switched to wp_admin_notice(), which does
    echo wp_kses_post( wp_get_admin_notice( ... ) ). On 6.3 and earlier login_header()
    echoes the error directly, so the construct that survives strip_tags stays inert
    text and the published chain does not fire.

    (b) HARDENING BACKPORT ONLY: 4.7 - 6.3. WordPress applied the same esc_html() change
    to these branches on 2026-08-06, but the sink is not reachable via the published
    chain. Reported separately - treat as patch hygiene, not an exploitable finding.

    A naive "< 7.0.3" comparison is wrong twice over: it flags a fully patched 6.8.7
    install as vulnerable, and it calls 4.7-6.3 exploitable when they are not. Every
    branch is matched against its own fix floor, cross-checked against
    api.wordpress.org/core/stable-check/1.0/.

    Output note: a host that exposes several readable version markers reports once per
    marker, with the same matcher name and version each time. Deduplicate on
    (template-id, host) if you are ingesting the JSON.

    Known blind spots, stated plainly. If the oracle is blocked AND every core version
    marker is stripped, the template stays silent - that means "unknown, check the host",
    not "clean". An advertised version can also lag the true patch state in either
    direction, so confirm on-host with "wp core version" before acting on a finding.
  remediation: |
    Update WordPress Core to the fixed release for your branch (all released
    2026-08-06): 7.0.3, 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.15, 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, or 4.7.34.

    WordPress 5.9.14 shipped the same day and was then replaced by 5.9.15 because of a
    packaging issue; api.wordpress.org/core/stable-check still reports 5.9.14 as
    "insecure", so only 5.9.15 is a valid patched state on that branch.

    Installs below 4.7 receive no fix and must move to a maintained branch. Verify with
    "wp core version" - an available or approved update is not an installed one.

    Compensating controls that break the RCE escalation without fixing the XSS: set
    DISALLOW_FILE_MODS to block plugin upload, and disable Application Passwords if
    unused. Note that a WAF rule keying on "<" in the log parameter is weak - a
    public WAF-bypass repository for this chain already exists.
  reference:
    - https://wordpress.org/news/2026/08/wordpress-7-0-3-release/
    - https://nvd.nist.gov/vuln/detail/CVE-2026-64638
    - https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-52p2-r8wf-jcrf
    - https://pwn.ai/blog/xss2shell
    - https://api.wordpress.org/core/stable-check/1.0/
  classification:
    cvss-metrics: 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
    cvss-score: 8.9
    cve-id: CVE-2026-64638
    cwe-id: CWE-79
  metadata:
    verified: true
    max-request: 4
    vendor: wordpress
    product: wordpress
    shodan-query: http.component:"WordPress"
    fofa-query: app="WordPress"
  tags: cve,cve2026,wordpress,wp,xss2shell,xss,rce,unauth,login

http:
  - method: GET
    path:
      - "{{BaseURL}}/wp-admin/js/inline-edit-post.min.js"
      - "{{BaseURL}}/?feed=rss2"
      - "{{BaseURL}}"
      - "{{BaseURL}}/wp-links-opml.php"

    redirects: true
    max-redirects: 2
    req-condition: true

    matchers-condition: or
    matchers:
      # ---- TIER 2: version classification. Every clause is gated on the oracle NOT
      # having proven the host patched, so a definitive tier-1 "patched" verdict can
      # never be contradicted by a stale HTML marker (observed in the wild: a homepage
      # advertising ver=6.9.4 while the same site's feed reported 7.0.3).
      #
      # Each clause is gated on a well-formed two- or three-part version. Two-part is
      # deliberately accepted: WordPress names a branch's FIRST release "x.y", not
      # "x.y.0" (api.wordpress.org/core/stable-check lists 6.9 and 7.0, never 6.9.0 or
      # 7.0.0), and those are real releases flagged insecure. compare_versions coerces
      # x.y to x.y.0, which is exactly right. Rejecting two-part strings would downgrade
      # every genuine .0 install to an unclassified finding. The gate still rejects
      # junk, and the extractor is anchored to core-only markers so a stray theme or
      # jQuery version can never reach it.
      - type: dsl
        name: exploitable-7.0-below-7.0.3
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            compare_versions(version, '>= 7.0.0', '< 7.0.3')

      - type: dsl
        name: exploitable-6.9-below-6.9.6
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            compare_versions(version, '>= 6.9.0', '< 6.9.6')

      - type: dsl
        name: exploitable-6.8-below-6.8.7
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            compare_versions(version, '>= 6.8.0', '< 6.8.7')

      - type: dsl
        name: exploitable-6.4-through-6.7-below-branch-fix
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            (compare_versions(version, '>= 6.7.0', '< 6.7.6') ||
             compare_versions(version, '>= 6.6.0', '< 6.6.6') ||
             compare_versions(version, '>= 6.5.0', '< 6.5.9') ||
             compare_versions(version, '>= 6.4.0', '< 6.4.9'))

      - type: dsl
        name: hardening-backport-only-6.0-through-6.3-below-branch-fix
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            (compare_versions(version, '>= 6.3.0', '< 6.3.9') ||
             compare_versions(version, '>= 6.2.0', '< 6.2.10') ||
             compare_versions(version, '>= 6.1.0', '< 6.1.11') ||
             compare_versions(version, '>= 6.0.0', '< 6.0.13'))

      - type: dsl
        name: hardening-backport-only-5.x-below-branch-fix
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            (compare_versions(version, '>= 5.9.0', '< 5.9.15') ||
             compare_versions(version, '>= 5.8.0', '< 5.8.14') ||
             compare_versions(version, '>= 5.7.0', '< 5.7.16') ||
             compare_versions(version, '>= 5.6.0', '< 5.6.18') ||
             compare_versions(version, '>= 5.5.0', '< 5.5.19') ||
             compare_versions(version, '>= 5.4.0', '< 5.4.20') ||
             compare_versions(version, '>= 5.3.0', '< 5.3.22') ||
             compare_versions(version, '>= 5.2.0', '< 5.2.25') ||
             compare_versions(version, '>= 5.1.0', '< 5.1.23') ||
             compare_versions(version, '>= 5.0.0', '< 5.0.26'))

      - type: dsl
        name: hardening-backport-only-4.x-below-branch-fix
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version) &&
            (compare_versions(version, '>= 4.9.0', '< 4.9.30') ||
             compare_versions(version, '>= 4.8.0', '< 4.8.29') ||
             compare_versions(version, '>= 4.7.0', '< 4.7.34'))

      # No shape guard here: any 4.6.x is unsupported regardless of patch level.
      - type: dsl
        name: eol-below-4.7-unsupported-no-fix-available
        dsl:
          - >
            !(status_code_1 == 200 && contains(body_1, 'inlineEditPost') &&
              contains(body_1, 'new Option(')) &&
            compare_versions(version, '< 4.7.0')

      # Oracle says this build predates its branch's 2026-08-06 release, but no core
      # version marker was readable, so exploitable vs hardening-only cannot be decided.
      - type: dsl
        name: confirmed-unpatched-core-asset-oracle-version-unknown
        dsl:
          - >
            status_code_1 == 200 &&
            contains(body_1, 'inlineEditPost') &&
            !contains(body_1, 'new Option(') &&
            !regex('^[0-9]+[.][0-9]+([.][0-9]+)?$', version)

    extractors:
      # Anchored to core-only version markers. A generic /wp-includes/...?ver= pattern
      # must NOT be used: it captures jQuery (?ver=3.7.1), jquery-migrate (3.4.1) and
      # theme assets, which both pollutes the value and can drive a bogus
      # "below 4.7" verdict on a fully patched site.
      - type: regex
        name: version
        part: body
        group: 1
        internal: true
        regex:
          - '(?:content="WordPress |wordpress\.org/\?v=|generator="WordPress/|wp-emoji-release\.min\.js\?ver=)([0-9]+\.[0-9]+(?:\.[0-9]+)?)'

      - type: regex
        name: wordpress-version
        part: body
        group: 1
        regex:
          - '(?:content="WordPress |wordpress\.org/\?v=|generator="WordPress/|wp-emoji-release\.min\.js\?ver=)([0-9]+\.[0-9]+(?:\.[0-9]+)?)'

Save it as CVE-2026-64638.yaml and run it against a single target or a list of hosts:

# Single target
nuclei -t CVE-2026-64638.yaml -u https://example.com/

# A whole list of WordPress hosts
nuclei -t CVE-2026-64638.yaml -l wordpress-hosts.txt

# Example match against an affected 7.0.2 instance:
[CVE-2026-64638:exploitable-7.0-below-7.0.3] [http] [high] https://example.com/ ["7.0.2"]

The matcher name tells you immediately whether you are looking at something exploitable or merely unpatched. We validated the version logic with nuclei v3.11.1 against mock responders covering every branch boundary, including the cases that break naive templates: a patched 6.8.7 host stays clean, 5.9.14 is correctly flagged as below its 5.9.15 floor, and a bare 6.8 is correctly read as the 6.8 release rather than being discarded.

Three design notes worth stealing if you write your own:

Anchor the ?ver= regex to core-only markers. A generic /wp-includes/.*\?ver= pattern captures jQuery at ?ver=3.7.1 and jquery-migrate at ?ver=3.4.1. We caught this in testing on a fully patched site: the extractor returned ["3.4.1","3.7.1"], values that would drive a bogus “below 4.7” verdict. Match the generator tag, the feed and OPML generators, and the emoji release URL, and nothing else.

Do not reject two-part versions. This one is counter-intuitive. Nuclei’s compare_versions coerces 6.8 to 6.8.0, so the obvious hardening move is to demand a full three-part string. That is wrong for WordPress: a branch’s first release is named x.y, not x.y.0. The stable-check API lists 6.9 and 7.0 and has no entries for 6.9.0 or 7.0.0, and both are flagged insecure. Demand three parts and you quietly downgrade every genuine .0 install to an unclassified finding. Accept two or three parts, and anchor the extractor instead.

Two honest blind spots. If the oracle is blocked and every version marker is stripped, the template stays silent, and silence means “unknown, go check the host.” And an advertised version can lag the true patch state in either direction, so confirm with wp core version before you act on a finding.

Detecting XSS2Shell across your attack surface with Sn1per

One template against one URL is a spot check. The real problem is discovery: most organizations do not have a clean list of every WordPress instance they expose. A marketing microsite, a forgotten staging blog, a subsidiary’s WordPress on a domain acquired two years ago. Those are the hosts that miss the automatic update and quietly sit on 7.0.2. Finding them is an attack-surface problem before it is a scanning problem.

Sn1per runs discovery and detection as one engine. It enumerates the surface (subdomains, live hosts, services, technologies), fingerprints what is running, and runs its curated Nuclei set, including this template, against everything it finds, into a persistent named workspace:

# Discover the surface, then run web detections into a persistent workspace
sniper -t example.com -m recon -w acme-wp
sniper -t example.com -m web   -w acme-wp

# Pull the findings back out via the JSON API (Sn1per Professional 2026)
curl -sk -H "X-API-Key: $SN1PER_API_KEY" \
  "https://sn1per.local/pro/api.php?action=vulnerabilities&workspace=acme-wp" | jq '.'

Alongside the Nuclei template we added three checks to Sn1per’s curated active set for this release. Two extract the actual Core version from the generator meta tag and the RSS generator, which Sn1per previously did not do at all: its WordPress templates detected that a site was WordPress but never which version. The third flags builds missing the August 6 release using the same content-oracle signature. Running the same command from cron or a CI/CD pipeline re-checks the surface on a schedule, the model we cover in continuous attack surface testing and automated penetration testing.

Because Sn1per is self-hosted and Docker-first, the scan and the resulting list of your unpatched WordPress sites never leave your perimeter. For an inventory of what you have not patched yet, that on-prem posture is the point.

Which Sn1per edition fits

Capability Community Edition Professional 2026 Enterprise
Curated Nuclei set incl. XSS2Shell template Yes Yes Yes
Discovery + detection engine Yes Yes Yes
Scale Single operator Up to 150 assets, single operator Near-unlimited, multi-operator
Web UI + scheduled scans No Yes Yes
JSON API + SIEM / ticketing export No Yes Yes
Multi-workspace / multi-team No No Yes
Self-hosted / on-prem Yes Yes Yes

Sn1per Community Edition is free and open-source and ships the same detection engine, so a spot check across a handful of hosts costs nothing. Sn1per Professional 2026 adds the web UI, scheduled scans, and the JSON API for teams that want continuous coverage and findings routed into a SIEM or ticket queue; live pricing is on the shop. Sn1per Enterprise scales to near-unlimited assets with multi-operator, multi-workspace management for MSSPs and large security teams (custom quote). If you are weighing the two paid tiers, our Professional vs Enterprise comparison breaks it down.

Remediation and mitigation

1. Patch. This is the only real fix. Update Core to your branch’s fixed release from the table above. WordPress has shipped automatic background updates for minor releases by default since 3.7, so much of the population will self-patch without intervention. Verify rather than assume: auto-update is commonly disabled by managed hosts that pin versions, by the AUTOMATIC_UPDATER_DISABLED and WP_AUTO_UPDATE_CORE constants, by filesystem permissions, and on any install deployed from a Git checkout or a container image. Containerized WordPress does not auto-update at all; an image built before August 7 stays vulnerable until you rebuild it.

2. If you cannot patch immediately, break the escalation rather than the XSS. Setting DISALLOW_FILE_MODS blocks the plugin upload the chain terminates in, and disabling Application Passwords removes the credential the chain steals. Neither removes the cross-site scripting, and neither is a substitute for patching, but both cut the path to PHP execution.

3. Do not rely on a WAF rule that keys on < in the log parameter. A WAF-bypass repository targeting this specific chain was published on August 9, three days after the patch. Signature rules against a parser differential are exactly the kind of control that gets bypassed.

4. Administrator hygiene is a real control here. Because the chain requires an authenticated admin to open a hostile page, ordinary anti-phishing discipline genuinely reduces risk: do not browse untrusted links from a session that is logged into wp-admin, and keep admin accounts separate from day-to-day browsing.

A warning about one widely-visible “exploited in the wild” claim

If you triage this CVE through a feed aggregator, you may see it flagged as exploited in the wild, with text describing attacks beginning August 2 that deploy a ransomware strain called StormEncryptor. That attribution is wrong, and it is the most likely way a defender gets misled about this vulnerability right now.

StormEncryptor is deployed by Storm-1175, which Microsoft Threat Intelligence assesses as likely exploiting CVE-2026-18577, an authentication bypass in N-able N-central. That is an MSP remote-monitoring product with no relationship to WordPress. CVE-2026-18577 is in CISA’s KEV catalog, added August 3, though note that KEV itself marks its ransomware-campaign use as unknown, so the ransomware linkage comes from Microsoft rather than from CISA. CVE-2026-64638 is not in KEV at all; we checked the catalog directly on August 10 (catalog version 2026.08.10, 1,662 entries) and it returns zero hits.

The timeline also rules it out. XSS2Shell was privately reported, patched on August 6 and disclosed on August 7. Attacks could not have started on August 2 against a bug that was not public for another five days. The same aggregator page shows an EPSS score under 1%, contradicting its own badge.

The accurate status as of August 10, 2026: no confirmed in-the-wild exploitation, no mass-scanning telemetry, and no KEV listing. That said, roughly two dozen public proof-of-concept repositories appeared within 72 hours of disclosure, several of them full chains. The researchers’ restraint in withholding the exploit lasted less than a day. Treat the clock as running even though the sirens are not.

Frequently asked questions

What is XSS2Shell? XSS2Shell is the name for CVE-2026-64638, a pre-authentication reflected cross-site scripting flaw on the WordPress login screen. The username submitted to /wp-login.php was reflected unescaped into the failed-login error message. It can be chained to PHP code execution, but only against an administrator who is logged in and visits an attacker-controlled page.

Which WordPress versions are affected by CVE-2026-64638? Every branch from 4.7.0 through 7.0.2 received the fix on August 6, 2026, across 24 separate releases. The current-branch fix is 7.0.3, and the others are listed in the table above. The exploitable range is narrower than the patched range: only 6.4.0 through 7.0.2 can actually be exploited, because the KSES step the chain depends on only reaches the login screen from 6.4.

Is CVE-2026-64638 being exploited in the wild? No confirmed exploitation as of August 10, 2026, and it is not in the CISA KEV catalog. Be careful with feed aggregators: at least one incorrectly badges this CVE as exploited by attributing a StormEncryptor ransomware campaign to it, when that campaign is tied to CVE-2026-18577 in N-able N-central. Multiple public proof-of-concept exploits do exist.

Is CVE-2026-64638 a pre-auth RCE? The XSS is genuinely pre-authentication and needs no account, so “unauthenticated XSS to RCE” is fair. “Zero-click RCE” is not: escalation requires a logged-in administrator to visit an attacker-controlled page, which is why the CVSS 4.0 vector carries UI:A and the score is 8.9 High. It also requires the site to be served over HTTPS, because Application Passwords are unavailable otherwise. This is materially lower urgency than wp2shell (CVE-2026-63030), which needed no interaction and is in CISA KEV.

What is the fixed version for WordPress 5.9? 5.9.15, not 5.9.14. WordPress released 5.9.14 on August 6 and replaced it the same day because of a packaging problem. The WordPress stable-check API still reports 5.9.14 as insecure, so only 5.9.15 is a valid patched state on that branch.

Is the XSS2Shell Nuclei template safe to run in production? Yes. It is GET-only and sends no login POST, no XSS payload and no exploit traffic. It reads one static Core JavaScript file plus public version markers. There is deliberately no “benign marker” login probe, because esc_html() is a no-op on harmless input, so patched and unpatched hosts return identical output; the only input that discriminates is the exploit itself.

XSS2Shell is a good reminder that two correct-looking sanitizers can combine into a vulnerability neither one has on its own. It is also a reminder that the sites which stay vulnerable are rarely the ones you are thinking about: they are the WordPress instances nobody remembered they had, on branches nobody realized were still in production. Patch what you know about today, then use the template above, or Sn1per across your whole surface, to find the rest.

Try it free

See your attack surface like a pentester would.

Sn1per finds, ranks, and exploits real vulnerabilities autonomously — the same way attackers do.