Files
2026-07-18 19:49:20 +02:00

181 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>