wp2shell / CVE-2026-63030: A Security Patch Analysis of WordPress 7.0.2
A code-level analysis of the route-confusion, REST-lifecycle, and SQL-input hardening in WordPress 7.0.2, released in response to wp2shell and the related SQL-injection flaw.
Published on July 18, 2026 by Maciej Szymczak · Updated July 19, 2026 · 18 min read

In this article
- Introduction
- What the public evidence establishes
- Evidence scope
- Affected version ranges
- Disclosure and patch timeline
- The batch endpoint remains supported
- Two URLs, one REST route
- How the batch handler confusion enabled the chain
- The one-line correction
- The 6.9 regression: a crash became confused state
- WordPress also blocks recursive REST serving
- Hardening the author-exclusion SQL path
- From route confusion to SQL injection
- The reported no-FILE execution path
- Conditions and impact
- Prerequisites and non-prerequisites
- Offensive analysis: attacker objective and boundaries
- Conditional INTO OUTFILE variant
- Verification scope
- Reproduction status
- Detection and response
- Patch chronology and review targets
- Conclusion
Introduction
This is a code-level analysis of the WordPress 7.0.2 changes shipped in response to wp2shell: CVE-2026-63030, a REST batch-route-confusion flaw that leads to pre-authentication remote code execution when chained with CVE-2026-60137, an SQL injection in WP_Query::author__not_in.
The patch is more interesting than an endpoint block. WordPress did not remove, rename, or disable the REST batch endpoint at /batch/v1. It repaired the internal boundaries that allowed a batch subrequest to become detached from the route handler that was matched and validated for it.
The source changes establish the defensive boundaries, while independent public implementations describe concrete SQLi-to-administrator paths. WordPress 7.0.2 was released on July 17, 2026, and supported older branches received backports. See the official 7.0.2 security summary, the researcher’s disclosure note, and Cloudflare’s deployment analysis.
What the public evidence establishes
The WordPress security advisory for CVE-2026-63030 classifies the batch-route confusion combined with SQL injection as critical RCE in 6.9.0–6.9.4 and 7.0.0–7.0.1. The companion CVE-2026-60137 advisory confirms that the author__not_in SQL injection also affected 6.8.0–6.8.5.
Swipe horizontally to compare
| Question | Evidence available in public sources |
|---|---|
| Did the batch handler desynchronize request, validation, and match state? | Yes — directly visible in the pre-patch and patched code. |
| Could validation and execution concern different subrequests? | Yes — a direct consequence of the shifted $matches index. |
Was author__not_in able to reach SQL without scalar integer normalization? | Yes — directly visible in the pre-patch WP_Query code. |
| Does the combined issue lead to pre-authentication RCE? | Yes — classified as such in the WordPress GHSA. |
Is there a public no-FILE RCE path? | Yes — independent public implementations describe one; Cloudflare separately confirms the cache condition for the known RCE path. |
Searchlight Cyber's original technical write-up remains withheld, but independent implementations now disclose concrete route selection and no-FILE SQLi-to-administrator chains. This article intentionally omits reusable nested request bodies, SQL expressions, and forged-object graphs while describing the relevant security-boundary crossings at a defensive level.
Evidence scope
This is a source-and-patch analysis, not a claim of an original exploit reproduction. The Core diffs and defensive invariants were verified directly. The no-FILE mechanism is attributed to an independently published implementation at a pinned commit; the historical INTO OUTFILE variant is likewise pinned below.
Affected version ranges
Swipe horizontally to compare
| Version | Batch-route confusion | author__not_in SQL injection | Security status |
|---|---|---|---|
| 6.8.0–6.8.5 | Not present in this form | Affected | SQL injection; not the combined RCE chain |
| 6.8.6 | Not present in this form | Patched | Patched |
| 6.9.0–6.9.4 | Affected | Affected | Combined issue classified as RCE |
| 6.9.5 | Patched | Patched | Patched |
| 7.0.0–7.0.1 | Affected | Affected | Combined issue classified as RCE |
| 7.0.2 | Patched | Patched | Patched |
The range is significant: the SQL-injection issue existed in 6.8, but WordPress’s advisory identifies the combined RCE impact from 6.9 onward, when the batch-route confusion was present. See CVE-2026-60137 and CVE-2026-63030.
Disclosure and patch timeline
Swipe horizontally to compare
| Date | Event |
|---|---|
| August 14, 2025 | r60635 changes malformed batch-path handling to return a structured error, introducing the parallel-array misalignment. |
| July 17, 2026 | WordPress releases 7.0.2 and supported-branch backports; the three relevant runtime changes are included in the release. |
| July 17, 2026 | WordPress disclosed the issues to Cloudflare before public release; Cloudflare says its WAF rules were deployed at 17:03 UTC. Cloudflare’s announcement records both facts. |
| July 18, 2026 | Independent public reporting and implementations describe the cache-dependent, no-FILE execution model. The Hacker News coverage notes the public mechanism and working PoC. |
The batch endpoint remains supported
WordPress 7.0.2 still registers the same public REST route:
Route: /batch/v1
Method: POST
Callback: WP_REST_Server::serve_batch_request_v1()Normal batch requests continue to work, including mixed-result batches that return 207 Multi-Status. The patch changes the internal safety of batch dispatch, not the endpoint's public contract. The one-line batch-error alignment commit modifies the existing callback rather than removing the route.
That distinction matters operationally. A URL-level block can reduce exposure during an emergency, but it can also break legitimate clients. Upgrading is the durable fix.
Two URLs, one REST route
These two requests reach the same logical endpoint:
POST /wp-json/batch/v1
POST /?rest_route=/batch/v1With pretty permalinks enabled, WordPress rewrites the first form to the equivalent rest_route=/batch/v1 value. The second form supplies that value directly. rest_api_loaded() then passes /batch/v1 to the REST server, which has one registered handler for the route.
/wp-json/batch/v1 ┐
├──> rest_route=/batch/v1 ──> serve_batch_request_v1()
/?rest_route=/batch/v1 ┘There is no separate “pretty URL” batch implementation to patch. Both aliases use the corrected 7.0.2 code path; the REST API/rewrite source shows that alias convergence. The REST lifecycle guard commit is evidence for the separate re-entry protection discussed below.
For WAFs, reverse proxies, and middleware, this is a useful reminder: a policy that checks only the path can miss an equivalent query-string route. Searchlight Cyber's disclosure note listed both forms as temporary mitigation targets. Read the disclosure note.
How the batch handler confusion enabled the chain
The batch handler processes a list of subrequests. During validation, it keeps parallel collections that can be thought of as:
$requests[$i]
$validation[$i]
$matches[$i]For every input item, all three positions must describe the same subrequest:
$requests[$i]holds the parsed request.$validation[$i]records whether that request is valid.$matches[$i]holds its matched route and handler.
In WordPress 7.0.1, a path-parsing failure was placed in $validation, but no corresponding element was added to $matches:
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request;
continue;
}This created an index shift. In a batch with a malformed path followed by Route A and Route B, the match for Route A could be stored at $matches[0] and the match for Route B at $matches[1]. When execution reached the original request at index 1, it would read $matches[1]—the handler for Route B.
Swipe horizontally to compare
| Input index | Subrequest | Stored match before 7.0.2 |
|---|---|---|
| 0 | Malformed path | No entry |
| 1 | Route A | matches[0] |
| 2 | Route B | matches[1] |
The broken invariant was simple but security-critical:
request[i] must execute handler[i]A subrequest could carry Route A's request object and validation result into Route B's permission and execution callbacks. The defect therefore separated schema enforcement from the handler that ultimately consumed the request.
The important limitation is that this was not a universal permission-check bypass. respond_to_request() still runs the shifted handler's permission_callback before its callback. The resulting state is more precisely described as:
Swipe horizontally to compare
| Stage | Interpreted as |
|---|---|
| Request parameters | Route A request object |
| Schema and sanitization | Route A |
| Permission callback | Route B |
| Execution callback | Route B |
An effective chain therefore needed an anonymously reachable handler whose behavior remained useful after its own permission callback ran. The patch removes that request-routing defect.
The one-line correction
The 7.0.2 change adds an error placeholder to the match collection as well:
if ( is_wp_error( $single_request ) ) {
$has_error = true;
+ $matches[] = $single_request;
$validation[] = $single_request;
continue;
}Every batch item now consumes a corresponding position in $matches, including an invalid item. The invalid item is returned as its own error response; the placeholder ensures that later valid requests continue to read their own $matches[$i] entries.
|$requests| = |$validation| = |$matches|Small patches can carry large security consequences. This one restores the integrity boundary between route matching and dispatch, so a request can no longer borrow a later request's handler. Review the complete batch-error alignment diff.
The 6.9 regression: a crash became confused state
The bug was introduced while fixing an earlier robustness failure. In WordPress 6.8, a malformed batch path could leave a WP_Error where later code expected a WP_REST_Request, causing a fatal error and HTTP 500. Trac ticket #63502 documents both the failure and the intended parse_path_failed response.
The 6.9 change (r60635) correctly preserved the parse error in $requests and $validation, but did not reserve its corresponding slot in $matches. The effect was a regression in state consistency:
6.8: malformed path → fatal error / HTTP 500
6.9: malformed path → structured error
→ shifted handler-match sequence
→ route confusionThe crash was not a security control, but its replacement needed to preserve the global positional invariant across all parallel structures. The 7.0.2 placeholder does exactly that.
WordPress also blocks recursive REST serving
WordPress 7.0.2 also prevents a REST request from starting another top-level REST-serving lifecycle while a dispatch is already active. The recursive-serving guard commit adds checks in both WP_REST_Server::serve_request() and rest_api_loaded(). In serve_request(), the guard effectively does this:
if ( $this->is_dispatching() ) {
return false;
}rest_api_loaded() has a matching guard. Nested top-level serving now returns false or stops before starting another REST lifecycle in the same PHP request. In the publicly reported no-FILE chain, that blocks the REST re-entry needed after the temporary administrator-context switch. It is also defense in depth: the batch-alignment and SQL-normalization fixes independently remove the earlier primitives.
Hardening the author-exclusion SQL path
The release also makes WP_Query normalize author__not_in consistently through wp_parse_id_list(). Before 7.0.2, integer normalization depended on the value already being an array; a scalar took a different path before being incorporated into the author-exclusion SQL condition:
if ( is_array( $query_vars['author__not_in'] ) ) {
$query_vars['author__not_in'] = array_unique(
array_map( 'absint', $query_vars['author__not_in'] )
);
sort( $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) ";For a scalar, the type check failed, (array) retained the original string as one element, and implode() carried it into the SQL fragment. The patched code always produces a normalized list of integer IDs before constructing NOT IN, and updates the query variable for stable cache-key generation. This prevents untrusted text from reaching the SQL fragment. Review the exact WP_Query hardening diff.
From route confusion to SQL injection
The security-relevant composition is an interpretation conflict, not a direct command-execution flaw. At a high level, route confusion can cause a request object validated under one route’s schema to be interpreted by another route’s callback. Analyses of the chain describe applying that mismatch more than once: first to obtain nested batch processing outside the expected outer-batch schema, then to deliver a scalar value to a posts-collection path that ultimately maps an author-exclusion parameter to WP_Query::author__not_in.
The important property is not any single endpoint name. The callback consuming a collection-only parameter was paired with a request validated against a different schema that did not declare that parameter. Once the scalar reaches the pre-patch query path, the type-specific sanitization shown above is skipped.
SQL injection alone is not automatically code execution. The conditional file-write technique discussed below is one post-SQLi route. A separate no-FILE route to application takeover and code execution explains the persistent-object-cache condition reported by Cloudflare.
The reported no-FILE execution path
The no-FILE path does not ask MySQL to write a PHP file. Instead, it uses the SQL injection to return rows that WordPress subsequently treats as WP_Post objects. In the reported chain, those forged objects occupy WordPress’s in-memory posts cache for the remainder of the active PHP request. This is semantic object confusion and same-request cache poisoning in WordPress's object model—not PHP memory corruption. At the initial cache-poisoning stage, a real database ID can be represented in memory as a different post type and status without immediately changing the corresponding wp_posts row.
At a high level, the public chain proceeds as follows:
- Batch-route confusion lets a scalar author-exclusion value reach
WP_Query. - SQL injection returns forged post-shaped rows.
- Those rows poison the per-request post cache.
- A forged Customizer state causes WordPress to temporarily switch
current_userto an existing administrator. - A dynamic post-status hook re-enters REST dispatch.
- The nested request creates an attacker-controlled administrator account.
- Normal administrator code-installation capability produces code execution.
This explains why the issue is accurately described as pre-authentication RCE even though the final code-installation action runs under an administrator account created during the same chain. No pre-existing credentials, victim login, or user interaction are required. The public implementation later authenticates with the administrator account created by the pre-authentication chain and uses the application's ordinary plugin-installation capability to execute code. Cloudflare confirms the practical condition that matters for this route: it applies when a persistent object cache is not in use. Cloudflare’s analysis provides that operational qualification.
The chain’s individual Core primitives are consistent with the 7.0.2 patch set:
- WordPress stores posts in an object cache, so a query result can affect subsequent
get_post()-style lookups during the request. - Customizer publication can save settings on behalf of the user ID attached to a setting; the relevant behavior is in
WP_Customize_Manager. - Post-status transitions expose dynamic hooks based on the new status and post type; the relevant code is in
wp_transition_post_status(). - Before 7.0.2,
rest_api_loaded()could begin another top-level REST serving cycle during an active dispatch. The recursive-serving guard diff closes that re-entry point.
The public wp2shell implementation documents the reported no-FILE chain. Its implementation requirements are distinct from the CVE prerequisites and are listed below.
Conditions and impact
This reported path avoids MySQL’s global FILE privilege, secure_file_priv, a database-to-webroot mapping, and administrator-password cracking. It is not, however, implementation-independent. Cloudflare qualifies the known execution path as applying where a persistent object cache is not in use. Searchlight separately describes its original stock-install attack as having no preconditions; the public material does not establish that these are identical implementations. The final code-installation stage also depends on the site allowing the resulting administrator to install and activate code; deployments that disable file modifications can still face pre-authentication administrator creation and application takeover even if that final step is unavailable.
Persistent object caching is not a patch. It may prevent this particular cache-dependent execution route, but it does not restore batch-array alignment or normalize author__not_in. Upgrade WordPress regardless.
Prerequisites and non-prerequisites
Swipe horizontally to compare
| Factor | Relevance to the reported no-FILE path |
|---|---|
| Vulnerable Core version | Required: 6.9.0–6.9.4 or 7.0.0–7.0.1. |
| Reachable REST batch endpoint | Required for the affected request path. Both URL representations must be considered. |
| Persistent object cache | The known code-execution route requires that one is not in use. This is a condition, not a remediation. |
MySQL FILE / secure_file_priv | Not required for the public no-FILE chain. |
| Existing administrator account | The public implementation requires an existing administrator identity to impersonate temporarily; its password or session is not required. |
| Public post or page and working self-oEmbed path | Required by the public implementation to create backing oembed_cache records. This is an implementation requirement, not a condition stated by the CVE. |
| WordPress deployment mode | The reviewed implementation targets single-site WordPress and creates an account with the administrator role. It does not create a Multisite Super Admin. Its administrator-creation and plugin-installation stages therefore require separate validation on Multisite. |
| Plugin-install capability | Required for the public PoC’s final command-execution stage on single-site WordPress. |
| File-modification policy and filesystem access | DISALLOW_FILE_MODS, filesystem permissions, or equivalent controls can block plugin installation even where the account role would otherwise permit it. |
The relevant operational conclusion is straightforward: do not use an object-cache deployment, disabled file modifications, or a missing MySQL FILE grant as a reason to defer patching. They can change one outcome or variant, but they do not correct either vulnerability.
Offensive analysis: attacker objective and boundaries
From an offensive perspective, the decisive property is composability. None of the three primitive failures is equivalent to shell_exec() on its own. The impact comes from preserving attacker-controlled interpretation across several Core subsystems until an unauthenticated request obtains an administrator-controlled action.
Swipe horizontally to compare
| Boundary | Attacker-controlled effect |
|---|---|
| Route confusion | A request reaches a handler under the wrong schema. |
| SQL construction | A scalar reaches an integer-only query context. |
| Object interpretation | Forged post-shaped data affects same-request state. |
| REST re-entry | A privileged context is used for a second dispatch. |
| Administrator action | Ordinary code-installation capability becomes available. |
That distinction matters during incident response. A request that only targets /batch/v1 is suspicious, but it does not by itself demonstrate code execution. Stronger indicators include a malformed batch subrequest, nested requests data in the original HTTP body, unexpected author_exclude values, and an administrator account created without a corresponding pre-existing authenticated session. The REST re-entry occurs inside the same PHP request and normally will not appear as a second inbound access-log entry; observing it directly requires application tracing or comparable instrumentation.
Before 7.0.2, one input sequence advanced while the handler-match sequence did not. WordPress restores that one-to-one mapping, blocks nested top-level REST serving during an active dispatch, and forces author__not_in through integer-list parsing before SQL construction. These are structural corrections—not URL filters or request signatures—and they remove the components an attacker would otherwise compose.
Conditional INTO OUTFILE variant
INTO OUTFILE is a separate, configuration-dependent SQLi-to-code path. A historical independent PoC pinned at commit f10acf558e4bbf8ba3c46e18b99645a471188e96 demonstrates a pre-authentication RCE variant that writes a PHP file with MySQL INTO OUTFILE and retrieves it through the web server. The reference is pinned because the repository's current default branch has since replaced the FILE-privilege variant with a no-FILE chain.
The historical variant requires MySQL’s global FILE privilege, a secure_file_priv policy that permits the chosen destination, an existing target directory, and a destination file that does not already exist. The path is on the MySQL server's filesystem; the mysqld operating-system user must be able to create it, and the web tier must be able to read it through a PHP-executable mapping. MySQL documents the FILE privilege and secure_file_priv.
This is a configuration-dependent escalation route. It is not evidence that MySQL OUTFILE is available on every WordPress installation.
Verification scope
The core analysis is based on the public advisories, the release tag, the affected WordPress source code, and the three security commits. Together, they establish the corrected security boundaries:
Swipe horizontally to compare
| Commit | Correction | Security boundary restored |
|---|---|---|
c8bdf1fa1235 | Preserves the missing $matches[] position. | A request cannot acquire a later subrequest’s handler. |
85015b84fbc5 | Prevents a nested top-level REST serving cycle. | An active dispatch cannot re-enter the REST lifecycle. |
74d37a344cbf | Parses author__not_in as an integer-ID list. | Arbitrary text cannot reach the author-exclusion SQL expression. |
Reproduction status
Swipe horizontally to compare
| Subject | Revision or environment | Review date | Status |
|---|---|---|---|
| WordPress Core patch analysis | 7.0.1 / 7.0.2 source and the three security commits | July 19, 2026 | Statically reviewed |
Public no-FILE implementation | 1d61bb049b668ec9ba80ce8729da3f04c79b7371 | July 19, 2026 | README, exploit source, and referenced WordPress Core paths reviewed statically; not executed |
| Local SQLi-to-RCE reproduction | N/A | N/A | Not run for this article |
The public implementation is cited as an independent source, not as a local reproduction.
Detection and response
Treat an unpatched installation as an incident-risk condition, not merely a maintenance issue. Upgrade to 7.0.2 or the supported backport first; URL blocking and WAF signatures are temporary containment measures.
During triage, review WAF or request-body telemetry for anomalous anonymous requests to both REST batch aliases, malformed batch subrequests, nested requests objects, and unusual values reaching post-collection query parameters. Standard web-server access logs typically expose the outer batch request but not the internal REST re-entry. Review WordPress user records and audit logs for administrator accounts created outside an expected administrative workflow. If an unknown administrator account or code change is found, preserve logs and relevant database records before removing access, then rotate WordPress salts, administrator credentials, database credentials, and other secrets that may have been exposed.
For temporary protection, ensure the relevant WAF protections are enabled and blocking. Cloudflare published separate rules for CVE-2026-60137 and CVE-2026-63030, but explicitly states that WAF coverage does not replace applying the WordPress update. See Cloudflare’s mitigation guidance.
Patch chronology and review targets
The three runtime files revised in the security release are:
wp-includes/rest-api/class-wp-rest-server.php
wp-includes/rest-api.php
wp-includes/class-wp-query.phpView the official version documentation.
When reviewing the source history, do not mistake the release-tag revision for the security patch. The relevant changes landed immediately before the final 7.0.2 tag:
85015b84fbc5: subrequests must use dispatch — SVN r62772.c8bdf1fa1235: batch-request errors must propagate — SVN r62773.74d37a344cbf: forceauthor__not_invalues to integers — SVN r62774.
The 7.0.1–7.0.2 GitHub comparison includes those runtime changes alongside normal release housekeeping.
Conclusion
WordPress 7.0.2 does not disable the REST batch API. It restores three independent security invariants: every subrequest retains its own matched handler, an active REST dispatch cannot start another top-level serving cycle, and author__not_in is normalized to integer IDs before SQL construction.
Those are structural corrections rather than request signatures or endpoint blocks. Sites running WordPress 6.9.0–6.9.4 or 7.0.0–7.0.1 should be treated as exposed until a patched version is verified. WAF coverage, persistent object caching, or disabled plugin installation may alter one public exploitation path, but none replaces the Core update.
