Analysis of WP2Shell: a vulnerability chain that enables unauthenticated RCE, and why your SAST can't catch it

July 23, 2026
Insights

Two weaknesses in WordPress core combine into an unauthenticated remote code execution chain we call WP2Shell:

The SQL injection is not exploitable on its own, and the batch desync, in isolation, only runs a request under a handler it was never validated for; it is the two together that yield an unauthenticated SQL injection.

This article explains the two vulnerabilities in detail, and why they are invisible to static-analysis tools.

WordPress: A bit of context

WordPress is an open-source PHP content-management system that powers 59.1% of all websites whose CMS is known, according to W3Techs, which is roughly 41.2% of all websites on the internet[1]. It also offers the largest ecosystem of plugins and themes, which any administrator can add to their site.

That ubiquity comes with a specific security-research profile. WordPress is a legacy codebase: its first release was in 2003[2], and an almost religious commitment to backward compatibility means twenty-year-old design decisions still ship in the latest version. Old PHP code, as long as it stays compatible with newer PHP versions, is left untouched. Yet coding practices in 2026, especially around security, are not what they were 10, 15, or 20 years ago.

WordPress and its plugin ecosystem are frequently affected by newly disclosed vulnerabilities.

Unauthenticated remote code execution, probably the most critical vulnerability a remote application can suffer from, is nonetheless not that common in WordPress core. Let's dig into this two-vulnerability chain and see why it went unnoticed for so long.

SQLi: CVE-2026-60137

This first vulnerability is a SQL injection, introduced in WordPress 6.8. While we might assume this class of bug belongs to
another era, legacy codebases, untrained developers, and vibe-coding keep it a very current concern.

The sink

Inside the file wp-includes/class-wp-query.php, in the WP_Query::get_posts() method, author filtering is compiled into SQL:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
        sort( $query_vars['author__not_in'] );
    }
    $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );   // <-- no absint on the scalar path
    $where         .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
} elseif ( ! empty( $query_vars['author__in'] ) ) {
    if ( is_array( $query_vars['author__in'] ) ) {
        $query_vars['author__in'] = array_unique( array_map( 'absint', $query_vars['author__in'] ) );
        sort( $query_vars['author__in'] );
    }
    $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) );   // <-- absint always
    $where     .= " AND {$wpdb->posts}.post_author IN ($author__in) ";
}

Look at the asymmetry between the two branches:

So if author__not_in is a scalar string, it is concatenated straight into the SQL.

Why it looks unreachable

Here is the twist that makes this bug interesting rather than trivial: through WordPress core's own front doors, author__not_in can never be a scalar.

		if ( ! empty( $query_vars['author'] ) && '0' != $query_vars['author'] ) {
			$query_vars['author'] = wp_slash( '' . urldecode( $query_vars['author'] ) );
			$authors              = array_unique( array_map( 'intval', preg_split( '/[,\s]+/', $query_vars['author'] ) ) );
			sort( $authors );
			foreach ( $authors as $author ) {
				$key                  = $author > 0 ? 'author__in' : 'author__not_in';
				$query_vars[ $key ][] = abs( $author );
			}
			$query_vars['author'] = implode( ',', $authors );
		}

This operation later lands in the safe is_array() branch.

		/*
		 * This array defines mappings between public API query parameters whose
		 * values are accepted as-passed, and their internal WP_Query parameter
		 * name equivalents (some are the same). Only values which are also
		 * present in $registered will be set.
		 */
		$parameter_mappings = array(
			'author'         => 'author__in',
			'author_exclude' => 'author__not_in',
			'exclude'        => 'post__not_in',
            [...]
		);

The parameter is registered with the schema type => array, items => integer:

		if ( post_type_supports( $this->post_type, 'author' ) ) {
			$query_params['author']         = array(
				'description' => __( 'Limit result set to posts assigned to specific authors.' ),
				'type'        => 'array',
				'items'       => array(
					'type' => 'integer',
				),
				'default'     => array(),
			);
			$query_params['author_exclude'] = array(
				'description' => __( 'Ensure result set excludes posts assigned to specific authors.' ),
				'type'        => 'array',
				'items'       => array(
					'type' => 'integer',
				),
				'default'     => array(),
			);
		}

REST doesn't just map the parameter, it sanitizes it against the declared schema. WP_REST_Request::sanitize_params(), called during WP_REST_Server::dispatch(), runs each argument through rest_sanitize_value_from_schema(). Because author_exclude is declared as an array of integers, a comma-separated string is first split into an array by rest_is_array() (via wp_parse_list()), and then every element is cast with (int), so the value, too, arrives as an integer array.

In other words: read class-wp-query.php in isolation and the vulnerable branch is dead code. There is no in-tree source that produces a scalar author__not_in. A very sound taint-based scanner pointed at core finds a sink with no reachable tainted source and reports nothing. It is, in isolation, correct to do so.

The scalar source has to be manufactured, through a plugin, a theme, or from another vulnerability. That is where the second half of WP2Shell comes in.

Path confusion: CVE-2026-63030

The second vulnerability is a logic issue in the /batch/v1 REST endpoint. It was introduced in WordPress 6.9 and is really the entry point, the enabler, for the SQL injection above.

The batch endpoint

Since WordPress 5.6, core exposes /wp-json/batch/v1. It bundles several REST sub-requests into one HTTP call and runs them in series. The handler is WP_REST_Server::serve_batch_request_v1() (wp-includes/rest-api/class-wp-rest-server.php).

It runs the batch in two separate passes, and the second pass trusts the first:

  1. Validation pass: for each sub-request: match it to a handler, check the batch allow-list, run has_valid_params(), run sanitize_params().
  2. Execution pass: dispatch each sub-request via respond_to_request(). This function does not re-validate or re-sanitize; it goes straight to the permission callback and then the endpoint callback.

Because sanitization happens only in pass 1 and dispatch only in pass 2, the two passes must stay perfectly index-aligned.
Spoiler alert: they don't.

The endpoint has shipped since 5.6, but the desynchronization described below was introduced in 6.9; a refactor of the two-pass validate/execute loop is what allowed $matches to drift out of alignment with $requests and $validation.

The desync

Three parallel arrays are keyed by sub-request position. One of them is built differently from the other two.

The first array is $requests, which is the list of sub-request objects. It is shown below. Some segments are omitted for brevity.

$requests = array();

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;
    }
    $single_request = new WP_REST_Request( $args['method'] ?? 'POST', $parsed_url['path'] );

    [...] // set query params, body, headers, cookies, etc.

    $requests[] = $single_request;
}

If an error is encountered while parsing the path, a WP_Error is pushed to $requests. That error is not a valid request object, but it does consume a slot in the array.

The second and third arrays are $matches and $validation, which are built in the validation loop, whose head is defined below:

$matches    = array();
$validation = array();
$has_error  = false;

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;   // validation advances
        continue;                          // <-- matches[] is SKIPPED
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;                   // matches only advances for non-error requests
    // ... allow_batch check, has_valid_params(), sanitize_params() ...
}

A sub-request whose path fails wp_parse_url() consumes a slot in $requests and $validation but not in $matches. From that point on, $matches is shifted by one relative to $requests. That is the root cause of the desynchronization.

Let's see how the desync manifests in the execution loop:

foreach ( $requests as $i => $single_request ) {
    // ...
    $match = $matches[ $i ];                       // indexed by the $requests position
    list( $route, $handler ) = $match;
    $result = $this->respond_to_request( $single_request, $route, $handler, $error );
}

$i walks $requests, but $matches[$i] now points at a neighbour's match. So request N's object is executed with a different request's $route and $handler.

Index states, worked through

Position i $requests[i] $validation[i] $matches[i] Executes as…
0 WP_Error (bad path) WP_Error (shifted, see below) skipped (is_wp_error)
1 request A (widgets) true match of request B (posts) A's object under B's handler
2 request B (posts) true match of request C

Take three sub-requests, where request 0 has a deliberately malformed path so wp_parse_url() fails:

Because request 0 never pushed to $matches, $matches[1] holds the match that belongs to request B, not request A. Request A's object is dispatched under request B's route and handler.

One misconception is worth dispelling, since some write-ups frame this as an authorization bypass: it is not. In respond_to_request(), the permission_callback and the callback are read from the same $handler, so the desync swaps them as a pair; it can never run one endpoint's callback under another endpoint's permission check, and therefore cannot, on its own, cause a privilege escalation. The request is simply executed by a handler it was never validated for, and here that handler (posts::get_items) is public anyway. What matters is that the request is now executed under a different schema than the one it was validated against.

The real consequence: the parameter arrives unsanitized

This is what turns a routing curiosity into an injection. Sanitization in pass 1 is bound to each request's own matched schema, and it only touches parameters declared in that schema. match_request_to_handler() sets the request's attributes to the handler it matched, and sanitize_params() skips anything not in those attributes (wp-includes/rest-api/class-wp-rest-request.php):

foreach ( $this->params[ $type ] as $key => $value ) {
    if ( ! isset( $attributes['args'][ $key ] ) ) {
        continue;                 // param not in THIS endpoint's schema -> left untouched
    }
    // ...apply the schema's sanitize_callback...
}

Chain the two bugs together:

  1. The attacker's malicious sub-request targets an endpoint that doesn't declare author_exclude, and includes author_exclude as an extra parameter set to a scalar string payload.
  2. Pass 1 matches it to that other endpoint's schema. Since that schema has no author_exclude argument, the payload passes through completely unsanitized. (Had it been matched to posts, the type=array, items=integer schema would have coerced it into a harmless integer array.)
  3. The desync makes pass 2 dispatch that same request object under the posts get_items callback.
  4. get_items maps author_exclude → author__not_in and assigns it as passed (class-wp-rest-posts-controller.php): $args[ $wp_param ] = $request[ $api_param ];. The value is still a scalar string, never integer-cast.
  5. WP_Query receives author__not_in as a scalar, misses the is_array() branch, skips absint, and concatenates the raw string into post_author NOT IN (...).
This is a simplified description of the chain. A batch sub-request's method is schema-restricted to POST, PUT, PATCH, or DELETE, so GET (which posts::get_items requires) cannot be requested directly. A real exploit abuses the desync twice, nesting one batch inside another, to smuggle a GET sub-request through.

From there, turning an unauthenticated SQL injection into full remote code execution is a well-trodden path, and we deliberately stop short of it. That step leaves the application-security lens this article is about, how the vulnerability exists, why it is reachable, and why detection tools stay blind to it, and enters the separate discipline of exploitation. The RCE at the end of WP2Shell is therefore left intentionally vague: for our purposes, the interesting story ends at the injection.

Why SAST can't find it

The headline claim. It is worth being precise about which static-analysis technique fails and why, because they fail for different reasons, and understanding that is the whole point of the article.

Static analysis is a complex task for PHP

PHP is a highly permissive language, with weak typing, dynamic properties, and a hook system that makes control flow harder to reason about. That makes it very complex to infer rules and detect vulnerabilities in a large codebase. The static-analysis techniques available for PHP are limited.
We can count:

And that's about it in practice. Abstract interpretation, symbolic execution, model checking, and formal verification do exist for PHP, but mostly as research prototypes and academic tools; none is production-grade, and none is usable at the scale of a codebase like WordPress.

The desync vulnerability is invisible

Let's be clear: the desynchronization is not a data-flow problem. There is no taint to follow, no sanitizer to miss, no value to track.
The call graph is not modified. The vulnerability is in the control-flow ordering of two loops in a two-phase dispatcher and to detect it, a static analyzer would have to reason about the relational invariant that "the handler used at execution index i corresponds to a different request than the one whose params were sanitized at index i."

Even where it might be possible to detect this with symbolic execution or abstract interpretation, how would a tool know that the two loops, and more specifically the two arrays, are related, and that the desynchronization is unintended?

If it did know, we would be closer to another category of static analysis: model checking or formal verification. But there are essentially no production-grade such tools for PHP, for numerous reasons tied to the nature and behavior of the language. Even if there were, you would have to write a model for each part of the codebase, which is not realistic for something as large as WordPress. Worse, it would require having already identified the vulnerability and written a model for it; and if you had, it would no longer be an unknown vulnerability.

The SQL injection vulnerability doesn't exist

First, let's be honest here. WordPress codebase is huge and suffers from the same problem as many other legacy codebases such as PHP itself. The best practices in terms of security are not applied everywhere, and the codebase is not consistent. It survives over time thanks to a vulnerability discovery -> patch process, which is not ideal but is the reality of the situation. Defense in depth was not a priority in the past, and it still isn't applied everywhere. A change in one part of the codebase can have an impact on another, and that is exactly what happened here.

SQL injection is a well-known injection vulnerability and is often targeted by SAST. Let's see why it is not detected in this case.

1. Lexical/Syntactic pattern matching

The most basic tier, grep-style rules, or a syntactic Opengrep/Semgrep pattern. It looks for a shape of code. Two problems:

2. Taint analysis (e.g. Opengrep taint mode)

Taint tracking connects a source of untrusted data to a dangerous sink, minus any sanitizer in between. Every one of those three concepts breaks here:

3. Data-flow analysis for type inference (e.g. Psalm)

This tier parses the code to an AST, infers types, and follows a bounded amount of data flow; the level at which a string-vs-array<int> confusion should surface. It doesn't, because the only place the "array of integers" contract is ever written down is the REST schema in the controller (author_exclude => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ]), which is completely decorrelated from the WP_Query class. This schema is a runtime validation descriptor, not a static type on any variable. It is also attached to the wrong name: the schema constrains the REST parameter author_exclude, while the SQL sink consumes the query variable author__not_in: two identifiers in two different files, bridged only by a runtime mapping table ('author_exclude' => 'author__not_in').
The variable that holds the query vars is defined as shown below in class-wp-query.php:

/**
 * Query vars, after parsing.
 *
 * @since 1.5.0
 * @var array
 */
public $query_vars = array();

Nothing in the code links the schema's declared shape to the type of the query var, so by the time the value reaches the sink the tool has no idea it was ever meant to be integers; it just sees author__not_in as mixed. That's because it arrives through wp_parse_args() on a dynamic array, in a class marked #[AllowDynamicProperties], which strips away any useful type information along the way.

The local reasoning that remains is actively unhelpful: the is_array() check tells it the value is an array, but only inside that branch. On the scalar path, the explicit (array) cast turns whatever the value is back into a valid array, so implode( ',', (array) $mixed ) is perfectly legal code. The tool sees nothing wrong, because by its own rules nothing is wrong.

The type is knowable in principle and unknowable in this codebase, and the one artifact that encodes the right type is decorrelated from the variable that needs it.

The common thread

All three tiers share one blind spot: they analyze one unit at a time, and cannot understand the link between them. The source half of WP2Shell and the sink half live in different files, and they are joined only at runtime, by the batch dispatcher, via an index desync. Neither file contains a bug you can point to in isolation:

SAST tools fail to find WP2Shell because, first, they are not designed to reason about composition, and second, because the SQL injection is hard to find on its own and is not even a bug in isolation.

Prevention

Highly customized SAST: but is it really an option?

One obvious answer is "write your own rules." And you can write an Opengrep rule that flags the local smell: a variable reaching a SQL IN (...) clause without an unconditional integer cast. That is worth doing as a defense-in-depth guardrail.

But be honest about the ceiling:

To make the limitation concrete, here is roughly the kind of Opengrep rule someone might reach for; one that matches something in the file:

rules:
  - id: wordpress-wp-query-author-not-in-sqli
    languages: [php]
    mode: taint
    message: >-
      `author__not_in` reaches a WordPress database query without guaranteed
      numeric normalization. Normalize IDs with `wp_parse_id_list()` or `absint()`.
    severity: ERROR

    metadata:
      category: security
      technology:
        - wordpress
      cwe:
        - "CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')"
      confidence: HIGH

    pattern-sources:
      - pattern: $QUERY_VARS["author__not_in"]

    pattern-sanitizers:
      - pattern: absint(...)
      - pattern: array_map('absint', ...)
      - pattern: wp_parse_id_list(...)
      - pattern: $WPDB->prepare(...)
      - pattern: esc_sql(...)

    pattern-sinks:
      - pattern-either:
          - pattern: $WPDB->get_col($SQL)
          - pattern: $WPDB->get_results($SQL)
          - pattern: $WPDB->get_var($SQL, ...)
          - pattern: $WPDB->get_row($SQL, ...)
          - pattern: $WPDB->get_col($SQL, ...)
          - pattern: $WPDB->get_results($SQL, ...)
          - pattern: $WPDB->query($SQL)

First, it is a local rule that treats external sources of WP_Query arguments as unsanitized. That is a reasonable assumption and a good defense-in-depth guardrail, but it is not a discovery tool. Second, it is a pattern that matches a very specific scenario: a variable named author__not_in reaching a $wpdb query without an integer cast.

Because of how the rest of the codebase is structured, it is not possible to write a more generic rule that would catch the bug without drowning in false positives. Since you would have to write a rule for each of the many query vars that are handled differently in different places, this isn't a realistic way to catch this kind of bug; it would be faster and far easier to rewrite the code to be safe by design.

Ban the pattern, not the instance

The more durable fix is to make the class of bug unwriteable, and to use SAST to enforce the ban rather than to discover violations:

Safe-by-construction code generation

The strongest prevention is to make the unsafe program unrepresentable: write code that meets security best practices and applies defense in depth. Better still, generate code that is correct by construction rather than reviewing code that might be wrong. You are on the blog of Symbiotic Security, so you can guess what I have in mind: Symbiotic Code, our coding agent, is our approach to generating code that is safe-by-construction, and it is one of its goals to avoid this kind of bug.

Conclusion

WP2Shell is not really a bug in class-wp-query.php. On its own, the vulnerable branch is dead code: the author filter is only ever reached with integer arrays, so nothing bad can happen. But "nothing bad can happen" rests entirely on an assumption made in a different file, that whatever fills author__not_in has already turned it into integers. Defense in depth exists precisely so you never have to trust that assumption: you normalize at the sink, every time, exactly like the author__in branch three lines up already does. A single unconditional absint would have made the whole chain impossible, no matter what the batch endpoint did. That line was never written, and that is the recurring story of WordPress core: twenty years of code where safety is assumed at boundaries instead of enforced at sinks, kept alive by a discover-then-patch treadmill. The batch desync did not create the SQL injection. It just found the one spot where the assumption had no check behind it.

And this is exactly the class of bug static analysis cannot save you from, not because the tools are bad, but because the vulnerability is not in any single file. Pattern matching sees a sink no more suspicious than the safe branch beside it. Taint analysis sees a source that is sanitized, just against the wrong schema, in another file. Type inference sees a mixed value legally cast to an array. And none of them can even represent the enabler, because the desync is a relational invariant across two loops, not a data-flow fact. SAST reasons about one unit at a time; WP2Shell is a property of the composition of three. You can always write a custom rule afterwards, but by definition you can only write it once a human has already found the bug.

So the honest answer is not "buy a better scanner." A scanner is a detection tool, and deterministic detection, that is, detection without AI, is the wrong layer for this bug: it looks for vulnerabilities after they already exist, and the desync defeats even that. Winning takes more than one thing: reviewing the source code, of course, but also AI-based solutions, and moving upstream, to the moment the code is written. An AI model does not have to prove a whole-program invariant to avoid this bug; it only has to carry the security context as it writes: that an ID list must be normalized before it touches SQL, and that a value crossing a trust boundary can never be assumed to hold the type its schema promised. That is what we built Symbiotic Code for: a coding agent tuned for security whose objective is to generates code safe by construction. The cheapest vulnerability to fix is the one the code was never able to contain.

Symbiotic Code carries security context as the code is written, so an ID list is normalized before it touches SQL and a value crossing a trust boundary is never assumed to hold the type its schema promised. Code at full speed, secure by default, under control.

93% fewer vulnerabilities in AI-generated code. 0 vs. 45 vulnerabilities in a head-to-head against Claude Code across 100 projects on the same model. 27 hours saved per developer per month.

See how Symbiotic Code works: https://symbioticsec.ai

References

[1] - https://nvd.nist.gov/vuln/detail/CVE-2026-60137
[2] - https://nvd.nist.gov/vuln/detail/CVE-2026-63030
[3] - https://w3techs.com/technologies/details/cm-wordpress
[4] - https://wordpress.org/news/2003/05/wordpress-now-available/

About the author
Sébastien Rolland
Security Researcher
Icon line
See all articles

Book a demo

See how our solution empowers teams to grow their security maturity and to code securely & efficiently.
Book a demo
Icon line
Demo illustration