Vulnerability Research

CVE-2026-63030 Explained: wp2shell WordPress Core RCE

By SecureLayer7 Lab

21 min read

wp2shell: A Pre-Auth WordPress Core RCE from a Missing Array Push

WordPress runs the front end of about 40 percent of the internet. That statistic gets quoted so often it has lost its texture, but the operational reality behind it is that a pre-authentication remote-code-execution bug in WordPress Core is the closest thing the web has to a mass casualty event. Most CVEs affect a vendor’s customers. A WordPress Core RCE affects small-business marketing sites, personal blogs, news outlets, government publications, e-commerce storefronts, and the fleet of “just a landing page” installs that live on shared hosting behind a cPanel license somewhere. When one of them falls, the shared-hosting neighbor tends to fall next.

wp2shell, the vulnerability chain published under CVE-2026-63030, is the first pre-auth Core RCE in five years. It composes two defects: CVE-2026-63030 in the REST API’s batch dispatcher, and CVE-2026-60137 in WP_Query’s author__not_in parameter. Neither alone is a Critical CVE. Together they collapse the distance between “send one unauthenticated JSON POST” and “root-equivalent shell on the web-server user” to a single Python script that has been on GitHub in more than twenty forks since the weekend of the disclosure.

The bug was reported to the WordPress security team by Searchlight Cyber. The response was one of the fastest emergency releases in the project’s history: WordPress 6.9.5 and 7.0.2 shipped on 2026-07-17 with forced auto-updates enabled, meaning any site with auto-updates on will have patched within 72 hours regardless of whether the operator noticed. The subset that did not — sites with auto-updates disabled by managed hosts, air-gapped intranets, or older enterprise installs frozen at a specific version — is exactly the fleet the attackers are currently walking through. BleepingComputer and SecurityWeek confirmed mass in-the-wild exploitation on 2026-07-20.

The technical story that makes wp2shell worth writing about is not the SQL injection. SQL injection in WP_Query is a bug WordPress Core has shipped and re-shipped for a decade; the interesting part of CVE-2026-60137 is that it was previously gated behind an authenticated-user check that the community assumed would never be bypassable. The bypass — CVE-2026-63030 — is a subtle parallel-array index-drift bug in the batch endpoint that lets a completely unauthenticated request slip past authorization by pretending, at the framework’s dispatch layer, to be a different request entirely. That primitive is what wp2shell demonstrates, and it is the same shape of bug that has produced authorization bypasses in Django REST Framework, Rails ActionController, and Spring Boot batch handlers in the past three years.

The rest of this article walks the eight-step chain end to end, dissects the two-line patch that closes it, and argues that “parallel arrays that must stay index-aligned” belongs in the top tier of state-confusion bug classes that REST-batch API auditors should be looking for.

Vulnerability Overview

attack flow cve-2026-63030
wp2shell attack chain: unauthenticated POST to /wp-json/batch/v1 → array desync → SQLi → admin creation → plugin upload → RCE

Eight steps, one unauthenticated POST to start, one persistent PHP webshell to end. The chain composes two CVEs but the primitive that matters — the state confusion in the batch dispatcher — is a single missing array push.

Fingerprint. Any unauthenticated attacker with network reach to the WordPress install hits GET /wp-json/ and reads the X-WP-* response headers or the HTML meta generator tag to confirm the WordPress version. Any version in the range 6.9.0 – 6.9.4 or 7.0.0 – 7.0.1 is exploitable; earlier and later versions are not.

Confusion probe. The attacker sends a POST /wp-json/batch/v1 with a benign nested requests[] array. The response is HTTP 207 (Multi-Status) with a JSON body containing per-sub-request results. The tell is a pair of error codes: parse_path_failed on the first sub-request (the “primer”) and block_cannot_read on the second (the “carrier”). The block_cannot_read code is the confirmation marker — it means the carrier’s route match went to the wrong slot in the internal dispatcher.

SQLi confirmation. The attacker fires the same batch again with the inner request containing ?author _ exclude=0) AND (SELECT 1 FROM (SELECT SLEEP(3))_z)– -. The route confusion means the wire parameter author _ exclude is fed into WP_Query’s internal author__not_in, which a vulnerable branch string-interpolates into post_author NOT IN(…). If the response is delayed by three seconds, the SQLi is live — and it fired without any authentication, without any nonce, without any current_user_can() check.

Read wp_users. The attacker enumerates INFORMATION_SCHEMA to discover the site’s table prefix (default is wp_ but many hosts randomize it), then UNION-selects user_login, user_pass, and ID from the users table. All observed public PoCs stop here for the “read-only” attack pattern; the ones that want RCE continue to Step 5.

Admin acquisition. Two branches. (a) Crack: the extracted wp_users.user_pass is a bcrypt-HMAC-SHA384 hash on modern WordPress; attackers throw it at hashcat and, on any site whose admin uses a real password, wait. (b) Bridge: the more sophisticated PoCs (0xsha’s is the reference) use UNION SELECT to forge wp_posts rows carrying an oEmbed / customizer changeset. The changeset provides an authenticated admin context for a subsequent POST /wp/v2/users?roles=administrator call — WordPress writes the new admin row through its own code path, no raw INSERT INTO wp_users required. The bridge only works on sites without a persistent object cache (Redis / Memcached), because the customizer changeset relies on transient DB state.

Authenticate to wp-admin. With the new (or recovered) admin credentials the attacker logs in via /wp-login.php and pulls the admin nonces from /wp-admin/ to authorize plugin operations.

Plugin upload. A multipart POST /wp/v2/plugins (or the legacy POST /wp-admin/update.php?action=upload-plugin) delivers a ZIP containing {random-slug}/{random-slug}.php with a valid plugin header. WordPress unzips it into wp-content/plugins/{random-slug}/ and — if the payload activates itself in the plugin header’s activation hook — the PHP inside is now on-disk and reachable.

RCE. The webshell inside the plugin PHP responds to HTTP requests. Icex0 and 0xsha use a hash_equals($token, $_GET[‘t’])-gated passthru($_GET[‘c’]) pattern; mverschu registers a REST route wp2shell/v1/<endpoint> with permission_callback => __return_true and a base64-encoded c= parameter. Either way, the attacker now runs shell commands as the web-server user with no further authentication. All public PoCs self-clean by deactivating and deleting the plugin after the session ends.

At a glance. CVSS 9.8 (WPScan CNA) vs 7.5 (CISA-ADP disputed) — the disagreement is whether to score 63030 alone or the full chain including 60137. Affected: WordPress Core 6.9.0 – 6.9.4 and 7.0.0 – 7.0.1. Fixed: 6.9.5 and 7.0.2, both released 2026-07-17 with forced auto-updates. Required identity: none — pre-auth. User interaction: none. Wire signature: JSON POST to /wp-json/batch/v1 whose primer sub-request has a path that fails wp_parse_url() (typical values: “///” or “http://:”).

Root Cause Analysis

The batch endpoint — what it’s supposed to do

WordPress’s REST API introduced a batch endpoint in version 5.5 to solve a genuine performance problem: single-page admin interfaces sometimes needed to send five or ten API calls in a row (save a post, update its metadata, sync its featured image, trigger a cache purge), and each call carried its own round-trip cost. The batch endpoint at POST /wp-json/batch/v1 accepts a JSON body of the shape:

{
  "requests": [
    { "method": "POST", "path": "/wp/v2/posts", "body": { ... } },
    { "method": "PATCH", "path": "/wp/v2/media/42", "body": { ... } },
    { "method": "POST", "path": "/wp/v2/tags", "body": { ... } }
  ],
  "validation": "require-all-validate"
}

Internally, the batch dispatcher iterates over the sub-requests, matches each one to its registered REST route handler, validates the body against the route’s schema, and then — if the validation mode allows it — dispatches each sub-request to its handler and collects the response.

To manage this, the dispatcher maintains two parallel arrays: $validation (which holds the per-sub-request validation result or WP_Error) and $matches (which holds the route-handler tuple for each sub-request). Both are built inside WP_REST_Server::match_request_to_handler() and its batch caller, and both are supposed to be index-aligned: sub-request N corresponds to slot N in both arrays.

The missing array push

When a sub-request’s path fails to parse — for example, if the path is “///” or “http://:” or any other string that wp_parse_url() returns false for — the dispatcher takes an early-exit branch. On that branch it does two things:

  1. It pushes a WP_Error into $validation[] — good.
  2. It does not push anything into $matches[] — bad.

The next sub-request’s route match is then pushed into $matches[] at what should have been the failed sub-request’s slot. From this point on, $matches trails $validation by exactly one index. Sub-request i is validated as itself but dispatched by $matches[i], which is the handler for sub-request i+1.

two parallel arrays
Dual-array layout showing $validation and $matches before and after the primer’s missing push causes them to desync by one index

The primitive is subtle: nothing in the dispatcher’s output makes the desync obvious. The response is HTTP 207 with a valid-looking per-sub-request result array. The only wire-level tell is the block_cannot_read error code that leaks out when a sub-request that shouldn’t be reachable from the batch endpoint (typically a route that requires a nonce or a specific user context) ends up being invoked without one.

The require-all-validate mode makes it exploitable

The batch endpoint’s default validation mode is require-all-validate, which the WordPress docs describe as “if any sub-request fails validation, the whole batch fails.” Read literally, that behavior should make the missing-push bug unreachable: the primer’s WP_Error should abort the batch before any dispatch happens.

The pre-patch code does not implement that behavior correctly. It proceeds to dispatch the surviving sub-requests despite the primer’s failure. This is what the fix inverts — and it is why the wp2shell primer is a wp_parse_url failure specifically, not some other kind of validation error. Only the wp_parse_url path takes the early-exit branch that both pushes to $validation and dispatches the rest of the batch.

The nested-batch trick — where the confusion is actually reachable

If the desync only swapped sub-request 1’s handler for sub-request 2’s handler, the practical reachability would be limited: both sub-requests are still batch-callable routes, and batch-callable routes have relatively narrow security-relevant behavior. The wp2shell PoCs get around this with a nested batch: sub-request 1 (the “carrier”) is itself a POST /wp/v2/posts whose body carries an inner batch.

The desync happens at both levels. At the outer level, the primer causes $matches to shift by one, so the outer carrier’s request is dispatched by the outer closer’s handler. At the inner level, the inner primer does the same thing, so the inner carrier’s request is dispatched by the inner closer’s handler — and that inner handler is the posts-collection or users-collection route, which honors query parameters like author_exclude that go straight into WP_Query.

The two-level nesting is why the wp2shell primitive reaches the SQLi at all. A single-level batch confusion would not expose it.

Chaining with the SQL injection

CVE-2026-60137 is a SQL injection in WP_Query’s author__not_in parameter. It was previously known and had a partial mitigation: the vulnerable branch was only reachable when the caller had passed WordPress’s authorization checks, on the theory that any authenticated user was already high-privilege enough to compromise the site by simpler means.

Under wp2shell’s confusion primitive, the caller is not authenticated at all. The mis-routed handler receives the wire parameter author_exclude, binds it to the internal query var author__not_in, and the vulnerable branch string-interpolates it into the WHERE clause without escaping. The typical PoC payload is:

?author _ exclude=0) AND (SELECT 1 FROM (SELECT SLEEP(3))_z)– –

The 0) closes the NOT IN(0) clause; the AND (SELECT SLEEP(3)) gives a boolean/time-blind primitive; the _z is a required MySQL derived-table alias; the — – comments out whatever WP_Query appends after. Once the attacker confirms the injection works, they switch to UNION SELECT for direct data extraction.

From SQLi to admin — two branches

The attacker’s next objective is a working administrator credential. Two branches exist in the public PoC ecosystem:

Hash-read (Icex0 default; 0xsha’s read –preset users). The attacker uses UNION SELECT to dump wp_users.user_pass. On modern WordPress this is a bcrypt-HMAC-SHA384 hash — computationally expensive but not immune to offline cracking, especially against admins who set passwords in the WordPress 4.x era. Cracked hashes yield direct login access.

Customizer bridge (0xsha’s crack-free path). More sophisticated. The attacker uses UNION SELECT to forge wp_posts rows containing an oEmbed / customizer changeset. The changeset acts as an authenticated admin context for a subsequent POST /wp/v2/users with {“roles”:[“administrator”]} — and WordPress writes the new admin row through its own wp_insert_user() code path. There is no raw INSERT INTO wp_users anywhere in this branch; the write goes through the framework’s normal API and passes every capability check because the changeset makes it look authorized. The bridge only works on sites without a persistent object cache; Redis and Memcached break the transient state the changeset depends on.

From admin to RCE

Once the attacker has an administrator credential, RCE is the standard WordPress plugin-upload path. A multipart POST /wp/v2/plugins (or the legacy POST /wp-admin/update.php?action=upload-plugin) delivers a ZIP containing {slug}/{slug}.php with a plugin header. WordPress unzips into wp-content/plugins/{slug}/. Two webshell payload flavors appear in the public PoCs: a hash_equals($token, $_GET[‘t’])-gated passthru($_GET[‘c’]) (Icex0, 0xsha), and a registered REST route wp2shell/v1/<endpoint> with permission_callback => __return_true (mverschu).

Either way, the attacker now has PHP code execution as the web-server user. On stock Linux hosts this is www-data or apache; on shared hosting it is often a per-user account, which limits blast radius to that one site but not always — misconfigured file ownership (a chronic issue on cPanel installs) can turn a single-site compromise into a fleet-wide one via traversal into a shared file store.

Why parallel-array design is a recurring hazard

Parallel arrays that must stay index-aligned are one of the oldest state-confusion bug classes in web-framework history. Django REST Framework shipped a similar bug in its batch-serializer implementation in 2023. Rails ActionController’s collection-route handler had one in 2024. Spring Boot’s @RequestMapping array binding had a variant in early 2026. The failure mode is always the same: one array is written to on one code path, another is written to on a different code path, and the two paths disagree about whether to write on an error case.

The mitigation is also always the same: either introduce a single source of truth (a struct/object that couples the two related values together, pushed as one unit), or add a defensive length assertion before any code that indexes both arrays. WordPress’s fix does both — which is what the patch-diffing section covers.

Patch Diffing

The WordPress security team shipped the fix in two coupled commits that landed together in 6.9.5 and 7.0.2. Both are in wp-includes/rest-api/class-wp-rest-server.php.

Patch Diffing cve-2026-63030
The two coupled patch commits: fa72c128 aligns the arrays, 97b5a752 adds a defensive length check — both required for the fix

fa72c12879fbfda17d450fc1fd919f698f549f4d — the index-alignment fix

The primary fix. In the branch where a sub-request’s path fails to parse, the pre-patch code pushed a WP_Error into $validation[] and returned without touching $matches[]. The fixed code pushes a WP_Error placeholder into $matches[] too, so the two arrays stay lockstep regardless of which sub-requests fail.

The change is small in isolation — a handful of lines — and it is exactly the kind of one-liner that a code reviewer would nod at without questioning. The bug it closes is the primary wp2shell primitive; without this fix, everything downstream fails.

97b5a75246f6c82fe9d9f0ba492f06d93b602b98 — the defensive length check

The belt-and-suspenders fix. Before dispatching the batch, the patched code asserts count($validation) === count($matches). If the arrays are out of sync for any reason — including a future bug in the alignment logic — dispatch aborts with an error rather than proceeding to dispatch under a wrong route.

This second fix does not close a currently-known bug. Its purpose is to make the next parallel-array bug in this code path detectable at runtime instead of silently exploitable. In defense-in-depth terms, fa72c128 fixes the known instance; 97b5a752 protects against the next unknown instance.

The require-all-validate semantic tightening

The third component of the fix, folded into fa72c128, is a behavior change: in require-all-validate mode, if any sub-request fails validation, the whole batch now returns 400 with no dispatch. Read literally, this is what the docs always claimed the mode did — the pre-patch code just did not implement that claim correctly.

Any one of the three components — array alignment, length check, mode tightening — would have prevented wp2shell. Together they close the current bug, catch future variants at runtime, and make the batch endpoint’s documented behavior actually match its implementation.

Static Analysis

The vulnerable code path

The vulnerable code lives in WP_REST_Server::serve_batch_request_v1() and the helper match_request_to_handler() in wp-includes/rest-api/class-wp-rest-server.php. The pre-patch dispatcher, reconstructed from the WordPress GitHub tag 6.9.4:

batch dispatcher
Pre-patch batch dispatcher showing where $matches[] is not pushed on the wp_parse_url failure branch

The critical path is the early-exit branch on parse failure. The dispatcher iterates over the sub-requests, calls wp_parse_url() on each path, and on failure pushes to $validation[] before continuing. The continue statement is where the desync is born — it skips the $matches[] push that the normal path would have done.

The wire-level payload

The wp2shell PoCs converge on a specific wire pattern. The outer batch has three sub-requests: a primer with an unparseable path, a carrier that fires a nested batch, and a closer that consumes the misaligned slot. The inner batch has the same three-sub-request shape. The author_exclude SQLi rides inside the inner carrier’s query string.

wp batch capture
Terminal capture: curl POST to /wp-json/batch/v1 with the three-sub-request nested primer/carrier/closer shape, showing HTTP 207 with parse_path_failed and block_cannot_read markers

The block_cannot_read error code is the wire-level signature of a successful confusion probe. It appears when a route that requires a specific block context (typically a Gutenberg internal API) is invoked without that context, which is exactly what happens when the batch endpoint mis-dispatches into it. Any WAF or SIEM rule looking for wp2shell should key on the pair parse_path_failed + block_cannot_read in a single batch response.

The SQLi confirmation

Once the confusion is confirmed, the attacker upgrades the batch payload to carry the SQLi payload in the author_exclude query string of the inner carrier. The confirmation is a time-based probe:

SQLi confirmation
Terminal capture: SQLi confirmation via SLEEP(3) — the batch response takes 3 seconds longer than the baseline, confirming injection is live

The baseline batch response takes ~200ms on a warm cache; the SLEEP(3) probe pushes it past 3 seconds. That delay is the ground truth that the author_exclude payload reached the SQL layer without being filtered.

From confusion to admin

The attacker’s next move — either the hash-read or the customizer-bridge branch — writes state into the database. The result is verifiable at the DB level:

triage after wp2shell exploitation
Terminal capture: MySQL query showing the newly-created administrator row in wp_users with user_registered matching the exploitation timestamp

For post-compromise triage this is the highest-signal artifact: any wp_users row with user_registered after 2026-07-17 on a site that did not intentionally create new administrators is a candidate compromise indicator.

The public PoC ecosystem

Four PoCs are publicly available on GitHub as of publication, all consistent in their attack primitives:

  • 0xsha/wp2shell — most complete. Lab compose files, matrix testing, both SQLi-only and full-RCE modes, cleanest desync explanation, and the customizer bridge.
  • Icex0/wp2shell-poc — cleanest single-file runner. check/read/shell commands, stdlib-only, well-documented marker codes.
  • attackercan/wp2shell-poc2 — independent implementation with the same primitives. Useful as a diversity check that the primitive is not one author’s artifact.
  • mverschu/CVE-2026-63030 — the earliest public drop. Uses “http://:” as its primer and registers a REST-route webshell instead of the token-gated passthru pattern.

VulnCheck tallied 24+ distinct forks as of 2026-07-19. The primitive is broadly weaponized and available; the defensive posture must assume every unpatched site is a candidate compromise.

Conclusion

Impact

WordPress runs approximately 40% of the web. Even a small fraction unpatched is a large attacker-controlled fleet, and mass exploitation is already underway per BleepingComputer and SecurityWeek. Because forced auto-updates shipped with the patch, most sites will auto-patch within 72 hours of 2026-07-17 — but the tail that stays vulnerable is exactly the population that matters: managed-hosting installs where the host disables auto-updates for “stability,” air-gapped intranets that patch on a monthly cycle, older enterprise deployments frozen at a specific version for plugin compatibility, and the long-tail of “just a landing page” sites where nobody has logged into the admin panel in eighteen months.

The blast radius per compromised site is a full PHP-execution shell as the web-server user. On stock Linux hosts this is www-data or apache; on shared hosting it is often a per-user account, which theoretically limits blast radius to a single site. In practice, misconfigured file permissions on cPanel-style hosting frequently expose a shared file store where one compromised WordPress install can drop a webshell into neighboring installs. WordPress compromise → shared-host lateral movement → mass defacement, SEO poisoning, or credential harvesting has been the observed monetization pattern for every previous WordPress Core RCE, and there is no reason wp2shell will differ.

Remediation

In priority order:

  1. Confirm the auto-update landed. Run wp core version via wp-cli or check Dashboard → Updates in the admin panel. A version of 6.9.5 or 7.0.2 (or later) means the patch is in place. Anything else means you need to act.
  2. Patch immediately if auto-updates were disabled. wp core update from the command line, or manually upload the release tarball. Do this before touching anything else in this list.
  3. Block /wp-json/batch/v1 at the WAF or nginx layer if you cannot patch yet. A conservative rule blocks the whole endpoint from unauthenticated sources; a surgical rule blocks any batch request whose sub-request paths include “///” or fail basic URL parsing. Cloudflare, Sucuri, and Wordfence have all shipped rules; check whichever WAF is in front of your sites.
  4. Audit wp_users for administrator rows created after 2026-07-17. The SQL query is SELECT ID, user_login, user_email, user_registered FROM wp_users WHERE user_registered > ‘2026-07-17’. Cross-reference against your own admin creation records; anything unexpected is a candidate compromise.
  5. Audit wp-content/plugins/ for suspicious directories. Look for plugin slugs with random-hex names, single-letter names, or sequential numbers. Any plugin whose only file is a single .php file containing hash_equals($token, $_GET[‘t’]), passthru($_GET, or permission_callback => __return_true is a webshell.
  6. Rotate all administrator credentials on any site that was unpatched after 2026-07-17. The SQLi could have read the hashes even if the RCE path was never taken.

Detection

Pre-compromise wire signatures: – POST /wp-json/batch/v1 from unauthenticated source IPs. Legit batch requests are almost always authenticated (they piggyback on an admin session cookie or an application password). – Missing X-WP-Nonce header on any batch request. WordPress’s own admin JavaScript always sends the nonce; wp2shell PoCs never do. – HTTP 207 batch response containing both parse_path_failed and block_cannot_read error codes. This is the confusion probe’s confirmation marker. – ?author _ exclude= query parameter with a non-integer value. Any value starting with 0), containing SELECT, or containing MySQL comment sequences is a strong indicator.

Post-compromise indicators: – New administrator rows in wp_users created after 2026-07-17 that were not created by your own operators. – New plugin directories in wp-content/plugins/ created after 2026-07-17 with random or suspicious slugs. – PHP files in wp-content/plugins/*/ containing hash_equals(, passthru(, shell_exec(, or permission_callback => __return_true — all present in the observed webshell variants. – Webshell activity in access logs: GET /?rest_route=/wp2shell/v1/…, GET /wp-content/plugins/{slug}/{slug}.php?t=…&c=…, or similar patterns.

References