

Two weaknesses in WordPress core combine into an unauthenticated remote code execution chain we call WP2Shell:
WP_Query, reachable only when the author__not_in query var arrives as a scalar string instead of an array[1]./batch/v1 REST endpoint that lets an attacker have a request validated against one endpoint's schema and then executed by a different endpoint's handler[2].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 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.
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.
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:
author__in casts every element with absint in the implode itself, so it is safe regardless of whether the input was an array or a scalar.author__not_in only applies absint inside the is_array() guard. On the scalar path, the value is wrapped with (array) and imploded verbatim into the NOT IN (...) clause.So if author__not_in is a scalar string, it is concatenated straight into the SQL.
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.
?author=...). author__not_in is not a registered public query var (wp-includes/class-wp.php), so ?author__not_in= is silently dropped. The public author var is accepted, but core explodes it and builds author__not_in as an array of abs( intval() ) values, as shown below, extracted from class-wp-query.php: 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.
GET /wp/v2/<posts|comments>?author_exclude=...). Both the posts and comments controllers map author_exclude → author__not_in (class-wp-rest-posts-controller.php and class-wp-rest-comments-controller.php): /*
* 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.
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.
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:
has_valid_params(), run sanitize_params().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.
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.
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.
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:
author_exclude, and includes author_exclude as an extra parameter set to a scalar string payload.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.)get_items callback.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.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 toPOST,PUT,PATCH, orDELETE, soGET(whichposts::get_itemsrequires) cannot be requested directly. A real exploit abuses the desync twice, nesting one batch inside another, to smuggle aGETsub-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.
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.
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.
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.
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.
The most basic tier, grep-style rules, or a syntactic Opengrep/Semgrep pattern. It looks for a shape of code. Two problems:
$wpdb->get_col(). There is no $wpdb->prepare() call, so the query is not parameterized. But this is the case for many queries in WordPress core, and a rule that flags every unprepared query would be a flood of false positives.implode( ',', (array) $var ), and just a few lines away an almost identical author__in branch does the same thing but with an unconditional absint. A rule broad enough to flag implode into a SQL string will fire on the safe author__in branch too, drowning the real issue in false positives; and a rule specific enough to avoid that will miss the scalar path.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:
$_GET / $_POST near the sink. The tainted value enters as a REST request parameter and travels through the dispatcher, get_items, the $parameter_mappings table, and into a WP_Query argument array. That is deeply interprocedural and, more damningly, interfile. The source lives in class-wp-rest-server.php, the sink in class-wp-query.php.absint is the sanitizer used on the array branch. But many other values are handled too, of different types, each requiring a different kind of sanitizer. On top of that, those values are supposed to already be sanitized by the controller, and they usually are.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.
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:
class-wp-query.php has a sink with (in-tree) no scalar source. The whole design is terrible, but it is normally safe.class-wp-rest-server.php has a dispatcher desync that, on its own, runs a request under a handler it was never validated for. That is a real defect, but its impact looks minor in isolation; it only turns critical when the borrowed handler happens to be a public SQL sink.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.
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:
WP_Query argument in another without human help. Your custom rule can, at best, describe the sink's local shape.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.
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:
$wpdb queries and require $wpdb->prepare() or a similar parameterized query builder. It is effective and easy to enforce with a SAST rule and it applies to all query vars, not just author__not_in.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.
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
[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/


.png)
