Share
## https://sploitus.com/exploit?id=A72B0077-7929-5AAE-B25E-DD597CAC645C
![plot](docs/shell.svg)


# How does this work at all

![plot](docs/exploit-chain.png)


# How this PoC differs from the public wp2shell exploits

all pocs weaponize the same two bugs - the REST batch route confusion (CVE-2026-63030) and the
`author__not_in` SQL injection (CVE-2026-60137) - with the same double-nested batch shape.
What differs between them is the **RCE path chosen**, the **environment preconditions**,
and the **safety defaults**. This document states, concretely, where the implementation in
this repository sits in that landscape.

## The short version

1. **Works behind a persistent object cache.** The public UNION-based PoCs use the
   populated-base injection form: the full-chain implementation this lineage shares
   false-negatives there, and the single-file unifier lists "no persistent object cache" as
   an explicit precondition. This repo's killed-base form keeps the UNION channel - and
   therefore the whole pre-auth RCE bridge - alive on exactly those hosts (the common
   managed-WordPress setup). See §1.
2. **Safe to run against production by default.** `check` sends no SQL payload unless asked;
   all traffic can carry an attribution tag; everything the `shell` command writes to the
   target is removed automatically afterwards. See §3.
3. **Field-validated, not just lab-verified.** The behavior in §1 was observed, diagnosed,
   and confirmed against a real production host during an authorized engagement; the
   resulting stage-by-stage detection guide ships in [CASE-STUDY.md](CASE-STUDY.md).

## Comparison table

| Capability | This repo | Icex0/wp2shell-poc | sergiointel/wp2shell-poc | 0xsha/wp2shell | OUTFILE variant [4] |
|---|---|---|---|---|---|
| Pre-auth blind/timing SQLi read | yes | yes | yes (timing) | yes | yes |
| In-band UNION read (1 request/value) | yes | yes | - [1] | - [1] | - |
| Error-based read (EXTRACTVALUE) | yes | yes | - | - | - |
| UNION channel survives persistent object cache | **yes (killed-base)** | no - probe false-negatives [2] | not documented | no - documented precondition [3] | n/a [5] |
| Crack-free pre-auth RCE | yes (SQLi-to-admin bridge) | yes (same bridge) | yes (bridge origin) | yes (same bridge) | yes, via `INTO OUTFILE` [5] |
| Extra preconditions for RCE | none beyond default install | none (on non-object-cache hosts) | none (same) | none (same) | MySQL `FILE` priv + web-writable path shared with mysqld |
| Non-destructive check / patch validation | yes (marker triplet; no payload by default) | yes | no | yes (`block_cannot_read`) | yes (marker batch) |
| Attribution/User-Agent tagging | yes, on all commands | no | no | transport flag | no |
| Automatic cleanup (webshell + generated admin) | yes | yes | not documented | token-gated webshell only | dropper removed [5] |
| Blue-team detection guide | yes, from a production run | no | no | lab matrix instead | mitigation notes |
| Dependencies | stdlib only | stdlib only | single file | stdlib only, single file | Python ≥3.10 package |

[1] Timing/blind only as a read channel; the UNION fake-post primitive exists inside the
bridge but is not exposed as an extraction oracle.
[2] The naive availability probe (`0) UNION SELECT …`) is silently dropped during object-cache
hydration, `available()` returns false, and the whole pre-auth bridge aborts - see §1.
[3] The project's own README lists "no persistent object cache (Redis/Memcached)" under
Preconditions.
[4] Public variant mirrored at Sploitus (link below): blind read plus an `INTO OUTFILE`
dropper as the RCE step, using a `per_page=-1` categories carrier.
[5] The OUTFILE RCE path does not depend on fake-post rendering, so object caches do not
block it - the MySQL `FILE` privilege and a shared writable directory do. Managed hosting
almost never grants `FILE` to the WordPress DB user, and `secure_file_priv` is commonly set.

## 1. The object-cache problem (the real differentiator)

The UNION fake-post primitive depends on how `WP_Query` returns rows:

- **Full-row mode** - the SQL returns whole `wp_posts` rows; a `UNION`-injected row becomes
  a `WP_Post` directly. The forgery renders.
- **Split (ID-only) mode** - the SQL returns IDs only, and each ID is hydrated afterwards
  through the (persistent) object cache / database. The forged row's ID does not exist, so
  hydration **silently drops it**. No error, no forged post.

On hosts with a persistent object cache, a populated base result set pushes `WP_Query` into
split mode. The stock probe used by the public PoCs -

```
0) UNION SELECT  -- -
```

- leaves the base set populated (`post_author NOT IN (0)` matches every row), so behind an
object cache the forged row evaporates: the availability probe false-negatives, `available()`
returns false, and the entire pre-auth bridge is reported "dead" on a host that is in fact
fully exploitable. The public unifier documents the same boundary by listing *"no persistent
object cache"* as a hard precondition.

This repo empties the base set instead:

```
1) AND 1=0 UNION ALL SELECT  -- -
```

With zero base rows, the forged row is the **only** row; the query stays in full-row mode;
no hydration lookup ever runs. One injected keyword (`AND 1=0`) is the entire difference
between "UNION channel dead" and "full pre-auth RCE" on object-cached hosts - which are the
majority of managed WordPress production environments. The diagnosis, the probe matrix
(`per_page` × injection form), and the production confirmation are in
[CASE-STUDY.md](CASE-STUDY.md) §4.

Scope note: the blind/timing read channel is **not** object-cache-sensitive (counting rows
in SQL does not involve fake-post hydration), so every PoC's blind read works everywhere.
What the object cache kills in the other PoCs is specifically the UNION-dependent part:
in-band extraction and the SQLi-to-admin bridge.

A second, related lesson documented in the case study: when both channels work, treat the
in-band UNION read as authoritative - the production timing oracle produced bit flips under
jitter on a value the in-band read settled unambiguously.

## 2. RCE path choice

Three pre-auth RCE paths exist across the public PoCs:

| Path | Used by | Extra preconditions |
|---|---|---|
| **SQLi-to-admin bridge** (forge oEmbed/changeset/nav rows → `POST /wp/v2/users` → login → plugin upload) | this repo, sergiointel (origin), Icex0, 0xsha | none beyond a default install |
| **`INTO OUTFILE` dropper** (write a PHP file via SQLi, fetch it for a shell) | OUTFILE variant [4] | MySQL `FILE` privilege, `secure_file_priv` allowing it, and a directory writable by mysqld **and** served by the web server |
| **Hash recovery → crack → login** (dump `user_pass`, crack offline, then plugin upload) | all (as fallback) | the bcrypt hash must actually crack (`$wp$2y$`, hashcat `-m 35500`) - slow, often never |

This repo implements the bridge: it needs no database privileges beyond what WordPress
already has, works when DB and web tiers share nothing, and leaves no file behind for the
`FILE`-privilege path to depend on. The trade-off is complexity - the bridge is a seven-row
poisoned post graph - which is exactly where the §1 object-cache false negative used to
hide the path.

## 3. Safety defaults for authorized use

Built for running against production systems under authorization, not just labs:

- **`check` is non-destructive by default** - passive fingerprint plus a benign marker
  batch; no SQL payload is sent unless `--confirm-sqli` is given. After patching, the
  disappearing marker triplet doubles as fix validation.
- **Attribution tagging** - `--user-agent` on every command so all exploit traffic is
  identifiable in logs (an engagement rule-of-thumb the public tools don't default to).
- **Automatic cleanup** - the webshell is token-locked under a randomized path and removes
  itself; a bridge-created administrator is deleted afterwards with its content reassigned
  to the borrowed admin account. Failure of cleanup is reported loudly, not swallowed.
- **Request accounting** - every command prints how many requests it sent.

## 4. What this repo does *not* claim

- No new vulnerability. Both bugs are the publicly disclosed CVEs; the double-nested batch
  shape, the `author_exclude → author__not_in` sink, the fake-`WP_Post` UNION primitive, and
  the customizer-bridge concept are all public techniques (lineage acknowledged below).
- No new exploitation primitive. The delta over the public landscape is: the killed-base
  object-cache fix with production evidence, the production-safe defaults, and the
  detection documentation - robustness and operational safety, not novelty of technique.
- IoC strings are arbitrary. Login prefixes, plugin slugs, shell markers, and User-Agent
  values differ across every variant and per run; detection must anchor on behavior
  (see CASE-STUDY.md §5), never on these strings.

## References

- Icex0/wp2shell-poc - full-chain implementation this repo's lineage shares -
  
- sergiointel/wp2shell-poc - first public PoC; origin of the crack-free admin-creation
  technique - 
- 0xsha/wp2shell - single-file unifier of six public PoCs, with Docker labs and a
  version×DB matrix (documents the object-cache precondition) - 
- OUTFILE variant (blind read + `INTO OUTFILE` dropper), mirrored at Sploitus -
  
- Curated list of public PoCs and checkers (for defenders) -
  
- GHSA-ff9f-jf42-662q / GHSA-fpp7-x2x2-2mjf; WordPress 7.0.2 release announcement -
  see README.md references.