Back to blog

CVE-2026-60137 + CVE-2026-63030: how a one-position array desync in WordPress's batch API turned a login-only SQL injection into unauthenticated RCE

· · 21 min read
CVE-2026-60137 + CVE-2026-63030: how a one-position array desync in WordPress's batch API turned a login-only SQL injection into unauthenticated RCE

On July 17, 2026, WordPress shipped 7.0.2, 6.9.5, and a backport to 6.8.6, then quietly forced automatic updates across its install base. The release notes were terse. What they patched, disclosed the same day by Adam Kues of Assetnote (Searchlight Cyber's attack surface research team) through WordPress's HackerOne program, was a two-bug chain that a section of the security community immediately nicknamed wp2shell: an anonymous HTTP request, sent to a default WordPress install with no plugins and no valid account, that ends as a rogue administrator and a webshell. The SQL injection at the center of it, CVE-2026-60137, carries a CVSS of 9.1 (Critical). On its own it needs a logged-in user. The other half of the chain, CVE-2026-63030, a route-confusion bug in the REST API batch endpoint, is what removes that requirement. The striking part is the size of the blast radius: WordPress powers north of 500 million sites, roughly two out of every five websites on the internet, and within a day of the patch public proof-of-concept code had multiplied into dozens of independent variants while attackers had already started firing them.

This article is a two-layered breakdown. The first part is for readers who need to decide quickly whether they have anything to fix and what exactly, without wading through PHP internals. The second part dissects the chain the way the researcher reconstructed it: the batch dispatcher that loses track of which request maps to which route, the WP_Query parameter that trusts a string where it expected an array, and the walk from a blind boolean oracle to a forged administrator. The article closes by looking at how the Pragma Core platform addresses exactly the kind of problem that made this possible, because wp2shell is not really a WordPress story. It is a story about what happens when two components each trust an invariant the other one is quietly allowed to break.


Part I. Executive breakdown

What happened

WordPress core exposes a REST API. One of its built-in routes, /wp-json/batch/v1, exists so a client can bundle several API calls into a single request and have the server run them in order. It is a convenience feature, and like most convenience features it has an allowlist: only certain routes are permitted inside a batch, and requests that would need authentication are supposed to be checked before they run.

The first bug, CVE-2026-63030, is that the batch dispatcher can be made to lose count. When the server processes the bundle, it builds one list of the requests and a parallel list of the route matches for each request. If one of the requests is malformed in just the right way, it fails early, and the two lists slip out of alignment by a single position. From that point on, every request in the bundle is handled by the route that belongs to the request next to it. A harmless-looking call gets executed as if it were a different, more privileged one. In effect, the attacker smuggles input past the checkpoint by getting the guard to check the wrong ticket.

The second bug, CVE-2026-60137, is a plain SQL injection sitting behind that checkpoint. WordPress's main query builder, WP_Query, accepts a parameter called author__not_in that is meant to be a list of author IDs to exclude. The code assumes that value is an array of numbers. If it receives a string instead, the numeric sanitization is skipped and the raw text is dropped straight into a database query. On its own, reaching this parameter with attacker-controlled input requires a logged-in account. Chained behind the batch confusion, no account is needed at all.

Put together, an anonymous attacker sends a crafted batch request, the desync routes their payload to the vulnerable query path, and the SQL injection runs. From there they read the WordPress database, forge a new administrator through the site's own data structures, and drop a plugin that behaves as a webshell. The result is complete remote code execution as the web server, reached from the public internet with a handful of requests and no credentials.

Who is affected

Component / version Status
WordPress 7.0.0 to 7.0.1 Vulnerable to the full wp2shell chain. Update to 7.0.2 now.
WordPress 6.9.0 to 6.9.4 Vulnerable to the full wp2shell chain. Update to 6.9.5 now.
WordPress 6.8.0 to 6.8.5 Vulnerable to the SQL injection component only. Update to 6.8.6.
WordPress 7.1 beta line Fixed in 7.1 beta2.
WordPress before 6.8.0 Not affected.

The number that should worry you is not the CVSS. It is that the vulnerable branch, 6.9, shipped on December 2, 2025, which means a very large share of actively maintained, auto-updating sites landed squarely in range. WordPress enabling forced automatic updates for these releases tells you how the project rated the risk internally, regardless of the 7.5 that one scoring body assigned to the batch bug. Public exploit code appeared within roughly a day of disclosure, opportunistic exploitation was reported within hours of the patch, and by July 19 researchers were tracking more than two dozen distinct proof-of-concept implementations. This is a default-configuration, no-plugins-required, unauthenticated bug in the most widely deployed web application on earth. If you run WordPress and cannot confirm you are on a fixed version, assume you were reachable.

Why this matters beyond WordPress

Strip away the WordPress specifics and you are left with a pattern that recurs across modern software: a request multiplexer that fans one inbound message out into several internal operations, and an authorization model that assumes each operation stays bound to the identity and the route it was checked against. Batch endpoints, GraphQL query batching, gRPC streaming, JSON-RPC arrays, server-side request routers, and API gateways all do a version of this. The moment the mapping between "what was checked" and "what runs" can drift, the allowlist becomes advisory.

The SQL injection half is even more familiar. It is a type-confusion bug wearing an injection costume: a sanitizer that only fires for one shape of input (an array) and silently no-ops for another (a string). That exact anti-pattern, where the safe path and the unsafe path are decided by an implicit type check the caller can influence, lives in countless codebases far from WordPress.

Recommended actions

  1. Update immediately to WordPress 7.0.2, 6.9.5, or 6.8.6 depending on your branch. If you disabled automatic updates, re-enable them or patch by hand today. Do not wait for a maintenance window.
  2. Assume compromise if you were unpatched and internet-facing between July 17 and the moment you updated. Patching closes the door but does not evict an attacker who already walked through it.
  3. Hunt for the artifacts, not the requests. The decisive steps ride inside batch POST bodies that rarely land in access logs, so your evidence is in the database. Look for administrator accounts you did not create, especially usernames beginning wp2_ or w2s_ and email domains like @wp2shell.invalid or @wp2shell.shellcode.lol, plus oEmbed loopback cache rows, customize_changeset posts with abnormal parent IDs, and orphaned usermeta.
  4. Inventory every WordPress install you own, including forgotten marketing microsites, staging copies, and appliance-embedded instances. The chain does not care that a site is unimportant to you.
  5. Rotate secrets for any site you cannot clear: all administrator passwords, WordPress salts and keys, database credentials, and any API tokens the site held. Reissue, do not just reset.
  6. Put the REST API behind scrutiny. If you can, restrict or monitor /wp-json/batch/v1 at the edge, and treat unexpected POSTs to it as high signal until you have confirmed a clean, patched state.

Part II. Technical breakdown

Background: the batch endpoint and the two invariants it rests on

WordPress's REST API is a routing table. Each registered route (/wp/v2/posts, /wp/v2/users, and so on) carries a set of handler callbacks and, crucially, a permission_callback that decides whether the current request is allowed to run that handler. When a request comes in, the server matches its path against the routing table, runs the matched route's permission callback, and, if that passes, dispatches to the handler. The permission check and the dispatch are meant to be two halves of one atomic decision about one request.

The batch controller, registered at /wp-json/batch/v1, layers a loop on top of this. A client POSTs a JSON body containing a requests array, and the controller walks it, resolving each entry to a route and running it. To do that it keeps two parallel structures: the list of parsed sub-requests, and the list of route matches it computes for them. The whole design assumes an invariant so obvious it is never written down: the request at index i corresponds to the match at index i. Every permission decision is made about the request the server believes it is holding.

The batch controller also enforces an allowlist. Not every route may appear inside a batch, and requests that fail validation are supposed to be recorded as errors and skipped. This is the second invariant: a request that errors out is removed from the pipeline and cannot influence the requests around it. wp2shell is what you get when both invariants are false at once.

The vulnerability, part one: route-match desynchronization (CVE-2026-63030)

The batch controller builds its two lists in separate passes. First it validates and parses each incoming request, collecting the good ones. Then it computes route matches. The defect is in how a failed request is accounted for between those passes: when a sub-request is malformed enough to produce a WP_Error during matching, the error is appended to one structure but the corresponding slot is not reserved in the other. The match list ends up one element shorter than the request list.

From that offset onward, the loop pairs request [1] with the match computed for request [2], request [2] with the match for [3], and so on. Every permission callback now runs against the wrong route. A request whose own path would be rejected by the allowlist, or would demand authentication, gets waved through on the strength of a neighbor's permission check, and then executed by a handler it was never authorized for.

The public proof-of-concept bodies make the shape concrete. A malformed leading entry seeds the desync, and a nested batch amplifies it:

{
  "requests": [
    { "method": "POST", "path": "///" },
    {
      "method": "POST",
      "path": "/wp/v2/posts",
      "body": {
        "requests": [
          { "method": "POST", "path": "///" },
          { "method": "GET",  "path": "/wp/v2/users?author_exclude=<PAYLOAD>" },
          { "method": "GET",  "path": "/wp/v2/posts" }
        ]
      }
    },
    { "method": "POST", "path": "/batch/v1" }
  ]
}

The "///" entries are the seeds. Each one fails to match a real route, produces a WP_Error, and shifts the match list by one, so the request that actually carries the payload (/wp/v2/users?author_exclude=...) is dispatched under a permission decision that was computed for a different, benign request. The nesting lets the attacker reach an inner route with an outer route's clearance. What matters is that a value the attacker fully controls, the author_exclude query parameter, arrives at the users endpoint through a path the allowlist believed it had closed.

A notable property for defenders: a non-destructive detector can confirm the desync without ever running SQL. When the route confusion lands, the response carries a giveaway code such as block_cannot_read, emitted by a block-renderer permission callback that a straight, un-desynced request would never have reached. Seeing that code come back is proof the request executed under a handler it did not belong to. That is also why the safe public checkers key on it.

The vulnerability, part two: string-versus-array confusion in WP_Query (CVE-2026-60137)

author_exclude at the REST layer maps to author__not_in inside WP_Query, WordPress's central query builder. The parameter is documented and used as an array of author IDs to exclude from a result set. The query-construction code in wp-includes/class-wp-query.php builds the SQL fragment for it roughly like this:

// author__not_in: expected to be an array of author IDs
if ( ! empty( $q['author__not_in'] ) ) {
    $author_not_in = implode( ',', array_map( 'absint', (array) $q['author__not_in'] ) );
    $where .= " AND {$wpdb->posts}.post_author NOT IN ($author_not_in)";
}

The intent is clear: coerce to an array, run absint() over every element, and you can only ever produce a comma-separated list of non-negative integers, which is safe to interpolate. The problem is subtle and lives in the interaction between the guard and the input. In the vulnerable path the value reaches this code as a string, and the sanitization that the safe sibling parameter applies is skipped, so the raw string is what gets concatenated into the NOT IN (...) clause. Contrast it with author__in, which in the correct code applies the integer coercion in a way the attacker cannot sidestep. The two parameters look symmetric. Only one of them actually forces every element through absint() before it becomes SQL. That asymmetry is the whole bug: the safe path and the unsafe path are chosen by the shape of the input, and the attacker controls the shape.

The result is a classic injection point in the WHERE clause of a SELECT against the posts table:

... AND wp_posts.post_author NOT IN (0) AND (<attacker SQL>)-- -

Root cause and escalation: from a blind oracle to a forged admin

The injection is in a SELECT, and the endpoint returns rows, so the natural primitive is a boolean-blind or UNION-based read. The public tooling uses both. A representative blind test squeezes one bit at a time out of any table the database user can see:

author_exclude=0) AND (ASCII(SUBSTRING(
  COALESCE((SELECT user_pass FROM wp_users LIMIT 1),''),1,1))>64)-- -

Send it, watch whether the response set changes, and you have a one-bit oracle. Iterate over positions and comparison thresholds and you reconstruct arbitrary values. UNION-forging is faster where the column count and types line up, letting the attacker fold chosen data directly into the returned posts. Either way the target is the same: wp_users.user_pass, the phpass (or bcrypt, on newer installs) administrator hashes, and wp_usermeta, where capabilities live.

From here the reported chains split into two generations. The first generation dumps the admin hash, cracks it offline, and logs in through the front door, then uses the normal admin capability to install a plugin that is a webshell. That works but it depends on the hash being crackable. The second generation, which appeared within about two days of disclosure, skips the cracking entirely. Because the SQL primitive can write as well as read once the attacker chains it through WordPress's own persistence paths, the public exploits assemble a rogue administrator directly: a forged wp_users row plus the wp_usermeta capability entries that grant it administrator, then an oEmbed loopback cache write and a customize_changeset maneuver that get code onto disk as a plugin. The phrase the write-ups use is a UNION-forged post and an oEmbed cache write turned into a rogue administrator and a webshell plugin. No password cracking, no login prompt, just data structures bent until one of them is executable.

This is also why the forensic advice inverts the usual instinct. The interesting requests are POST bodies to a single endpoint, which most WordPress access logs do not record in any useful detail, so the reliable evidence is the wreckage left in the database: the impossible admin account, the loopback oEmbed rows, the changeset with a parent it should not have, the orphaned usermeta. Hunt the artifacts, not the traffic.

Affected versions

The scoring is worth a footnote because it caused confusion in the first day. CVE-2026-60137, the SQL injection, is rated CVSS 9.1, Critical. CVE-2026-63030, the batch route confusion, was scored differently by different bodies, as high as 9.8 Critical by one and 7.5 High by another, which is exactly the kind of split you expect for a bug whose severity depends entirely on what it is chained to. Read the chain, not the individual numbers.

Timeline

Date Event
2025-12-02 WordPress 6.9 released, introducing the vulnerable batch route-confusion path.
2026 (pre-disclosure) Adam Kues of Assetnote / Searchlight Cyber reports the chain to WordPress via HackerOne.
2026-07-17 WordPress ships 7.0.2, 6.9.5, 6.8.6, and 7.1 beta2; forced auto-updates enabled; details disclosed.
2026-07-17 Opportunistic exploitation reported within hours of the patch.
2026-07-18 Full technical mechanism published; first working proof-of-concept posted to GitHub.
2026-07-19 Researchers tracking more than two dozen distinct public PoC implementations, including no-crack RCE variants.
2026-07-20 Vendor detection and authenticated scanning checks broadly available.

A note on the discovery methodology

This was manual source review by an attack-surface research team, not a fuzzer stumbling onto a crash. You can see the reviewer's mindset in the shape of the finding. The batch controller and WP_Query are both old, heavily used, heavily audited code. Neither bug is exploitable alone in a way that would alarm most reviewers: the route confusion just misroutes requests, and the SQL injection just needs a login. The insight was refusing to evaluate either component in isolation and instead asking what one bug grants the other. The desync does not need to reach a sink by itself; it only needs to move an attacker one trust boundary closer to a sink that already exists. That composition is where the value was.

The meta-lesson for the AppSec community is uncomfortable, because it argues against the way most tooling and most reviews are structured. We audit functions, endpoints, and taint flows one at a time, and we grade each finding by what it can do alone. wp2shell scored a login-gated injection and a benign-looking router as separately unremarkable, and their product as a pre-auth RCE against 40 percent of the web. The bugs worth the most are increasingly the ones that only exist in the seam between two components that each looked fine.


What we should learn from CVE-2026-60137 and CVE-2026-63030

  1. Parallel arrays are a trust boundary, and index drift is an auth bypass. Any time authorization is decided in one list and execution reads from another, a single off-by-one between them silently reassigns permissions. Treat "request i maps to decision i" as an invariant that needs an assertion, not a hope, and make an errored element consume a slot in every parallel structure so nothing downstream can slide.

  2. A sanitizer that only fires for one input type is a bypass waiting for the other type. author__not_in was safe for arrays and unsafe for strings. Wherever a security transform is guarded by an implicit type or shape check, the attacker will supply the shape the guard does not cover. Normalize and validate the type first, then sanitize unconditionally.

  3. Grade bugs by their chain, not by their solo impact. Both halves of wp2shell were individually easy to under-rate: a router that misroutes, an injection that needs a login. Severity models that score findings in isolation will keep mispricing exactly the compositions that matter most. Ask of every low or medium finding: what does this grant a bug next to it?

  4. Batch and multiplexing endpoints inherit every authorization assumption they wrap, and must re-verify per operation. Fan-out APIs (REST batch, GraphQL batching, JSON-RPC arrays, gRPC streams) are load-bearing security boundaries. Each sub-operation needs its own permission decision, evaluated against that exact operation, with no shared state that a sibling can perturb.

  5. When the attack rides in request bodies, your logs will lie by omission. The decisive wp2shell steps do not appear meaningfully in access logs. If your detection strategy assumes the interesting events are in the request line, an entire class of API-body attacks is invisible to you. Instrument the sinks and the resulting state, not just the front door.


How Pragma Core addresses this class of problem

Pragma Core is built for exactly the kind of issue that classical scanners miss, because the bug is not a single bad line. It is how input and state flow across two components that were each reviewed in isolation and each looked correct. wp2shell is that story in its purest form: a router and a query builder, both fine on their own, catastrophic together. The following capabilities map directly onto the seams the chain exploited.

Autonomous AI agents for attack chain investigation

The autonomous agents reason across a chain instead of stopping at the first flagged line. Pointed at a WordPress-shaped codebase, the question that surfaces wp2shell is not "is author__not_in sanitized" (the code looks like it is) but "which entry points can reach WP_Query with a string where an array is assumed, and does any of them sit behind a weaker permission check than the parameter's own gate." The agents chase that reachability question through the batch controller, notice that a sub-request can execute under a neighbor's permission decision, and connect the two. That is the exact reasoning path the human researcher walked, run continuously rather than once.

Interactive call graphs with a vulnerability overlay

The bug lived in a relationship, not a function, so a per-function view was always going to miss it. Pragma Core auto-generates call graphs for a connected repository and overlays findings on them, which is how you see that the batch dispatch loop and the WP_Query author-exclusion path are only two hops apart, and that the trust boundary between them is a single parallel-array index. Visualizing where untrusted author_exclude input propagates, and where it crosses from "allowlist-checked" to "executed," turns an invisible seam into a drawn edge.

SAST tuned for the actual pattern, not just injection sinks

Off-the-shelf static analysis is tuned for taint that ends in a SQL string, and it would likely clear the author__not_in code because a sanitizer is visibly present. Pragma Core's static analysis can be tuned to the pattern that actually mattered here: a security transform gated by an implicit type check ((array) coercion plus absint()) that no-ops for the non-array case, and a parallel-array construction where an error in one list is not mirrored in the other. Both are high-confidence, describable patterns once you know to look for the shape rather than the sink.

Human-guided AppSec investigations

wp2shell was found by an expert who refused to evaluate two components separately and instead asked what they grant each other. Pragma Core's expert-led research module lets an AppSec operator drive that same investigation, backed by autonomous agents and the full repository context already in the workspace, focused on the parts of a stack the team senses are fragile. It systematizes the "what does this bug hand the bug next to it" habit that produced this finding, rather than leaving it to whoever happens to have the time and the instinct.

Continuous tracking of the software you actually run

For the many organizations whose exposure to wp2shell is "we have WordPress somewhere," the hard problem on July 17 was inventory: which installs exist, on which branch, and which are internet-facing. Pragma Core tracks components and versions across every connected repository and surfaces the affected ones with CVSS context and a fixed upgrade path the moment a CVE lands, including instances buried in a marketing subdirectory or a container image nobody remembers deploying. The answer to "are we affected and where" should take minutes, not a frantic afternoon of grep.


Closing thoughts

CVE-2026-60137 and CVE-2026-63030 are not, fundamentally, a bug about WordPress, or even about SQL injection. They are a bug about composition: about two components that each honored a reasonable local contract while jointly violating a global one nobody owned. The batch controller trusted that its two lists stayed aligned. The query builder trusted that its input arrived as an array. Both trusts were locally sound and globally fatal, and the exploit is just the attacker holding them against each other. That shape is everywhere in modern architecture, in every gateway that fans a request into sub-operations, every serializer that decides safety by type, every place a permission is checked once and consumed somewhere else.

The organizations that come out of an advisory like this ahead are not the ones with the most scanners. They are the ones with enough code visibility and AppSec maturity to treat a write-up like this as an audit trigger: to ask, today, where in their own systems a permission decision drifts from the operation it authorizes, or a sanitizer quietly skips a shape it did not expect. Teams that want to move from "we scan and report" to "we systematically investigate what is fragile" can reach Pragma Core at pragma-core.com for a demo.


Sources

Related posts
CVE-2026-74820: how an unsanitized ORDER BY clause turned ServiceNow's AI Platform into an unauthenticated database backdoor
Sep 18, 2026
CVE-2026-85978: how one path normalization mismatch turned Akana's admin console into unauthenticated remote code execution
Sep 9, 2026
CVE-2026-78174: how an unredacted session token in a diagnostic log turned a low-privileged WatchGuard Dimension admin into super admin
Sep 1, 2026

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →