The first result looked complete. A remote script reached a WordPress site, logged in as an administrator, uploaded a plugin through the normal dashboard, and executed a command as the web server. A supplied outcome transcript showed the same kind of ending. Neither established the missing transition that mattered: how an unauthenticated request created administrator authority in the first place.
The test site had been installed with a known administrator password. That meant the login and plugin upload proved what an administrator could already do, not that the vulnerability had created the authority to do it. We discarded the result and started again with a stricter question: 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.
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. The batch-route confusion advisory credits Adam Kues of Assetnote/Searchlight Cyber, and the facilitated SQL injection advisory credits TF1T, dtro, and haongo. The WordPress 7.0.2 release also stated that the issues could be combined into remote code execution.
Pruva did not discover either CVE. Our work began after disclosure and independently reconstructed the stock-core composition that connected the public entrypoint to a fresh administrator and then to product-path command execution. The available outcome 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. An injected row would lose everything interesting before PHP saw it. We recorded that as an honest interim boundary: SQL influence and post rendering were real, but the transition to persistent authority was still missing.
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. Under a different public query shape, WordPress selected complete post rows. Runtime observation then confirmed that a fabricated 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. Connection-local SQL state did not become authorization in a later request, and the restricted application account could not turn the injection into a direct file or database write. Merely projecting a complete row also did not persist it.
Other stock surfaces failed for similarly useful reasons. Cache groups did not alias into user objects. REST validation and sanitization did not provide an anonymous mutation path. The Abilities API, signed deserialization candidates, ordinary content callbacks, and dynamic blocks did not convert temporary post state into administrator authority or executable code under the target contract.
Those negative results narrowed the requirement. 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 embed response only had to cause the legitimate partial update to run.
Borrowing a privileged moment
The persistent object could be interpreted as a scheduled Customizer changeset. When WordPress publishes a scheduled changeset, it temporarily adopts the original author’s user context while saving settings. 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 post transitions. A post save can fire dynamic transition hooks, and one reachable transition led back into REST request parsing. WordPress 7.0.1 allowed that top-level REST dispatch to begin while the original batch was still active. The nested request inherited the Customizer’s temporary privileged context.
At that point the same malformed batch reached the ordinary user-creation controller. This time runtime tracing observed the normal controller and its normal permission callback executing while core was temporarily operating as the site owner. The check succeeded, WordPress created a fresh user, and the standard role transition assigned administrator authority.
The chain was now complete:
- A malformed batch desynchronized requests and handlers.
- A post query admitted an attacker-shaped object into the cache.
- Normal partial-update behavior persisted selected object state.
- Customizer publication created a temporary privileged context.
- A post transition re-entered REST before that context was restored.
- The normal user controller created a new administrator.
This sequence is intentionally described at the level needed to understand the failure. Request construction, 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.
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 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. 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.