## https://sploitus.com/exploit?id=443C82CF-7A26-51F1-B4A5-3B0F613AC2DD
# wp2shell β Pre-Auth RCE in WordPress core
> Unauthenticated WordPress core RCE PoC chaining CVE-2026-63030 + CVE-2026-60137. Authorized testing only.
`wp2shell.py` is a single-file, standard-library-only proof of concept that chains
**two** WordPress core vulnerabilities into unauthenticated remote code execution:
| CVE | Component | Bug |
|-----|-----------|-----|
| **CVE-2026-63030** | REST `/batch/v1` handler | route-confusion **desync** β the batch handler loses sync between its *handler* table and its *validation* table once a sub-request path fails to parse, so a sub-request is dispatched through a handler it was never validated for. |
| **CVE-2026-60137** | `WP_Query` | scalar **SQL injection** β the `author__not_in` value is pasted straight into SQL, so a string reaching it is injectable. |
Neither is RCE on its own. The desync is the *delivery* (it smuggles an attacker string
into a query it should never reach); the injection is the *primitive*. Together they give
a pre-auth attacker a full SQL read/write oracle, and from there a working shell.
> β οΈ **Authorised testing only.** This exploits live WordPress installs. Use it only
> against systems you own or have explicit written permission to test.
---
## Affected versions
Vulnerable: **6.8.0β6.8.5**, **6.9.0β6.9.4**, **7.0.0β7.0.1**
Patched: **6.8.6**, **6.9.5**, **7.0.2** (and later)
**Reported** by Adam Kues (Assetnote / Searchlight Cyber); SQLi also credited to TF1T, dtro, haongo.
**Only 6.9.0β7.0.1 are RCE-capable.** On the 6.8.x line the desync still fires, but the
branch that would misalign the handler hits a `WP_Error::get_method()` fatal instead β
so 6.8.x is a **denial-of-service / crash**, not a shell. The tool detects this and tells
you which primitive is available.
---
## How the chain works
The exploit is built in layers; each one is a bridge to the next.
1. **Desync (CVE-2026-63030).** A batch request nests a second batch. One sub-request
carries a deliberately malformed path (`http://:`, which `wp_parse_url()` rejects).
In 6.9.0+ the failed parse makes the handler and validation tables drift by one entry,
so the *next* sub-request runs under a handler it was never validated against β the
public posts collection.
2. **Injection (CVE-2026-60137).** That mis-dispatched call reaches `WP_Query` with an
attacker-controlled `author__not_in`. A `UNION ALL SELECT` there doesn't *read* rows β
it **fabricates** them, letting us forge arbitrary posts in the query result.
3. **SQL β write bridge.** The fabricated posts carry `[embed]` markup. WordPress
dutifully resolves and **caches** them as real `oembed_cache` post-meta rows β turning
a read-only injection into an arbitrary *write* primitive.
4. **Privilege pivot.** Using that write bridge we forge (a) a customizer **changeset**
authored as the real administrator and (b) a self-referential `request` post. Together
they force WordPress to **re-run its own request pipeline as that admin** β a window
just long enough for a queued `POST /wp/v2/users` call to create a new administrator.
5. **Shell.** Sign in as the new admin, upload a plugin, run the command. The account,
its meta, the oembed rows, and the webshell are all **cleaned up** afterwards β no
persistent footprint is left behind.
### Extraction oracles
For the read side (`--dump`, prefix resolution, cached IDs) the tool auto-selects the
fastest oracle that works and **stops at the first hit**:
1. **`union`** β in-band UNION: the forged row's `post_title` carries `|||HEX(value)|||`,
which is reflected verbatim in the `/wp/v2/posts` response body. Reads a whole value
(any length) in **one request**. If UNION reflects, the boolean oracles are never probed.
2. **`bool`** β boolean X-WP-Total oracle: the confused sub-response's `X-WP-Total` header
is `count>0` for true / `0` for false. One bit per request (bisection).
3. **`time`** β time-based `SLEEP()`: the fallback when the body is stripped *and* the
response is a 500 (e.g. a post-dispatch fatal from a caching mu-plugin). Works even on
targets that crash on every REST call.
`--dump` uses `GROUP_CONCAT` to pull the whole credential set in one shot when UNION is
available.
---
## Installation
None. Python 3.7+ standard library only β no `pip install`.
```bash
chmod +x wp2shell.py
```
---
## Usage
```bash
# 1. Is it vulnerable? (no injection, no writes, no account created)
./wp2shell.py http://target --check
# 2. Read-only credential dump β FIRST administrator + option secrets
./wp2shell.py http://target --dump
# β¦every administrator
./wp2shell.py http://target --dump all
# 3. Full RCE β create a temp admin, run a command, clean up
./wp2shell.py http://target --exec "id; uname -a"
```
### Read-only recon (no admin creation, bypasses 2FA)
```bash
./wp2shell.py http://target --user-list # full user table (logins/emails/roles)
./wp2shell.py http://target --content # every post type at status=any (private/draft)
./wp2shell.py http://target --content full # β¦including each item's raw body
```
### Scanning many hosts
```bash
./wp2shell.py -f targets.txt --check --target-threads 8
```
---
## Options
| Flag | Purpose |
|------|---------|
| `url` / `-f FILE` | single target, or a file of targets (one URL per line) |
| `--check` | vulnerability check only β no injection, writes, or account |
| `--dump [all]` | dump the **first** admin's hash + secrets; `all` = every admin |
| `--exec CMD` | create temp admin β run `CMD` β clean up |
| `--user-list` | elevated in-band read of the full user table (no login, bypasses 2FA) |
| `--content [full]` | elevated read of every post type at `status=any`; `full` adds bodies |
| `--oracle {auto,union,bool,time}` | force an extraction oracle (default `auto`) |
| `--prefix PFX` | force the table prefix instead of auto-resolving |
| `--impersonate UID` | impersonate this user id instead of the first admin |
| `--threads N` | concurrent extraction probes within one target (default 5) |
| `--target-threads N` | scan N targets from `-f` concurrently (default 1) |
| `--proxy URL` | route through a proxy (e.g. Burp `http://127.0.0.1:8080`) |
| `--timeout SEC` | per-request timeout (default 30) |
| `--verbose` / `-v` | dump raw batch sub-responses (debugging) |
### WAF / hardening evasion
| Flag | Purpose |
|------|---------|
| `--form` | send the batch as `multipart/form-data` (clean URL, no `?rest_route=`) |
| `--permalink [PREFIX]` | route via `//batch/v1` (default `wp-json`); keeps the payload out of the URL **and** out of `$_POST`. Tip: `--permalink index.php/wp-json` reaches REST via `PATH_INFO`, evading rules anchored on `/wp-json` |
| `--dummy N` | prepend N bytes of junk **in the body** to overflow a WAF inspection buffer |
| `--donor {widgets,categories,tags}` | switch the confusion donor route if one 500s (default `widgets`) |
| `--per-page N` | forge `per_page` (`-1` or a large positive both return all rows; use a large positive if the target 500s on `-1`) |
| `--users-route PATH` | alternate case/query for the create-user call (`/wp/v2/Users`, `β¦?_=1`) |
| `--primer PATH` | alternate malformed desync path if a WAF fingerprints the default `http://:` |
| `--user-agent UA` | pin the UA (default: a random realistic browser UA per run) |
| `--embed-base URL` | override the oembed URL base (avoid a self-embed loopback) |
---
## Output notes
- **Password hashes** (`$wp$2y$β¦` / `$P$β¦` / `$2y$β¦`) go straight to hashcat
(`-m 3200` for phpass, or the WP bcrypt mode). WordPress 6.8+ uses `$wp$β¦` bcrypt.
- **`--dump`** defaults to the first administrator only. Use **`--dump all`** for the
full admin list.
- **500-error / crash targets:** if a post-dispatch fatal (e.g. a Pantheon-style cache
mu-plugin) turns every REST response into a 500, admin-creation won't work, but reads
still do β pivot to `--oracle time --dump` to recover hashes.
---
## Why the cleanup matters
The `--exec` path is designed to leave **nothing behind**: the temporary administrator,
its usermeta, the fabricated `oembed_cache` rows, and the uploaded webshell are all removed
before the tool exits. There is no `--keep` flag β an operator should never leave a live,
attacker-created admin account on a client's system.
---
## Files
- `wp2shell.py` β the exploit (single file, stdlib only).
---
## Credits
Vulnerability discovery, disclosure, and the original "wp2shell" write-up credit belongs to
the **Searchlight Cyber / Assetnote** research team:
- **CVE-2026-63030** β REST `/batch/v1` route-confusion desync
- **CVE-2026-60137** β `WP_Query author__not_in` SQL injection
This repository is an independent proof-of-concept implementation of the exploit chain they
disclosed; it does not claim discovery of the underlying vulnerabilities. If any attribution
is incomplete or incorrect, open an issue and it will be corrected.
## References
- Searchlight Cyber / Assetnote β *wp2shell: Pre-Authentication RCE in WordPress core*
- WordPress 7.0.2 release (fix) β
- Advisories β [GHSA-ff9f-jf42-662q](https://github.com/advisories/GHSA-ff9f-jf42-662q),
[GHSA-fpp7-x2x2-2mjf](https://github.com/advisories/GHSA-fpp7-x2x2-2mjf)
## Disclaimer
This is a security research proof of concept for authorised penetration testing and
defensive validation. Running it against systems you do not own or have written permission
to test is illegal. The author accepts no liability for misuse.