wp2shell-poc
Independent proof-of-concept for the unauthenticated WordPress REST batch route-confusion SQL injection associated with Searchlight Cyber's wp2shell advisory.
The unauthenticated primitive reaches the injection through a single endpoint:
POST /wp-json/batch/v1.
This repository is not Searchlight Cyber's official checker. check confirms the SQLi path,
read demonstrates database read, and shell opens a plugin-backed command shell either with
supplied administrator credentials or by first exercising the SQLi-to-admin bridge.
Affected versions
Searchlight Cyber's advisory lists these wp2shell RCE exposure ranges:
| Version range | Status |
|---|---|
| <= 6.8.5 | Not affected |
| 6.9.0 – 6.9.4 | Affected |
| 7.0.0 – 7.0.1 | Affected |
How it works
The REST batch endpoint (/batch/v1) is unauthenticated and runs several sub-requests in one
call, relying on each sub-request being validated and permission-checked on its own.
serve_batch_request_v1() builds two parallel arrays — $matches (the matched handler per
sub-request) and $validation (the validation result per sub-request) — then indexes both by
the same offset when dispatching. A sub-request whose path fails wp_parse_url() is appended to
$validation but not to $matches, so the arrays fall out of step and a sub-request is
dispatched under a different sub-request's handler. That is the route confusion.
The PoC nests the primitive twice:
- A
POST /wp/v2/postsrequest that carries arequestsbody is dispatched under the batch handler itself. Having been validated as a posts request, itsrequestslist is never checked against the batch schema, so its sub-requests may useGET— the method allow-list is bypassed. - Inside that inner batch, a
GET /wp/v2/usersrequest carrying anauthor_excludestring (the users schema has no such parameter, so the value passes validation untouched) is dispatched under postsget_items(). Thereauthor_excludemaps to theWP_Queryauthor__not_inquery var, which the vulnerable build interpolates into SQL as a string.
The result is a boolean- and time-based blind SQL injection reachable pre-authentication. This PoC also includes the stronger UNION fake-post primitive used by the SQLi-to-admin chain.
The RCE path implemented here is:
- Use UNION fake
wp_postsrows to render attacker-controlled content through a posts collection. - Use that render to make WordPress create real oEmbed cache posts.
- Recover those real cache post IDs through the SQLi.
- In one poisoned batch request, recast those IDs as a customizer changeset, navigation item, and request hook shape.
- Let the same request reach
POST /wp/v2/users, creating a generated administrator. - Log in as that generated administrator and use normal plugin upload behavior to run a command.
The last command-execution step is ordinary authenticated administrator plugin upload. The pre-authentication part is the admin creation bridge.
Requirements
Python 3.8+ and the standard library. No third-party dependencies.
Usage
Run it from the repository directory:
./wp2shell.py <command> <url> [options]
Or pip install . to get a wp2shell command on your PATH.
check — confirm the vulnerability (safe)
Prints passive WordPress markers and public version hints first, then sends a benign batch marker
probe. A vulnerable batch implementation returns HTTP 207 with the route-confusion marker pattern
parse_path_failed, block_cannot_read, and rest_batch_not_allowed.
The marker probe is based on the WordPress core fix. The malformed /// request creates
parse_path_failed; a /wp/v2/posts request acts as a batch-allowed spacer; the
/wp/v2/block-renderer/... route is not batch-allowed but returns block_cannot_read if its
handler is reached anonymously; /batch/v1 gives rest_batch_not_allowed. On vulnerable builds
the parse error shifts the batch handler arrays out of step, so the spacer request is dispatched
under the block-renderer handler. Fixed builds keep the arrays aligned, so this exact all-three
pattern should not appear for the crafted probe.
By default, check stops there and does not send a SQL timing payload. Use --confirm-sqli when
you also want the active paired timing confirmation. The timing step reads no data and changes
nothing; it sends three baseline/delayed pairs by default and decides on the median delta.
Treat the signals separately: an affected public version is strong triage evidence, the batch marker pattern confirms the vulnerable route-confusion behavior, and timing confirmation proves the SQLi path reached the database. A WAF or edge rule can block the timing payload, so a failed timing check does not override a positive marker probe.
./wp2shell.py check http://target
read — extract data (blind SQL injection)
./wp2shell.py read http://target # server fingerprint
./wp2shell.py read http://target --preset users # user logins and password hashes
./wp2shell.py read http://target --query "SELECT @@version"
By default extraction is --technique auto, which picks the fastest in-band method that works:
- union — forges a fake
WP_Postrow and reads its reflected title: one request per value. The source request targets the single-post item route (/wp/v2/posts/999999), so the collection-only paramsauthor_exclude,orderbyandper_pageride through unchecked; the inner desync dispatches it under the posts collection handler, whereorderby=noneremoves the trailingORDER BY(which otherwise breaksUNION) andper_page=500keepsWP_Queryin full-row mode (so the union row survives instead of being re-primed from the DB). A whole value comes back in the REST response as||HEX(value)||. - error —
EXTRACTVALUE/UPDATEXMLleak ~15 bytes per request, when the target reflects MySQL errors (e.g.WP_DEBUG_DISPLAYon). - blind — boolean/timing binary search, ~8 requests per character; works with no reflection.
Force one with --technique union|error|blind. All three are strictly read only. The union
path also demonstrates fake-WP_Post object-cache poisoning: the forged row is added to the posts
cache for the rest of the request, and its post_content blocks render through REST (which enables
unauthenticated SSRF via RSS/oEmbed blocks). A full password hash is ~2 requests with union, ~7
with error, versus ~490 blind.
shell — command execution
With --user and --password, shell logs in with supplied administrator credentials and uses
normal WordPress plugin upload behavior. This mode would work the same way on a patched site where
those credentials are valid.
Without credentials, shell first runs the pre-auth SQLi-to-admin bridge, logs in as the generated
administrator, then uploads the plugin shell.
./wp2shell.py shell http://target --user admin --password '<recovered>' --cmd id
./wp2shell.py shell http://target --user admin --password '<recovered>' -i # interactive shell
./wp2shell.py shell http://target --rest-route --cmd id # pre-auth bridge
./wp2shell.py shell http://target --rest-route -i # pre-auth interactive
shell uploads a plugin webshell (locked behind a random path and a per-run token) and prints its
path. The uploaded webshell is removed automatically. When the pre-auth bridge creates an
administrator, that generated account is removed automatically after the shell session finishes.
Options
| Option | Applies to | Description |
|---|---|---|
--rest-route |
check, read | Use /?rest_route=/batch/v1 (for sites without pretty permalinks). |
--proxy URL |
all | Route traffic through an HTTP proxy (for example, Burp). |
--timeout N |
all | Request timeout in seconds. |
--sleep N |
check | Delay used with --confirm-sqli. |
--samples N |
check | Timing pairs used with --confirm-sqli (default 3). |
--confirm-sqli |
check | Also send the active SQL timing confirmation payload. |
--preset |
read | fingerprint or users. |
--technique |
read | auto (default), union (in-band, forges a fake post), error (in-band, needs visible DB errors), or blind. |
--query |
read | A scalar SQL expression to read. |
--prefix |
read | Database table prefix (default wp_). |
--max-length N |
read | Maximum characters read per value (default 128). |
--user / --password |
shell | Optional admin credentials; omit both to use the pre-auth bridge. |
--cmd |
shell | Command to run (omit when using -i). |
-i / --interactive |
shell | Open an interactive shell after deploying. |
Remediation
Update to WordPress 7.0.2, or 6.9.5 if the site is on the 6.9 branch. Until then,
block both /wp-json/batch/v1 and the rest_route=/batch/v1 query parameter at
the edge, or require authentication for the batch endpoint via the
rest_pre_dispatch filter.
Legal
For authorized security testing only. Use it exclusively against systems you own or have explicit written permission to test. No warranty is provided and no liability is accepted for misuse.
References
- WordPress 7.0.2 release announcement — https://wordpress.org/news/2026/07/wordpress-7-0-2-release/
- Searchlight Cyber wp2shell advisory — https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/