Skip to content
black laptop computer turned on on table

wp2shell for Plugin Developers: The Two Anti-Patterns Behind a WordPress Core RCE

The WordPress core vulnerability chain known as wp2shell, tracked as CVE-2026-63030 and CVE-2026-60137, combined two relatively ordinary programming mistakes into an unauthenticated remote code execution chain.

The first mistake caused requests within the WordPress REST API batch endpoint to become associated with the wrong route handlers. The second allowed an unexpected parameter type to reach WP_Query without adequate sanitisation.

Together, these flaws produced SQL injection. But the SQL injection did not directly insert an administrator into the database. It manipulated the results of a SELECT query, poisoned WordPress’s request-local object cache and triggered a complex sequence of legitimate core behaviours that ultimately caused an administrator-creation request to be reprocessed with elevated privileges.

The full exploit chain was unusual. The underlying programming mistakes were not.

For plugin developers, wp2shell highlights two important anti-patterns:

  1. Maintaining related values in parallel indexed collections without preserving their alignment on every code path.
  2. Sanitising a value only when it already has the expected type, then coercing unexpected types later.

The vulnerability chain at a glance

WordPress’s REST API includes a batch endpoint:

/wp-json/batch/v1

This endpoint allows a client to bundle several REST sub-requests into one HTTP request.

The vulnerable batch-processing code maintained several related arrays, including:

  • Parsed request objects
  • Matched route handlers
  • Validation results

When every sub-request parsed successfully, their numerical indexes remained aligned. Request 0 corresponded to match 0, request 1 to match 1, and so on.

The problem appeared when one batch member failed to parse.

The failed request was retained in the requests collection as a WP_Error, but no corresponding placeholder was added to the matches collection. Every subsequent match then shifted one position relative to its request.

During dispatch, the server used the request’s numerical index to retrieve a handler from the now-shorter matches array. A valid request following the error could therefore be executed using the route handler, permission callback and schema belonging to a different request.

This handler confusion allowed an attacker-controlled request to reach the posts controller without having been validated against the posts controller’s schema.

One affected query parameter was author_exclude, which the REST posts controller maps internally to the WP_Query argument author__not_in.

Under normal routing, author_exclude is validated as an array of integers. Because of the batch-handler misalignment, an unexpected scalar value could reach WP_Query without that validation.

The vulnerable query logic sanitised author__not_in only when it was already an array. A scalar skipped the sanitisation step, but was later cast to an array and joined directly into a SQL NOT IN (...) clause.

That created the SQL injection primitive used by the remainder of the wp2shell chain.

Anti-pattern #1: parallel arrays that silently lose alignment

The first wp2shell vulnerability was not fundamentally a disagreement between two parsers. It was an index-synchronisation failure.

The vulnerable logic can be simplified as follows:

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $validation[] = $single_request;
        continue;
    }

    $matches[] = match_request_to_handler( $single_request );
}

foreach ( $requests as $index => $single_request ) {
    $match = $matches[ $index ];

    // The handler may belong to a different request.
    dispatch_request( $single_request, $match );
}

Suppose a batch contains three entries:

Request index 0: parsing error
Request index 1: request A
Request index 2: request B

The resulting arrays look like this:

$requests[0] = error
$requests[1] = request A
$requests[2] = request B

$matches[0] = handler A
$matches[1] = handler B

When the dispatch loop processes request A at index 1, it retrieves $matches[1], which is handler B.

The mismatch affects more than the execution callback. The matched handler tuple can also contain the route’s permission callback, argument schema and validation rules. As a result, request data can be checked under one set of assumptions and executed under another.

Why this pattern is dangerous

Parallel arrays create an implicit relationship through their numerical indexes. That relationship remains valid only if every operation affects every array in precisely the same way.

An early continue, filtered item, exception or conditional insertion can silently break the relationship.

This pattern can appear in plugins that separately store:

  • Requests and callbacks
  • Records and permissions
  • Uploaded files and validation results
  • Form fields and sanitisation rules
  • Jobs and ownership information
  • Actions and nonce results

If one collection omits an entry while another retains it, later code may apply security information belonging to one object to a different object.

Safer approach: keep related state together

Instead of maintaining separate arrays, represent each batch member as one structured record:

$batch_items = array();

foreach ( $raw_requests as $raw_request ) {
    $item = array(
        'request'    => null,
        'match'      => null,
        'validation' => null,
    );

    $item['request'] = parse_request( $raw_request );

    if ( is_wp_error( $item['request'] ) ) {
        $item['validation'] = $item['request'];
        $batch_items[] = $item;
        continue;
    }

    $item['match'] = match_request_to_handler( $item['request'] );
    $item['validation'] = validate_request(
        $item['request'],
        $item['match']
    );

    $batch_items[] = $item;
}

foreach ( $batch_items as $item ) {
    if (
        is_wp_error( $item['request'] ) ||
        is_wp_error( $item['validation'] ) ||
        empty( $item['match'] )
    ) {
        continue;
    }

    dispatch_request( $item['request'], $item['match'] );
}

Now the request, its handler and its validation result travel together. An error cannot shorten one collection without shortening the others.

If parallel collections are unavoidable, insert an explicit placeholder for every failed item and verify alignment before dispatch. Keeping the related state in one structure is generally safer.

Anti-pattern #2: sanitising only the expected type

The second flaw involved the WP_Query argument author__not_in.

The intended contract was straightforward: it should contain an array of integer author IDs.

The vulnerable logic effectively behaved like this:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $query_vars['author__not_in'] = array_map(
            'absint',
            $query_vars['author__not_in']
        );
    }

    $ids = implode(
        ',',
        (array) $query_vars['author__not_in']
    );

    $where .= " AND post_author NOT IN ($ids)";
}

An array was sanitised. A scalar was not.

The later cast to (array) did not make the scalar safe. It merely converted the unsanitised scalar into a one-element array that implode() would accept.

The important sequence was:

  1. The value arrived as an unexpected scalar.
  2. The is_array() condition prevented sanitisation.
  3. The scalar was later coerced into an array.
  4. The unsanitised value was interpolated into SQL.

This is a dangerous form of fail-open validation: the unexpected type bypasses the security operation but remains usable by downstream code.

Validate the type before using the value

Code should reject an invalid type or explicitly normalise it before use:

function normalise_author_ids( $author_ids ) {
    if ( ! is_array( $author_ids ) ) {
        return new WP_Error(
            'invalid_author_ids',
            'Author IDs must be supplied as an array.'
        );
    }

    return array_values(
        array_filter(
            array_map( 'absint', $author_ids )
        )
    );
}

The caller must then stop if normalisation fails:

$author_ids = normalise_author_ids( $author_ids );

if ( is_wp_error( $author_ids ) ) {
    return $author_ids;
}

When constructing a variable-length SQL list, use generated placeholders rather than directly joining values into the query:

if ( ! empty( $author_ids ) ) {
    $placeholders = implode(
        ',',
        array_fill( 0, count( $author_ids ), '%d' )
    );

    $sql = $wpdb->prepare(
        "SELECT *
         FROM {$wpdb->posts}
         WHERE post_author NOT IN ($placeholders)",
        ...$author_ids
    );
}

The broader rule is:

Never allow an unexpected type to skip validation and then recover its usability through implicit or explicit coercion.

Check both the type and the contents of security-sensitive values.

Why a SELECT injection became much more serious

The SQL injection did not directly execute shell commands or insert a WordPress administrator. It initially influenced a SELECT query against the posts table.

The escalation depended on WordPress object hydration and caching.

When WP_Query retrieves database rows, it converts them into WP_Post objects and places those objects in the request-local object cache. Later calls to get_post() can return the cached object rather than querying the database again.

By manipulating what the query returned, the attacker could cause WordPress to construct and cache WP_Post objects containing attacker-selected field values.

For the rest of that PHP request, other core code could retrieve those forged objects and treat them as legitimate posts.

This is an important security lesson: the result of a compromised read operation may later become trusted application state. A SELECT injection is not necessarily limited to data disclosure when query results are hydrated into objects, cached and subsequently passed into write operations.

The oEmbed write path

The exploit still needed a legitimate core mechanism to persist properties from a poisoned cached object.

WordPress’s oEmbed functionality provided such a path.

When WordPress refreshes stale oEmbed data, it can save a post using its ID and updated content while obtaining other post properties through get_post(). Because the object cache had been poisoned, get_post() could return the forged object.

The oEmbed refresh process could therefore write attacker-selected post properties—such as post status, type and parent—back into the database.

This persistence step also triggered the ordinary WordPress post-save lifecycle hooks required by the rest of the exploit.

Nested saves and temporary administrator identity

Persisting forged post data was not itself enough to create an administrator. The exploit needed two post-save effects to occur in a particular nested order.

One save caused WordPress to temporarily operate as an existing administrator. Another caused WordPress’s REST request handling to run again.

These actions had to overlap: the nested REST processing needed to occur while the temporary administrator identity was still active.

The chain used WordPress’s handling of invalid post-parent relationships to create nested saves. When poisoned posts formed a forbidden parent loop, WordPress attempted to repair the relationship by resaving affected posts. One save could therefore occur inside another.

The first significant save published a forged Customizer changeset. During changeset publication, WordPress temporarily switches to the user associated with the setting being published. Because the changeset object had been forged, the remembered user could be an administrator selected by the attacker.

For that limited portion of the request, WordPress operated as that administrator.

Re-entering the REST API

The second nested save manipulated a dynamic post-lifecycle hook.

WordPress constructs some hook names from a post’s status and type. By choosing forged values that combined into the name parse_request, the attacker could cause an ordinary post save to invoke the REST loader.

That happened while the temporary administrator identity from the outer changeset publication was still active.

The REST loader then processed request data again within the original PHP request and inherited the current administrator identity.

An administrator-creation request included in the original anonymous batch had initially failed because the caller lacked the create_users capability. When the request was processed again during this nested, elevated context, the capability check was re-evaluated and succeeded.

The attacker thereby obtained an administrator account without authenticating as an existing user.

Once administrator access had been established, the attacker could use WordPress’s plugin-installation functionality to install code and achieve remote code execution.

The multiplier: trusting poisoned objects and inherited execution context

The final escalation was not simply a privileged function forgetting to check a capability.

The user-creation endpoint did perform a capability check. The problem was that the request was evaluated again while WordPress’s current-user state had been temporarily changed to an administrator.

The more useful lesson for plugin developers is to be cautious about:

  • Treating cached objects as equivalent to freshly retrieved database records
  • Writing complete records based on partially supplied data
  • Triggering complex hooks while a temporary identity is active
  • Allowing nested operations to inherit mutable global security state
  • Constructing hook names dynamically from object-controlled properties
  • Re-entering request dispatch during an existing request

WordPress plugins frequently rely on global state, hooks and nested callbacks. Security-sensitive code should not assume that the current user, current post or cached object still represents the context that existed at the beginning of the request.

Practical takeaways for plugin developers

Keep related security state in one structure

Do not rely on matching indexes across independent collections of requests, handlers, permissions and validation results.

Fail closed on unexpected types

If a value must be an array, reject a scalar. Do not skip sanitisation and then cast the scalar into an array later.

Normalise at the point of use

Validate and normalise values immediately before they reach SQL, filesystem operations, dynamic callbacks or other sensitive sinks.

Use prepared SQL placeholders

For variable-length integer lists, generate %d placeholders and use $wpdb->prepare(). Do not directly interpolate an implode() result into SQL.

Treat query results as untrusted after query manipulation

Objects hydrated from database rows may flow into caches and later write paths. A compromised read can therefore become a state-manipulation primitive.

Be cautious with partial updates

If an update supplies only an ID and one changed field, verify where all unspecified fields come from. Do not automatically trust a cached object to provide them.

Review dynamic hooks

If user-influenced properties contribute to hook names, callback selection or dispatch behaviour, confirm that they cannot invoke unrelated internal functionality.

Minimise temporary privilege changes

Code that switches the current user should restore the previous identity reliably and avoid running broad hook ecosystems while elevated.

Expect re-entrancy

A save callback may initiate another save, dispatch another request or invoke the same logic recursively. Use explicit guards where re-entry would violate a security assumption.

Conclusion

wp2shell was powerful because several individually ordinary behaviours interacted:

  1. A parsing error caused parallel batch-processing arrays to lose alignment.
  2. A request was dispatched through a handler whose schema had not validated it.
  3. A scalar query parameter bypassed array-only sanitisation.
  4. SQL injection poisoned hydrated WP_Post objects in the request-local cache.
  5. The oEmbed write path persisted properties from those poisoned objects.
  6. Nested post repairs caused carefully ordered, re-entrant saves.
  7. A forged Customizer changeset temporarily switched WordPress to an administrator.
  8. A dynamic post hook started REST processing during that elevated window.
  9. A previously rejected user-creation request was re-evaluated and succeeded.
  10. The resulting administrator account enabled plugin-based code execution.

The central lesson is not simply “validate your REST routes” or “use prepared SQL.”

It is that relationships and assumptions must remain valid across the entire execution path. A request must stay associated with its own handler. A value must not become trusted merely because downstream code coerces it into the expected shape. Cached objects must not silently cross from read paths into trusted write paths. Temporary privilege must not leak into nested or re-entrant processing.

Those are the underlying patterns that made wp2shell possible—and they are patterns plugin developers can identify and remove from their own code.