mirror of
https://github.com/Icex0/wp2shell-poc
synced 2026-07-18 19:05:08 +00:00
181 lines
9.6 KiB
Markdown
181 lines
9.6 KiB
Markdown
# 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:
|
||
|
||
1. A `POST /wp/v2/posts` request that carries a `requests` body is dispatched under the batch
|
||
handler itself. Having been validated as a posts request, its `requests` list is never checked
|
||
against the batch schema, so its sub-requests may use `GET` — the method allow-list is
|
||
bypassed.
|
||
2. Inside that inner batch, a `GET /wp/v2/users` request carrying an `author_exclude` string
|
||
(the users schema has no such parameter, so the value passes validation untouched) is
|
||
dispatched under posts `get_items()`. There `author_exclude` maps to the `WP_Query`
|
||
`author__not_in` query 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 UNION fake-post primitive used by the SQLi-to-admin chain.
|
||
|
||
The RCE path implemented here is:
|
||
|
||
1. Use UNION fake `wp_posts` rows to render attacker-controlled content through a posts collection.
|
||
2. Use that render to make WordPress create real oEmbed cache posts.
|
||
3. Recover those real cache post IDs through the SQLi.
|
||
4. In one poisoned batch request, recast those IDs as a customizer changeset, navigation item, and
|
||
request hook shape.
|
||
5. Let the same request reach `POST /wp/v2/users`, creating a generated administrator.
|
||
6. Log in as that generated administrator and use plugin upload behavior to run a command.
|
||
|
||
The command-execution step is 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 — non-destructive vulnerability check
|
||
|
||
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 an SQLi payload. Use `--confirm-sqli` when you
|
||
also want an active SQLi confirmation. The confirmation tries the UNION read primitive first and
|
||
falls back to paired timing probes if UNION reflection is unavailable.
|
||
|
||
Treat the signals separately: a public version hint is only a hint, the batch marker pattern checks
|
||
for the route-confusion behavior, and `--confirm-sqli` checks whether an SQL payload reached the
|
||
database. A WAF or edge rule can block the active SQLi payload, so a failed SQLi confirmation does
|
||
not necessarily mean the route-confusion bug is absent.
|
||
|
||
```
|
||
./wp2shell.py check http://target
|
||
```
|
||
|
||
### read — extract data through 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 tries the available methods in this order:
|
||
|
||
1. **union** — forges a fake `WP_Post` row and reads its reflected title.
|
||
The source request targets the single-post item route (`/wp/v2/posts/999999`), so the
|
||
collection-only params `author_exclude`, `orderby` and `per_page` ride through unchecked; the
|
||
inner desync dispatches it under the posts *collection* handler, where `orderby=none` removes the
|
||
trailing `ORDER BY` (which otherwise breaks `UNION`) and `per_page=500` keeps `WP_Query` in
|
||
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)||`.
|
||
2. **error** — `EXTRACTVALUE`/`UPDATEXML` leak ~15 bytes per request, when the target reflects
|
||
MySQL errors (e.g. `WP_DEBUG_DISPLAY` on).
|
||
3. **blind** — boolean/timing binary search, ~8 requests per character; works with no reflection.
|
||
|
||
Force one with `--technique union|error|blind`. These read paths do not write database rows. The
|
||
union path adds the forged row to the `posts` cache for the rest of the request.
|
||
|
||
### shell — command execution
|
||
|
||
With `--user` and `--password`, `shell` logs in with supplied administrator credentials and uses
|
||
WordPress plugin upload behavior.
|
||
|
||
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 by the timing fallback for `--confirm-sqli`. |
|
||
| `--samples N` | check | Timing pairs used by the timing fallback for `--confirm-sqli`. |
|
||
| `--confirm-sqli` | check | Also send an active SQLi 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/>
|
||
- sergiointel/wp2shell-poc SQLi-to-admin bridge — <https://github.com/sergiointel/wp2shell-poc>
|