Most remote-code-execution stories begin with something that looks dangerous: eval(), unsafe deserialization, or an unrestricted file upload.
This one begins with bookkeeping.
WordPress's REST batch handler maintained several parallel arrays. A malformed request was recorded in two of them but not the third. From that point forward, a valid request could be paired with the route handler intended for a different request.
That mismatch was not a shell. Neither was the separate SQL injection that it exposed. But when those bugs met WordPress's post cache, oEmbed refresh logic, hierarchy repair, Customizer state, and globally shared user context, the result was an unauthenticated path to administrator creation. The final step to PHP execution was almost boring: administrators are normally allowed to install plugins.
WordPress released 7.0.2 on July 17, 2026, describing the issue as a REST API batch-route confusion combined with SQL injection leading to remote code execution. Because of the severity, WordPress enabled forced automatic updates for affected sites. Administrators should still verify that the update actually landed. (WordPress 7.0.2 announcement, CVE-2026-63030 advisory)
The attack is a chain, not one catastrophic function:
Attack chain at a glance
No single step provides code execution. The impact emerges as data crosses five trust boundaries.
Entry
Anonymous REST batch request
A malformed member enters WordPress through the public batch endpoint.
Mismatch
Requests and handlers drift apart
The missing array entry shifts route, permission, and callback alignment.
Data boundary
SQL rows become trusted cached objects
A scalar reaches WP_Query, then poisoned WP_Post objects enter the request cache.
Privilege transition
Customizer identity overlaps REST re-entry
A temporary administrator switch remains active during a nested REST dispatch.
Impact
Administrator creation leads to PHP execution
The protected users endpoint creates an admin; normal plugin installation runs code.
No vulnerable plugin is required. No password needs to be cracked. No MySQL FILE privilege or INTO OUTFILE trick is necessary.
The two CVEs overlap, but they do not affect every branch in exactly the same way. (Official release matrix)
| WordPress branch | Impact | Fixed release |
|---|---|---|
| 7.0.0–7.0.1 | SQL injection and the critical REST-to-RCE chain | 7.0.2 |
| 6.9.0–6.9.4 | SQL injection and the critical REST-to-RCE chain | 6.9.5 |
| 6.8.0–6.8.5 | Standalone SQL injection; not the full REST-to-RCE chain | 6.8.6 |
| Earlier than 6.8 | Not affected by these two specific issues | Not applicable |
| 7.1 beta | Both issues | 7.1 beta 2 |
The SQL injection is tracked as CVE-2026-60137 / GHSA-fpp7-x2x2-2mjf. The combined pre-authentication RCE is CVE-2026-63030 / GHSA-ff9f-jf42-662q. (SQLi advisory, RCE advisory)
WP_REST_Server::serve_batch_request_v1() effectively kept three ledgers:
In WordPress 7.0.1, a malformed path became a WP_Error in $requests. During the next loop, the error was added to $validation, but the code continued without adding a placeholder to $matches.
Later, execution walked $requests and looked up $matches[$i] using the same index:
This is a classic parallel-array desynchronization—a zipper with one missing tooth.
The affected logic can be reduced to this:
The 7.0.2 batch fix is only one line:
One important nuance: this is not simply “a public permission check paired with a privileged callback.” The entire handler tuple shifts, including its permission callback and execution callback. The primitive is more subtle: a request object prepared for one route is consumed under another route's handler and schema assumptions. (Vulnerable 7.0.1 batch implementation)
The posts REST controller exposes author_exclude and maps it internally to WP_Query's author__not_in variable. Under normal routing, the REST schema expects an array of integers. Route confusion lets the posts handler receive a request that was not sanitized against that schema. (REST parameter mapping)
In 7.0.1, WP_Query normalized the value only if it was already an array:
An attacker-controlled scalar skipped the absint() branch, was cast to an array only at implode(), and entered the SQL fragment.
WordPress 7.0.2 normalizes every accepted shape first:
The query hardening patch carries a lesson that applies well beyond WordPress: a parameter named “ID” is not safe because callers are expected to send numbers. Normalize it at the last trust boundary before SQL construction.
At this point the attacker can influence a SELECT, but MySQL is not executing operating-system commands. So how does the chain cross from query manipulation into application control?
The answer is object hydration.
WP_Query turns database result rows into WP_Post objects and primes WordPress's request-local object cache. A crafted query result can therefore behave like a post that exists for the rest of that PHP request—even when its fields did not come from a genuine row in the database. (WordPress 7.0.1 query result handling, post-cache priming)
That creates an in-memory WP_Post cache-poisoning primitive. It is not yet a durable write, so the next problem is finding legitimate core code that reads the poisoned object and writes it back.
WordPress's oEmbed cache supplies that bridge.
The exploit uses two top-level anonymous requests:
WP_Embed persists genuine oembed_cache rows.posts cache.get_post() returns the poisoned object from memory.There is a tiny PHP detail at the center of this step: the forged cached content is the string '0'. Humans see a value. PHP's empty('0') evaluates to true.
The oEmbed logic, simplified, looks like this:
That sparse wp_update_post() call is the bridge. It supplies only the ID and new content. wp_update_post() first loads every “original” field with get_post(), merges the sparse update over that object, and writes the result. If the object cache supplied forged original fields, legitimate core code persists them. (oEmbed update path, wp_update_post() merge behavior)
This is the pivotal move: data returned by SQL becomes trusted application state, and trusted application state becomes a real database update.
WordPress tries to protect hierarchical posts from impossible parent relationships. When it detects a cycle, wp_check_post_hierarchy_for_loops() repairs the loop by calling wp_update_post() on each affected member.
That normally helpful behavior becomes a recursion gadget when the “original” posts are poisoned in memory.
Our reproduced chain uses two tiny graphs:
The first repair reaches B, a poisoned object shaped as a past-due future customize_changeset. Because its scheduled date is already in the past, WordPress normalizes future to publish. That produces a real status transition and invokes _wp_customize_publish_changeset(). (Hierarchy repair, future-to-publish normalization, Customizer publish callback)
The crucial point is not the letters or IDs. It is that a safety mechanism performs additional sparse updates, and every sparse update reuses attacker-influenced cached fields.
Customizer changesets remember which user originally saved each setting. During publication, WordPress temporarily switches to that stored identity before saving the setting:
In normal operation, this is safe because the changeset's writer and capabilities were checked when the setting was created. The exploit does not use that normal write path; it fabricates the cached changeset object and its contents. The old safety argument no longer holds.
The stock nav_menus_created_posts setting is especially useful because it publishes selected draft posts with another sparse wp_update_post() call. That reaches the second hierarchy graph while WordPress's global current user is temporarily the original administrator. (Customizer user switch, navigation-setting save)
parse_requestEvery post save fires dynamic status/type hooks:
Core also registers the REST loader on an existing action named parse_request:
The second hierarchy repair reaches E, whose cached status/type pair composes that exact hook name:
This is a dynamic-hook collision. A post transition unexpectedly invokes a callback intended for the main HTTP request lifecycle. (Dynamic transition hook, default parse_request registration)
In WordPress 7.0.1, rest_api_loaded() could call serve_request() even while the REST server was already dispatching the original batch.
The nested serving cycle inherits process-wide globals—including the current user temporarily selected by the Customizer. A protected request that returned 401 during the outer anonymous pass can now be evaluated with create_users capability and return 201.
That is the authorization transition.
WordPress 7.0.2 closes it in two places. The REST loader returns early when a dispatch is active, and serve_request() refuses to start a fresh top-level cycle:
The REST re-entry patch is important defense in depth. WordPress did not rely on one signature: it repaired batch alignment, removed the SQL sink, and closed the nested-dispatch bridge.
Once the attacker owns an administrator, WordPress is behaving as designed. An administrator with install_plugins can upload and activate PHP code when filesystem modifications are allowed.
In our isolated, stock WordPress 7.0.1 reproduction, the proof sequence was:
We used a fixed argument array, captured stdout, and removed the account, plugin, options, and owned rows afterward. There was no web shell or arbitrary command parameter.
www-data is not root, but this is still full application compromise. The PHP worker can usually read wp-config.php, access database credentials, modify writable site code, steal application secrets, impersonate users, and attack other resources reachable from the web tier.
The correct distinction is:
The vulnerability is the anonymous path to administrator authority. Plugin execution is how that newly acquired authority becomes operating-system-level code execution.
| Broken assumption | 7.0.2 fix | Security effect |
|---|---|---|
| Every batch request index has a matching handler index | Add an error placeholder to $matches | Removes route confusion |
author__not_in is already an integer array | Always use wp_parse_id_list() | Removes the SQL injection sink |
| A top-level REST serving cycle cannot begin inside an active dispatch | Check is_dispatching() at both entry points | Removes privileged REST re-entry |
That three-layer repair is good security engineering. If one patch is incomplete or a variant reaches a neighboring path, the other boundaries still interrupt the chain.
Cloudflare reports that the RCE path applies when a persistent object cache is not in use. That matches the mechanism above: the two-request sequence relies on WordPress's default object cache disappearing between requests so the second request can populate those keys with forged objects. (Cloudflare analysis)
A persistent Redis or Memcached drop-in may cause this exact proof to fail because a genuine cached object survives into request two. That is an implementation detail, not a supported mitigation:
Patch the core. Do not deploy Redis as a security fix.
From a trusted host shell:
If your deployment is intentionally pinned to a maintained branch, install at least 7.0.2, 6.9.5, or 6.8.6, as appropriate. WordPress documents both the update process and wp core update.
For a specific package and locale:
The official wp core verify-checksums command runs before WordPress loads, which is useful when active application code is not yet trusted.
Core checksums do not cover wp-content. Also review repository-hosted plugins:
Private, premium, and custom plugins may not have WordPress.org checksums. “Checksum unavailable” is a prompt to compare with a trusted vendor artifact—not automatic proof of compromise. (Plugin checksum documentation)
As a short-lived compensating control, deny anonymous POST requests to the normalized REST route /batch/v1. Cover both common WordPress route forms:
A vendor-neutral policy looks like this:
Normalize encoded slashes and apply the rule at both the CDN and origin; a CDN rule does nothing if the origin is directly reachable. Test legitimate editor, plugin, and API workflows because batching is a supported feature.
Searchlight Cyber recommends this only as an emergency measure and warns that it can affect legitimate use. (Researcher mitigation guidance) Cloudflare has also deployed managed rules for both CVEs, but stresses that WAF coverage does not replace patching. (Cloudflare WAF guidance)
The version in a page's HTML or an HTTP header can be hidden, cached, or rewritten. Verify the installed package through your hosting control plane, deployment inventory, or WP-CLI, then validate official checksums.
Forced automatic updates are excellent exposure reduction, not evidence that every site updated successfully.
WordPress has not published universal attacker IPs, usernames, plugin names, hashes, or payload markers. A single 207 Multi-Status, oEmbed row, Customizer changeset, or plugin installation is not proof of exploitation. All can be legitimate.
Look for correlation:
Other useful leads include:
path mixed with otherwise valid members.author_exclude value in a request that reaches a posts handler.oembed_cache, customize_changeset, and unknown post types.post_parent relationships.The privileged users operation may occur inside the original PHP request, so it may not appear as a separate /wp/v2/users line in an edge access log. Standard access logs also rarely include request bodies.
Start by locating batch requests in copied logs:
Then inventory administrators and code from a trusted environment:
Recent timestamps are leads, not verdicts. Legitimate deployments change files, and attackers can preserve or alter timestamps.
Replace wp_ if the site uses another table prefix. Review administrator creation:
Look for two-node parent cycles:
A cycle can also result from a broken plugin or database corruption, and a capable attacker can remove evidence. Use it as an investigation pivot, not an automatic compromise finding.
Treat confirmed web-process RCE as a host incident, not merely a bad WordPress account.
.htaccess, .user.ini, wp-config.php, temporary directories, and server-level persistence.Useful session and salt rotation commands include:
These commands are documented by WordPress, but run them only after preserving evidence. (Session destruction, salt rotation)
If several arrays describe the same logical objects, every error path must preserve cardinality and alignment. Better yet, store one structured record per request.
The REST schema said “array of integers.” WP_Query still needed to normalize every input shape before constructing SQL. Defense in depth matters because dispatchers, hooks, filters, and internal callers can bypass the expected route.
get_post() does not mean “read this row from the database.” It means “obtain the current object,” possibly from memory. Code that merges cached objects into writes must consider how those objects were populated.
Temporarily changing a process-wide current user can be safe only when no unrelated work can re-enter during that window. Nested dispatch turned a short-lived internal identity into authority for a new external operation.
{$status}_{$type} looks harmless until attacker-influenced values compose the name of an existing lifecycle hook. Dynamic dispatch deserves the same threat modeling as an indirect function call.
Yes. The initial chain begins at the public REST batch endpoint and requires no existing WordPress login. The official advisory describes it as a REST batch-route confusion combined with SQL injection leading to RCE.
The official record says the opposite: route confusion combined with the SQL injection leads to RCE. Claims that the stock chain needs no SQLi are not supported by the WordPress advisory.
No. Searchlight Cyber states that the issue is exploitable on a stock installation with no plugins. The plugin appears only at the end, after administrator access is obtained, as a normal mechanism for executing PHP. (Searchlight Cyber research notice)
uid=33(www-data) the same as root?No. It is the operating-system identity of the PHP/web-server worker in many Linux deployments. It is still enough for full WordPress compromise and may expose adjacent secrets and services.
No. It can reduce exposure while the update rolls out, but it does not repair vulnerable core code. Encodings, alternate routing, origin bypass, and future variants make signature-only defenses fragile.
No. Verify the installed version and checksums. Updates can fail because of filesystem permissions, disabled file modifications, maintenance locks, custom deployment systems, or connectivity problems.
The striking part of this chain is not that WordPress exposed one obviously catastrophic function. It is that several ordinary-looking assumptions amplified one another:
Patch now—but keep the engineering lesson.
Security boundaries often fail in the seams between components, especially when one layer assumes the previous layer kept its bookkeeping straight.