WP2Shell: Pre Authentication RCE in WordPress Core

Advanced RCE 12 min read

CVE-2026-63030 and CVE-2026-60137 form an unauthenticated exploit chain against WordPress 7.0.1. A malformed member in the REST batch endpoint shifts route matches away from the requests they belong to, allowing a scalar value to reach a SQL injection in WP_Query. The chain turns control over the returned query rows into an administrator account, which can then install PHP code through WordPress.

How it works

1 The lab

Every case takes a real Uphack lab and walks the exact steps that find its flaw, one at a time. The lab itself is waiting at the end.

2 The panel

It plays like a video, except you drive it: scroll, and the panel beside the text walks the screens, the traffic and the code down to the exact line that fails.

3 The evidence

Every screen, request and line of code is taken from the running lab, down to the line numbers. Nothing here is illustrative.

01

What the two CVEs are

Surface

The target runs stock WordPress 7.0.1 and is affected by two core issues. CVE-2026-63030 causes route confusion in the REST batch endpoint, while CVE-2026-60137 allows a scalar value to reach a SQL injection in WP_Query.

The route confusion lets a request bypass the REST schema that normally blocks the injectable value. The resulting chain creates an administrator account without authentication, and from there reaches code execution on the host.

The full chain affects 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1. The SQL injection alone also affects 6.8.0 through 6.8.5, but the complete chain needs both CVEs on the same branch.

WordPress fixed the affected branches in 7.0.2, 6.9.5 and 6.8.6. This target runs stock 7.0.1 on MariaDB, with no plugins or unusual database privileges. Both REST components are enabled by default, so the exploit does not require additional configuration.

02

The registration policy

Surface

The General Settings page shows that Membership: anyone can register is disabled. WordPress enforces this setting through its normal registration paths: wp-login.php?action=register redirects to the sign-in form, and POST /wp/v2/users rejects an anonymous request.

The same page sets New User Default Role to Subscriber. If registration were enabled, a new account would receive limited permissions such as commenting and editing its own profile.

These settings define how WordPress handles registration. The exploit does not change them or use a registration endpoint; it reaches user creation through a different REST route.

03

The filter and its schema

Evidence

The posts route takes an author_exclude filter. Mina is user 2 and wrote both published posts, so excluding her returns an empty array.

Supplying SQL in the same parameter returns 400 rest_invalid_param. The error identifies the failed check: author_exclude[0] is not of type integer.

The posts route declares author_exclude as an array of integers. WordPress validates the request against that schema before calling WP_Query, so the direct injection attempt never reaches the vulnerable query code.

04

The batch endpoint

Evidence

The REST index at GET /wp-json/ is available without authentication. On this installation it describes 129 routes, including their methods, arguments and validation rules.

The index confirms that the integer-array check belongs to the posts route. It also exposes the structure of the batch endpoint used in the next stage.

The /batch/v1 route accepts up to twenty-five virtual requests. Each member supplies a method, path and optional body, and WordPress returns the corresponding responses in the same order.

The batch route has no permission callback of its own because each nested route performs its own checks. Its handler therefore depends on every request remaining aligned with the route match and validation result created for it.

05

How the arrays lose alignment

Mechanism

In WordPress 7.0.1, serve_batch_request_v1() processes the same list three times. It first parses the requests, then matches and validates them, and finally executes them. The vulnerability appears when those passes stop referring to the same member at the same index.

During the first pass, WordPress sends each member's path to wp_parse_url(). If parsing fails, the handler stores a WP_Error in $requests and continues with the remaining members.

Continuing after one malformed member is reasonable for an endpoint designed to return partial results. The problem appears in the collections built during the next pass.

The second pass builds $matches and $validation alongside $requests. For each parsed request, $matches stores its route and handler while $validation stores either true or an error.

A parse failure is added to $validation, but continue skips the append to $matches. From that point onward, $matches is shorter than the other arrays and its entries refer to later requests.

The execution pass uses the request index $i to read both $validation[ $i ] and $matches[ $i ]. The validation array still has one entry for every request, including parse failures, but the matches array does not.

A parsed request can therefore be validated against its own route and then executed with the route and handler stored for a later member.

06

Nested batches and the first SQL proof

Proof

The outer batch contains three members. Its first path is http://:, which has an empty host and port, so wp_parse_url() rejects it.

$requests now contains three entries while $matches contains two. The second member is validated as /wp/v2/posts, but $matches[1] contains the third member's match for /batch/v1. WordPress therefore executes the second request through the batch handler.

The second member contains another requests array, so the batch handler processes a nested batch and the same index shift occurs again. That second shift is what the exploit needs, because the outer one only redirects a member to the batch route. The inner one redirects a member to a route that reaches the database.

The inner batch starts with another invalid path, followed by a DELETE request whose body contains a string-shaped author_exclude, and then GET /wp/v2/posts. WordPress validates the DELETE against the delete-a-post schema, which does not define author_exclude. The shifted match then sends that request to the posts-list handler, where the body parameter reaches WP_Query as a scalar string.

The response confirms the route confusion without requiring the source code. The outer member names POST /wp/v2/posts, which normally returns a post object, but the server returns 207 Multi-Status with a nested responses array.

That response shape belongs to /batch/v1, showing that WordPress executed a different handler from the route named by the request.

The direct request with author_exclude=2 returned an empty array. Through the confused batch route, the response includes both of Mina's posts and About Canal Street Press, which is a page that the posts route would not normally return.

The injected value produces AND wp_posts.post_author NOT IN (2) OR 1=1 -- ). The comment removes the remaining WHERE conditions, including the restrictions on publication status and post type, which explains why the page appears in the result.

This proves unauthenticated SQL injection, but the injection remains inside a SELECT built by WP_Query, on a path that does not allow stacked queries. It can change the rows WordPress receives, but it cannot write to the database directly.

The query code shows why the scalar reaches SQL unchanged. WP_Query applies absint() only when author__not_in is already an array. A scalar skips that branch, is cast to an array later, and reaches implode() as attacker-controlled text inside NOT IN ( … ).

The adjacent author__in branch normalizes values again while building its list, so it does not make the same assumption about the original input shape.

07

Why a UNION of an ID alone fails

Mechanism

The obvious next move is to UNION a fake post ID into the result and see whether WordPress renders it. Injecting ID 999 returns nothing.

The reason is an optimization. For small result sets WP_Query runs two queries: SELECT ID FROM wp_posts … to collect matching IDs, then SELECT * FROM wp_posts WHERE ID IN (…) to fetch the rows. The injected ID enters the first query, the second finds no row to go with it, and the ID is discarded.

08

Forging a complete post row

Mechanism

The split-query optimization has a threshold. Above it, WordPress skips the two-query path and runs a single SELECT wp_posts.* FROM wp_posts … that returns complete rows, which a UNION can append to.

per_page: 501 pushes it over that threshold. The REST schema caps per_page at 100, but the desync means the schema for this route never runs, so the value arrives unchecked.

orderby: "none" solves a separate problem. WordPress normally adds ORDER BY wp_posts.post_date DESC. Once the query includes a UNION, MariaDB rejects that qualified column reference, so the payload removes the ordering.

The injected SELECT must match all 23 columns in wp_posts. WordPress renders the result with an attacker-controlled ID, title and content even though that row does not exist in the database.

At this point, the attacker can supply forged rows to the code that processes the query result, rather than only changing which real rows match the query.

09

Object cache poisoning and sparse updates

Mechanism

WP_Query does not hand back raw rows. It hydrates each one into a WP_Post object and stores it in the request-local object cache, keyed by post ID. For the rest of that PHP request, get_post( 900002 ) returns the forged object without touching the database, but the object disappears when WordPress sends the response.

wp_update_post() is what makes it persistent. It is a sparse update: a caller passes only the fields it wants to change, and WordPress loads the current post to fill in the rest and merges the changes over it.

That load is get_post(), which reads the cache. If the cache holds a forged object, the merge can persist its attacker-controlled fields alongside the change the caller intended to make.

10

Request one: three real rows

Proof

wp_update_post() still needs an existing database row. Without one, the forged object remains in memory and disappears with the response, which is why the exploit uses two HTTP requests.

The first request renders a forged post containing three local [embed] shortcodes. WordPress resolves a relative embed such as /?p=910005 locally and does not check that the referenced post exists. It then stores each generated preview as a real oembed_cache row.

Three rows land at IDs 7, 8 and 9. Auto-increment makes those IDs predictable on a clean install, which is also why sending extra requests between the two steps breaks the exploit.

11

Request two: six forged rows

Proof

The second request forges six rows through the UNION chain. Three are supporting rows: T carries the [embed] shortcode WordPress renders, S is the local post referenced by that embed, and P connects the changeset to a second parent cycle.

The remaining three rows replace the oEmbed cache objects created by the first request. O at ID 7 is empty, which makes WordPress refresh it. C at ID 8 is a forged Customizer changeset, while D at ID 9 carries post_status=parse and post_type=request.

These objects still exist only in the request-local cache. The next stages persist them by passing them through normal oEmbed, hierarchy and Customizer code paths.

12

The changeset publishes as an administrator

Proof

Rendering T triggers the embed lookup, which finds the empty cache entry O and calls wp_update_post( 7 ) to refresh it.

Updating row 7 runs WordPress's parent-hierarchy check. O points to parent 8, and C at 8 points to itself, so WordPress detects the cycle and repairs it with wp_update_post( 8, [ 'post_parent' => 0 ] ). That is a sparse update over a poisoned object, so the forged changeset type, status, content and date are written into real row 8.

Row 8 is now a genuine customize_changeset in the database, with status future and a date in the year 2000. WordPress treats it as an overdue scheduled change and publishes it.

During publication, WordPress reads the user_id stored in the changeset JSON and temporarily switches to that user. The forged changeset names user 1, the existing administrator, so the remaining callbacks run with administrator permissions.

13

The replay, and the account it creates

Proof

A second parent-cycle repair persists row D through the same sparse-update behavior. WordPress combines its forged post_status=parse and post_type=request values into the dynamic hook name parse_request.

When WordPress fires that hook, a registered callback re-enters the REST server and replays the batch while the administrator context from the changeset publication is still active.

The same member runs twice with different outcomes. On the first dispatch, the anonymous POST /wp/v2/users returns 401. The replay sends the same request while WordPress holds the temporary administrator identity, so it returns 201.

The outer response contains five members: three route errors, the nested 207 response from the inner batch, and a 201 Created response containing the new account.

Before the exploit runs, the site has two accounts: siteadmin, the administrator, and Mina, an author.

After the exploit, wp2shelllab appears as a third user with the Administrator role, along with an email address, profile and post count.

The Membership setting remains disabled because the exploit did not use the registration flow. WordPress created the account through its REST users route while the temporary administrator identity was active.

14

From administrator to code execution

Proof

On a default WordPress installation, an administrator can upload and activate plugins. Because plugins contain PHP that runs in the web worker, the new account provides a direct route to code execution without another vulnerability.

The proof plugin registers one handler, checks that the current user has manage_options, and executes /usr/bin/id. The response shows that the command ran as www-data, the WordPress web worker user.

15

How WordPress fixed the chain

Remediation

WordPress 7.0.2 adds the failed member to $matches as well as $validation. Every input member now contributes one entry to each derived array, preserving their length and order.

The existing execution loop already checks whether a match is a WP_Error, so no downstream change is required. Once the arrays remain aligned, the error is handled for the request that produced it.

The same release routes author__not_in through wp_parse_id_list(). The function accepts a scalar, array or comma-separated list and always returns integers, so normalization no longer depends on the input's original shape.

The REST schema still rejects invalid requests at the controller boundary. The change in WP_Query also protects callers that reach the query code through another path.

WordPress 7.0.2 also prevents serve_request() from starting a second top-level REST cycle while another request is being dispatched.

The route confusion and SQL injection do not require this behavior, but the later replay stage does. It re-enters the REST server from inside the current request while holding administrator authority. I find this guard particularly useful because it blocks that replay technique beyond the two reported code paths.

The patch keeps /batch/v1 available without authentication and continues to let each nested route enforce its own permissions. Those properties were not responsible for the confusion.

The relevant fix is consistent association between a request and the metadata derived from it. Storing the request, route match and validation result in one record would remove the parallel indexes entirely; the shipped patch restores the same association by appending to every array on every path.

canalpress.example

Based on a real lab.

Every screen and request above came from the live app. In the lab you do the whole thing yourself, hands-on, with guidance along the way.

Advanced RCE ~45 min