The investigation began with two artifacts that looked similar at the end but meant very different things. An earlier Pruva run reached a WordPress site, logged in as an administrator, uploaded a plugin through the normal dashboard, and executed a command as the web server. The platform had accepted it as a complete reproduction. Inspection showed that the successful login reused an administrator password chosen during target setup. It was an authenticated demonstration with a pre-authentication label.
A separately supplied outcome transcript was more interesting. It reported Admin creation confirmed, verified the new account through the normal login flow, uploaded a plugin through core, and returned uid=33(www-data). But the script source and its hidden middle phases were not available in the evidence we reviewed. The transcript established the outcome we needed to explain; it did not explain how the new administrator came into existence.
We rejected the first run and treated the second artifact only as a target outcome. The stricter question became: could an unauthenticated request against stock WordPress 7.0.1 create a new administrator without borrowing a credential, a plugin, or a database file-write capability?
That distinction mattered more than the shell. It turned an apparent exploit into a source-level investigation of how state moves through WordPress.
Research boundary. The accepted target was a clean, installed, single-site WordPress 7.0.1 deployment with the default theme, no attacker-supplied extensions, an unknown random owner password, and a restricted application database account. Open registration, direct database writes, stacked statements, database file-write features, seeded or crackable credentials, and preinstalled gadgets were not acceptable bridges. The unchanged proof also had to fail on an independently installed 7.0.2 source target.
Test topology. The vulnerable and fixed sites used the same pinned WordPress PHP 8.2/Apache base and separate source trees. MySQL 8.0 ran on a private target network with database-scoped application grants, no global
FILEprivilege,local_infile=0, and no multi-statement client mode. A remote driver reached WordPress over HTTP, while a separate bounded oEmbed provider returned ordinary iframe HTML. The final purity target had no mounts or must-use plugins.
Verified reproduction. The public evidence record REPRO-2026-00294 contains the validated vulnerable proof, the unchanged 7.0.2 control, and the promoted reproduction artifacts for this investigation.
What was known, and what remained missing
WordPress publicly disclosed the underlying issues before this investigation. CVE-2026-63030 covered the REST batch-route confusion and credits Adam Kues of Assetnote/Searchlight Cyber. CVE-2026-60137 covered the facilitated scalar SQL injection and credits TF1T, dtro, and haongo. The WordPress 7.0.2 release stated that the two issues could be combined into remote code execution.
Pruva did not discover either CVE. Our work began after disclosure and, despite having outcome and PoC context, derived and tested its own stock-core composition from source and runtime behavior. Here, “independently” describes the reconstruction process. It is not a claim of first discovery, first publication, or absence of private prior work. The supplied transcript was useful as a target to verify, but it omitted the state transitions and could not substitute for them.
A contract before another payload
Rejecting the known-password result changed the investigation. It prevented credential laundering: importing authority from the lab, then attributing ordinary administrator behavior to the vulnerability. The next proof had to begin with one owner whose random password was never exposed and end with a distinct account created by the vulnerable path.
We also inspected the apparent credential shortcuts rather than merely declining to use them. Password hashes were not pass-the-hash login material. Stored session verifiers were not the raw tokens needed to construct valid cookies. Password-reset, application-password, and recovery records did not reveal reusable plaintext. Normal cookie creation also depended on configuration secrets outside the database. Open registration was disabled and, in a normal installation, would have created a subscriber rather than an administrator.
That left a harder but cleaner objective: make WordPress itself materialize new authority through normal core behavior.
The bug was real, but the obvious exploit was not
WordPress’s REST batch handler keeps two parallel sequences: parsed subrequests and the route handlers matched to them. In 7.0.1, a malformed batch entry could be recorded in one sequence without a corresponding entry in the other. Every later request could then be paired with the next handler.
The obvious move was to shift an anonymous request onto the user-creation controller. WordPress still ran that controller’s permission check, however, and correctly denied user creation for the anonymous context. Runtime observation showed the check executing as user 0, returning the expected denial, with no user or role mutation. The mismatch changed which code received a request; it did not erase authorization.
That failure set the direction for the rest of the work. Instead of looking for a write endpoint with a weak check, we looked for a read path whose side effects could survive long enough to change application state.
A read query crossed into application objects
The batch mismatch could steer attacker-controlled input into a post query where WordPress 7.0.1 handled one scalar exclusion value differently from the array form. The value reached a SQL list without the integer normalization the query expected.
At first, that injection appeared boxed in. WordPress often optimizes post queries by selecting IDs, casting them to integers, and loading complete rows separately. We initially hypothesized that a fabricated row could enter the in-request post cache, then retracted the claim when runtime tracing showed the observed query selected IDs and reloaded real rows. SQL influence and post rendering were real, but the transition to an attacker-shaped PHP object was not yet proved.
We reopened the result because the source did not support treating that optimization as an invariant. Reading the query code revealed that split-query mode was conditional. The first full-row probe still failed because the generated query’s ordering was incompatible with the projection. A valid public query shape removed that ordering without changing the target or relying on a database write. Runtime observation then confirmed that a fabricated 23-column post object could enter PHP, occupy the application cache, and reach content rendering without first becoming a database row.
This was the first turning point. The useful capability was not simply “SQL injection.” It was temporary control over an application object that trusted core code would later read.
Evidence note. Runtime tracing confirmed that an anonymous request could introduce an attacker-shaped post object into PHP without directly modifying a database row. Separate checks ruled out stacked statements, database file writes, password-hash replay, and cross-group cache collisions.
The shortcuts that did not survive the threat model
The missing edge was not found by trying more SQL syntax. A stacked INSERT did not execute under WordPress’s single-statement database call, and registration state remained unchanged. Connection-local state such as FOUND_ROWS() affected pagination, not authorization. The restricted application account could not turn the injection into a direct file write, and no authorized insert followed from which an insert ID could be borrowed. Merely projecting a complete row also did not persist it.
Credential and execution surfaces failed for similarly useful reasons. Cache groups did not alias into user objects. REST validation and sanitization performed normalization and read-only lookups rather than user, role, option, or authentication writes. The stock Abilities API was capability-gated and did not expose a user-creation or execution ability. A legacy-widget deserialization path required a valid wp_hash() signature based on salts outside the database, and we found no signing oracle. Ordinary content callbacks and dynamic blocks did not convert temporary post state into administrator authority or executable code under the target contract.
Each failure narrowed the requirement instead of inviting a weaker lab. We no longer needed “more SQL” or a reusable credential. We needed a normal core consumer that would load the temporary object, perform a legitimate write, and preserve fields that the narrow update did not intend to change.
The partial update that was not partial
Temporary cache control still disappears when the request ends. The next task was to find ordinary core behavior that would read the fabricated object and persist part of it.
The useful path came from oEmbed caching. WordPress can load a cached post object, fetch an embed response, and update only the object’s content. But the general post-update routine first loads the old object and merges the supplied fields over it. If the cached “old” object is attacker-shaped, fields omitted from a supposedly narrow update come from that poisoned state.
Post hierarchy repair created a separate instance of the same pattern. When WordPress repairs a parent loop, it performs a small legitimate update of its own. That update also merges against the cached object. The oEmbed writeback and hierarchy repair were not direct database-write gadgets; they were two ordinary partial updates whose merge semantics could turn a transient object into persistent post state.
An early theory assumed the embed response itself would carry the next-stage data. WordPress’s HTML filtering prevented that. The better model was simpler: the cache object already held the data; the provider only had to trigger the legitimate partial update. A first combined probe still failed because the poisoned cache state caused the embed path to return before writeback. Runtime tracing exposed the early return, and a cache-miss state exercised the intended partial-update path without weakening the target.
This became a two-stage persistence sequence. The first poisoned object was merged through oEmbed writeback and hierarchy repair into a due Customizer changeset. Publishing that changeset created WordPress’s stock custom-CSS post. A second poisoned object then reached the same kind of narrow update and hierarchy repair through that real post. No attacker extension or direct database mutation supplied either write.
Borrowing a privileged moment
The first persistent object was interpreted as a scheduled Customizer changeset containing the stock custom_css setting for the site’s publicly discovered active stylesheet. When WordPress publishes a due changeset, it temporarily adopts the recorded author’s user context while saving settings. On the tested site, runtime tracing observed core switch to user 1 while creating the ordinary custom-CSS post. This is intentional behavior: capability-sensitive filters should run as they did when the change was authored.
On its own, that temporary context is not a login and does not give a remote caller a session. The investigation needed one more connection: a core callback that could run before WordPress restored the previous user.
The connection came from the second partial update. Post saves fire dynamic transition hooks named from their status and type. Hierarchy repair persisted an attacker-shaped custom-CSS object whose transition selected the existing parse_request action. Core had already registered rest_api_loaded() on that action. WordPress 7.0.1 then began another top-level REST dispatch while the original batch was still active and before the Customizer restored the previous user. The nested request therefore observed the temporary privileged context.
At that point the same outer request body was parsed again. The malformed batch reached the ordinary user-creation controller and its paired permission callback. The contrast with the earlier failure was decisive: the direct attempt had run the check as user 0 and was denied; the nested attempt ran the same normal check while core was temporarily operating as user 1. It succeeded, WordPress inserted a fresh user, and the standard role transition assigned administrator authority.
The chain was now complete:
CVE-2026-63030desynchronized parsed batch requests from matched handler tuples while keeping each handler’s permission callback attached.- The shifted posts handler exposed the scalar query path in
CVE-2026-60137. - A non-split query admitted an attacker-shaped post object into PHP and the post cache.
- oEmbed writeback merged a narrow update over that poisoned old object.
- Post-hierarchy repair supplied a second legitimate partial update and persisted a due Customizer changeset.
- Changeset publication created the stock custom-CSS post while core temporarily operated as its recorded author.
- A second poisoned partial update persisted a post transition that invoked
parse_requestandrest_api_loaded(). - WordPress 7.0.1 re-entered top-level REST before restoring the previous user.
- The normal user controller and permission callback created a fresh administrator inside that temporary context.
This sequence is intentionally described at the level needed to understand the failure. Request construction, exact cache values, row choreography, operational payloads, and lab credentials are not published here.
Proving impact without a shortcut
Administrator creation was established through three independent oracles. The normal user controller and role transition completed under the temporary owner context. A database before-and-after check showed a distinct new administrator. Finally, the verifier authenticated through the ordinary login flow using only the new account’s generated credentials.
We then established what that authority meant under a standard WordPress deployment. The authenticated verifier used the core plugin uploader and invoked a small command oracle from the uploaded plugin. The target response contained an independent marker, a zero exit status, and the process identity of the web-server user.
Nothing was copied directly into the container. The database did not write a PHP file. No pre-existing owner credential was used. The product’s own authenticated installation path connected the new administrator to command execution.
That distinction is part of the result, not a qualification hidden in the footnotes. The vulnerable path created administrator authority before authentication; the fresh administrator then used ordinary login and plugin installation to demonstrate its consequence. This was a real pre-authentication-to-RCE chain on the tested deployment, not a direct anonymous PHP shell. A deployment that disables plugin installation would change the final impact path, even though unauthorized administrator creation would remain severe.
A proof ladder, not one successful run
The first complete run was instrumented so we could explain each edge: cache insertion, partial-update merges, the Customizer user switch, nested dispatch, the user controller, and the role transition. That trace explained the mechanism but could not by itself prove that instrumentation was non-causal.
The next driver removed preselected object IDs and direct database inspection. It discovered the disposable rows and active stylesheet through the vulnerable application path, then created another fresh administrator. That remote-only repeat ruled out hidden database knowledge in the driver.
We repeated the chain against a new official WordPress 7.0.1 PHP/Apache image with no mounted instrumentation or must-use plugins. The site began with one owner whose random password was never exposed. The unchanged remote proof created a second administrator and again reached execution as the web server. The final reproduction script then ran twice at the top level; each execution recreated its vulnerable and fixed targets, producing four fresh vulnerable confirmations and four fixed controls in total.
Proof standard. The instrumentation explained the state transitions but was not required for exploitation. A clean repeat established causality; a fixed-version negative control established where the behavior diverged.
One otherwise successful attempt was deliberately not counted. It created the administrator, but the verifier addressed WordPress by container IP while the site redirected login to its canonical hostname. The verifier’s cookie jar correctly withheld the cookie across hosts, so the end-to-end login oracle failed. Repeating against the canonical origin passed without changing the chain. That failure was useful: a proof should preserve the product’s real origin semantics rather than weaken its verifier around them.
The public record promotes the non-secret proof artifacts needed to rerun and assess the result. Full operational traces contain generated credentials and request material, so they are retained by hash rather than published wholesale. The compact trace pivots are corroborating selections; the repeatable vulnerable/fixed harness remains the primary public proof.
The fixed control located the break
An independently installed WordPress 7.0.2 source target used the same network and database restrictions and another unknown owner password. The unchanged driver stopped at its first attempt to seed cache state. The original posts handler and permission callback remained aligned and executed as user 0. No projected object or oEmbed cache row appeared, no new user was created, and the target retained exactly its initial owner.
The fixed source strengthened three boundaries used by the chain: malformed batch entries remain aligned with handler matches, author exclusions are parsed as integer ID lists, and a new top-level REST request is rejected while dispatch is already active. The unchanged full driver stopped at the first alignment boundary, so it did not dynamically traverse every later guard. Source comparison and bounded variant analysis separately confirmed the integer-list parsing and nested-dispatch protections. WordPress documents the two underlying issues and their identifiers in the WordPress 7.0.2 security release.
That negative control is stronger than observing that a final command did not run. It identifies the first missing postcondition and ties the result to the repaired code paths.
What this proof does and does not establish
For stable releases, the batch-route confusion advisory covers WordPress 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1, fixed in 6.9.5 and 7.0.2. The facilitated SQL injection advisory additionally covers 6.8.0 through 6.8.5, fixed in 6.8.6. The unauthenticated RCE composition requires both issues, so the shared stable range begins with 6.9.0. Pruva’s runtime claim is narrower still: we exercised WordPress 7.0.1 as the vulnerable target and an independently installed 7.0.2 source tree as the fixed control. We did not reproduce every version named by the advisories.
The public validation run also did not rediscover the chain from an empty ticket. It loaded the frozen, source-derived research seed from the project cache, rebuilt fresh targets, and independently re-executed the complete proof and controls. That distinction separates discovery provenance from reproduction quality.
The variant stage tested alternate placements and retained guards against the fixed source. It did not confirm a distinct 7.0.2 bypass. That is a bounded negative result, not a claim that no related variant can exist.
Run accounting. The final Pruva validation and variant-analysis run executed for 3,929 seconds and recorded $63.383274 in priced model requests. Those figures cover the final automated run after the source-derived chain had been frozen. They do not represent the total time or cost of the preceding research, rejected hypotheses, operator work, or publication review.
Why this class of bug is easy to underestimate
None of the individual transitions looks like unauthenticated remote code execution. A handler mismatch still preserves a permission callback. A read query returns objects. An object cache is temporary. A partial update changes only a few named fields. The Customizer’s user switch is bounded. A post transition is just an event.
The security failure lives in their composition.
For defenders, the immediate action is to move WordPress 7.0.1 installations to 7.0.2 or a later supported release. It is also worth monitoring for unexpected administrator creation and unplanned plugin installation, then rotating credentials and investigating the host if either appears.
For researchers, the lesson is methodological: name the postcondition at every boundary. Track when database data becomes an application object, when temporary state is merged into persistence, and when a legitimate privileged context can be observed by re-entrant code. The missing middle is often where the actual vulnerability is hiding.