WP2Shell
Wordpress Core unauthenticated RCE
A technical walkthrough of the wp2shell chain, where two small bugs in the WordPress REST API turn into unauthenticated remote code execution on a default install. Original discovery by Adam Kues and Patrik Grobshäuser at Assetnote. The proof of concept discussed here was built with Claude.
Credit first, because it matters. This chain is the work of Adam Kues and Patrik Grobshäuser at Assetnote, the research arm of Searchlight Cyber. They named it wp2shell, reported it to WordPress, and put a checker up at wp2shell.com. This post is a technical breakdown of how the chain works, based on a proof of concept built with Claude to understand each step.
The short version: an unauthenticated attacker reaches remote code execution on a completely default WordPress install. No plugins, no non default config, no credentials. Just one published post, which every install has by default thanks to good old "Hello World".
Worth calling out up front: most public PoCs at this time lean on a non default MySQL configuration, for example a setup that allows writing files to disk from the database. This chain does not. It works against a 100% default WordPress and MySQL configuration, which is exactly what makes it dangerous at scale.
Affected versions are WordPress 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1. It was patched in 6.9.5 and 7.0.2 on July 17, 2026. If you run WordPress, update first, then read on.
Fair warning, this one is long. The chain has a lot of moving parts.
Proof of Concept is hosted on Github: https://github.com/yoerivegt/wp2shell-poc
The two ingredients
The chain starts with two vulnerabilities that feed into each other.
The first (CVE-2026-63030) is an array misalignment in the REST batch processor. It lets a sub request get dispatched through the wrong route handler. The second (CVE-2026-60137) is a SQL injection in WP_Query that only becomes reachable because of that misalignment.
On their own, one is a logic slip and the other is unreachable. Together they produce a pre auth SQL injection, and that is the foothold the rest of the chain builds on.
Bug 1: the REST batch array misalignment
The batch endpoint lives at /wp-json/batch/v1. It takes a list of sub requests and processes them together. Internally this happens in two phases: first it matches every sub request to a route handler, then it dispatches them.
Here is the route matching phase, simplified:
// Route-matching phase (simplified)
foreach ($requests as $single_request) {
if (is_wp_error($single_request)) {
$has_error = true;
$validation[] = $single_request;
continue; // <-- $matches[] is NOT appended
}
$match = $this->match_request_to_handler($single_request);
$matches[] = $match; // <-- sequential append
// ... validation ...
}
Note the continue. When a sub request path fails wp_parse_url(), it is recorded as a WP_Error and the loop skips ahead. It appends to $validation[] and then bails out before appending to $matches[]. So $matches ends up one entry shorter than $requests.
Now the dispatch phase:
// Dispatch phase
foreach ($requests as $i => $single_request) {
if (is_wp_error($single_request)) {
// ... handle error ...
continue;
}
// ...
$match = $matches[$i]; // <-- MISALIGNED: request index into shorter matches array
}
The dispatch loop walks $requests by index $i and reaches into $matches with that same $i. But $matches is missing the failed entries. Every failed parse shifts all following matches by one, so request number $i gets dispatched using the handler that was matched for request $i+1.
In other words, one malformed path acts as a desync primer. It bumps the whole handler list out of alignment, and the sub request right after it runs through the wrong route.
Here is what that looks like:
Batch sub-requests: Route matched: Dispatched as:
───────────────────── ────────────── ─────────────────
[0] POST http://: (WP_Error, skipped) → error response
[1] GET /wp/v2/widgets $matches[0] = widgets → $matches[1] = posts handler
[2] GET /wp/v2/posts $matches[1] = posts → $matches[2] = next handler
Sub request [1] asks for /wp/v2/widgets, but thanks to the shift it actually runs through the /wp/v2/posts handler. The reason widgets and posts matter specifically is bug number two.
Bug 2: the SQL injection hiding in author__not_in
The posts collection has a parameter called author_exclude. The posts schema defines it strictly as an array of integers, so normally it is safe. WordPress runs every element through absint().
Here is the relevant code in WP_Query::get_posts():
if (!empty($query_vars['author__not_in'])) {
if (is_array($query_vars['author__not_in'])) {
// Array path: each element gets absint() — SAFE
$query_vars['author__not_in'] = array_unique(array_map('absint', $query_vars['author__not_in']));
}
// String path: cast to (array) wraps the whole string as one element — NO SANITIZATION
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
Look at the is_array() guard. The absint() sanitization only runs when the value is an array. If author__not_in arrives as a plain string, the guard fails, the string skips sanitization entirely, and it lands inside a NOT IN (...) SQL clause with no parameterization and no escaping.
Getting a string in there when the schema demands an array is where the two bugs meet. The widgets endpoint has author_exclude in its schema too, but the widgets schema is looser and accepts it as a raw string. So a widgets sub request carries author_exclude as a string, passes the widgets validation because that is legal for widgets, and then the batch misalignment dispatches it through the posts handler. The posts handler reads author_exclude, maps it to WP_Query's author__not_in, and drops the raw string into SQL.
The validation checked one schema. The dispatch used a different handler. That is the whole trick.
Since the value lands inside NOT IN (...), closing the parenthesis lets arbitrary SQL follow:
author_exclude=999) UNION ALL SELECT ... -- -
That is a pre authentication SQL injection on a stock WordPress install.
From SQLi to admin
A read only SQL injection is already serious, since it exposes the whole database. Reaching RCE needs an administrator account, without knowing any existing credentials. The chain builds one by making WordPress attack itself, using its own object cache, oEmbed system, Customizer, and a hook that collides with the REST router.
A quick primer first. WordPress keeps a per request object cache. When WP_Query runs, it stores the rows it read so later calls to get_post($id) in the same request do not hit the database again. That cache is what gets poisoned below.
Step 1: seeding real database rows with oEmbed
A UNION injection can invent fake rows in query results, but those fakes only live in the object cache. Several steps of the chain need to write to real database rows, so the chain first creates a few real rows with known IDs.
WordPress is its own oEmbed provider. If a post contains an [embed] shortcode pointing at a URL on the same site, WordPress fetches that URL over loopback and caches the result as an oembed_cache post, which is a real row in wp_posts.
So a trigger post is injected whose content holds three embed shortcodes pointing back at the site, each with a different fragment so they get unique cache keys:
[embed width="500" height="750"]http://target/hello-world/#nonce0[/embed]
[embed width="500" height="750"]http://target/hello-world/#nonce1[/embed]
[embed width="500" height="750"]http://target/hello-world/#nonce2[/embed]
Rendering that post through the REST API with context=view makes WordPress process the shortcodes, producing three fresh oembed_cache rows. Their IDs are pulled back out with the SQL injection, and each gets a role in the "poison graph":
| Role | Post ID | Purpose |
|---|---|---|
changeset_id |
oEmbed cache #1 | Real row that gets overwritten with a fake customize_changeset |
cache_id |
oEmbed cache #2 | Entry point, its oEmbed update kicks off the cascade |
request_id |
oEmbed cache #3 | Real row that gets overwritten with post_type=request, post_status=parse |
Step 2: poisoning the object cache
A second batch request injects seven fake wp_posts rows via UNION ALL SELECT, each with the full 23 column schema. When WP_Query processes the UNION results, WordPress calls update_post_cache(), which calls wp_cache_add_multiple(), and every fake row gets stored in the object cache under the posts group. From that point on, any get_post($id) for those IDs returns the fake data instead of querying the database.
The important properties:
changeset_idandouter_idform one circular parent chain.changeset_idis a real oEmbed row,outer_idis a fake high ID that only exists in cache.request_idandinner_idform a second circular parent chain.request_idis also a real oEmbed row.cache_idpoints its parent atchangeset_id, linking the oEmbed update path to the first loop.nav_item_idpoints its parent atrequest_id, linking the changeset save path to the second loop.changeset_idis typed ascustomize_changesetwith statusfuture, and its content holds a Customizer payload with anav_menu_itemsetting whoseuser_idis the real administrator's ID.request_idis typed asrequestwith statusparse.
Two circular chains, two triggers, one goal.
Step 3: springing the first hierarchy loop
The trigger post's second embed maps to cache_id. When WordPress processes that cached embed, it calls wp_update_post(cache_id) to refresh the timestamp.
wp_update_post() reads the post from the poisoned cache and sees post_parent=changeset_id. Inside wp_insert_post() the wp_insert_post_parent filter fires, which runs wp_check_post_hierarchy_for_loops(). That walker follows the parent chain through the poisoned cache:
cache_id → parent: changeset_id → parent: outer_id → parent: changeset_id → LOOP DETECTED
Here is the key behavior. WordPress fixes the loop by calling wp_update_post() on every member to zero out its parent:
foreach (array_keys($loop) as $loop_member) {
wp_update_post(array('ID' => $loop_member, 'post_parent' => 0));
}
So the loop breaker calls wp_update_post(changeset_id). It reads the poisoned cache, sees post_type=customize_changeset and post_status=future, and because changeset_id is a real database row, the UPDATE sticks. WordPress then computes the effective status. Because post_date is set to 2020-01-01, which is in the past, the future status silently flips to publish:
if ('future' === $post_status) {
if (time() > strtotime($post_date_gmt)) {
$post_status = 'publish';
}
}
That produces a genuine future to publish transition on a Customizer changeset. WordPress believes a scheduled changeset came due.
Step 4: the admin identity switch through the Customizer
A future to publish transition on a changeset fires _wp_customize_publish_changeset(). This function reads the changeset content (poisoned cache again), finds the nav_menu_item setting with the administrator's user_id, and starts publishing settings. Here is what it does for each one:
$original_user_id = get_current_user_id();
foreach ($changeset_setting_ids as $setting_id) {
$setting = $this->get_setting($setting_id);
if ($setting) {
if (isset($setting_user_ids[$setting_id])) {
wp_set_current_user($setting_user_ids[$setting_id]); // ← SWITCH TO ADMIN
}
$setting->save();
}
}
wp_set_current_user($original_user_id); // ← RESTORE (too late if re-entry happened)
wp_set_current_user() switches the running PHP process to the targeted administrator before saving the setting. This is intentional, since it preserves the original author's capabilities during a scheduled publish. But this is happening inside a live unauthenticated request, and the process is now running as admin. The restore only fires after $setting->save() returns, so the goal is to never let it return until the damage is done.
Step 5: springing the second loop mid save
When $setting->save() processes the nav_menu_item setting, it calls wp_update_nav_menu_item(), which calls wp_update_post(nav_item_id).
The poisoned cache says nav_item_id's parent is request_id, the hierarchy check runs, and it hits the second loop:
nav_item_id → parent: request_id → parent: inner_id → parent: request_id → LOOP DETECTED
The loop breaker calls wp_update_post(request_id). Real row again, so the write sticks, and request_id becomes post_type=request, post_status=parse. All of this while the process still holds the admin identity, because $setting->save() has not returned yet.
Step 6: the hook collision that re-enters the REST API
This is the piece that makes the whole thing click. After that update, WordPress fires its dynamic status transition hook:
do_action("{$new_status}_{$post->post_type}", $post->ID, $post);
Plug in the values. $new_status is parse and post_type is request, so this becomes:
do_action('parse_request', $post_id, $post);
parse_request is not an obscure custom hook. It is a core routing action, and rest_api_loaded() is registered on it:
function rest_api_loaded() {
if (empty($GLOBALS['wp']->query_vars['rest_route'])) {
return;
}
$server = rest_get_server();
$route = untrailingslashit($GLOBALS['wp']->query_vars['rest_route']);
$server->serve_request($route);
die();
}
The request is already inside a REST call, so rest_route is still set to /batch/v1. rest_api_loaded() sees it and calls serve_request() again. That is a nested REST dispatch, running inside the first one, still carrying the administrator identity because the restore in step 4 has not fired.
A post type and status whose names collide into a core hook made WordPress re enter its own router.
Step 7: creating the administrator
The nested dispatch re processes the batch, including the tail sub requests stuffed at the end. One of those is a plain POST /wp/v2/users asking for an administrator role.
Under the nested dispatch, current_user_can('create_users') returns true, because the process is the admin. WordPress creates the account. Then the die() at the end of rest_api_loaded() kills the process before the identity restore can run, but the user is already in the database.
An unauthenticated request comes in, admin credentials come out.
Step 8: admin to shell
The rest is standard WordPress admin to RCE. With the new account the exploit logs into wp-login.php, uploads a plugin zip containing a token gated webshell, runs OS commands as the web server user, then removes the plugin directory to clean up.
PoC
The full chain running end to end:
$ python3 wp2shell.py http://target:9993 --cmd "id"
Phase 1: Confirm Pre-Auth SQL Injection
[+] SQL injection confirmed (union mode)
Phase 2: Extract DB Info + Seed oEmbed Cache
[+] Table: `wp_posts` (prefix: `wp_`)
[+] Admin ID: 1
[+] Backing IDs: changeset=5, cache=6, request=7
Phase 3: Cache Poisoning → Admin User Creation
[+] Batch returned HTTP 207
Phase 4: Login + RCE
[+] Authenticated!
[+] Webshell deployed
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Proof of Concept is hosted on Github: https://github.com/yoerivegt/wp2shell-poc
Tested and confirmed on:
| Environment | PHP Version | WordPress Version | Result |
|---|---|---|---|
| Debian (Apache) | PHP 7.4.33 | WordPress 6.9 | RCE as www-data |
| Debian (Apache) | PHP 8.3.32 | WordPress 6.9 | RCE as www-data |
What the chain needs to work
Not much, which is the concerning part.
- WordPress 6.9.0 to 6.9.4 or 7.0.0 to 7.0.1, unpatched
- Default configuration, no extra plugins or hardening
- A MySQL backend, since the
UNION ALL SELECTneeds column count parity withwp_posts - At least one published post, and the default "Hello World" post is enough
- The server able to reach itself over loopback for the oEmbed fetch, which is the default basically everywhere
Note the difference from most public PoCs floating around at this time. Those typically require a non default MySQL setup, for example one that permits writing files to disk (FILE privilege, secure_file_priv, and so on) to drop a webshell straight from the database. This chain needs none of that. It escalates purely through WordPress's own object cache and hook behavior, so a stock WordPress on a stock MySQL is enough.
Impact
An unauthenticated remote attacker can create an administrator account and run arbitrary commands as the web server user. That is full compromise of the WordPress site, and depending on the host, potentially the server it runs on. CVSS 9.8, network reachable, low complexity, no privileges required.
Root cause
The trigger is one tiny data structure mismatch. In serve_batch_request_v1(), the $requests, $matches, and $validation arrays grow at different rates when a sub request fails URL parsing. The continue skips the $matches[] append but not the $validation[] one, so request indices and handler indices drift apart.
That single off by N shift breaks the whole security model of the batch endpoint. Every sub request is validated against its own route's schema, but if it then gets dispatched through a different route's handler, that validation means nothing. A widgets request passes widgets validation, then runs through the posts handler and drops an unsanitized string into WP_Query.
The jump from SQL injection to RCE leans on four WordPress behaviors that are each reasonable on their own:
- UNION injected rows poison the object cache, because
update_post_cache()uses add semantics and the fakes survive the rest of the request. - Hierarchy loop detection calls
wp_update_post()on loop members, which reads the poisoned cache and writes attacker data to real rows. - Changeset publishing calls
wp_set_current_user()before saving each setting, which is intentional but dangerous when triggered mid request. - Dynamic hooks can collide with core hooks. The
{$status}_{$post_type}pattern letspost_status=parsepluspost_type=requestfire the coreparse_requestaction, which re enters the REST dispatcher.
None of these is a vulnerability by itself. Chained, they are a pre auth RCE. The scariest bugs are rarely one big hole, they are a handful of sensible decisions lined up in exactly the wrong order.
Remediation
If you run WordPress, the fix is boring and effective: update to 6.9.5 or 7.0.2. The patch realigns the batch arrays so a failed sub request no longer shifts the handlers of the ones after it, which shuts the front door and takes the entire chain with it.
For anyone maintaining similar code, two takeaways. Never index one array with the loop counter of another array that can grow at a different rate. And never assume a value validated against one schema will be consumed by the handler you expected, especially in a batching or routing layer.
Outro
This chain starts with a bug you could miss in a code review in half a second, a single misplaced continue, and it ends with WordPress logging itself in as admin and running commands. Object cache, oEmbed, the Customizer, and a hook name collision, all turned into weapons.
Full credit for the discovery goes to Adam Kues and Patrik Grobshäuser at Assetnote and Searchlight Cyber.
Disclaimer: this post was written by Claude, and the proof of concept was built with Claude, based on the original wp2shell discovery by Adam Kues and Patrik Grobshäuser at Assetnote and Searchlight Cyber.