DEV Community

own2pwn
own2pwn

Posted on

wp2shell: how a route confusion + a reachable SQLi become pre-auth RCE in WordPress core

This is a republication. The original and always up to date version lives here:
https://own2pwn.fr/articles/appsec/wp2shell-wordpress-rce (it also embeds a live,
non-intrusive exposure checker).

One request. A POST to /wp-json/batch/v1, a slightly twisted JSON body, and the server
answers a perfectly ordinary 200 OK. Thirty seconds later there is one more
administrator account in the database, an unknown plugin dropped under
wp-content/plugins/, and a shell that answers to id. No password. No vulnerable
third-party plugin. No exotic theme. Just WordPress, in its default install. This is
wp2shell, the bug that had security teams scrambling on 17 July 2026.

Legal note, read this first. This article documents and defends. We dissect the real
mechanism of wp2shell so you can detect it and fix it. We publish no ready-to-run
payload
(the original researchers deliberately held theirs back, we do the same).
Testing this flaw against a WordPress you neither own nor are mandated to audit is a
crime. Everything below assumes an authorized pentest or a lab you own.

What wp2shell actually is

Despite the name, wp2shell is not a plugin, nor yet another "admin to shell" tool that
assumes you already have credentials. It is the name given by
Searchlight Cyber
(through its Assetnote unit) to a pre-authenticated RCE in WordPress core, disclosed on
17 July 2026. Pre-auth means exactly what it says: an anonymous attacker, with no account at
all, gets code execution on the server.

Technically, wp2shell is not one flaw but a chain of two WordPress core bugs:

  • CVE-2026-63030, a route confusion in the REST API batch endpoint (/wp-json/batch/v1). This is the link that turns a "theoretical" bug into an account-free, exploitable RCE.
  • CVE-2026-60137, a SQL injection in WP_Query's handling of author__not_in.

Taken separately, these two are annoying. Chained, they give full RCE on a stock install
with no third-party component. The route confusion was found by Adam Kues (Assetnote /
Searchlight Cyber) and reported through WordPress's HackerOne program; the SQL injection was
reported in parallel by researchers TF1T, dtro and haongo. WordPress responded by
force-pushing automatic updates to the affected installs.

The exact version scope:

  BRANCH         STATE                                  FIXED IN
  ------------   ------------------------------------   ------------
  < 6.8.0        not vulnerable to the chain            --
  6.8.0 - 6.8.5  SQL injection only (no RCE)            6.8.6
  6.9.0 - 6.9.4  FULL pre-auth RCE                      6.9.5
  7.0.0 - 7.0.1  FULL pre-auth RCE                      7.0.2
  7.1 beta       FULL pre-auth RCE                      7.1 Beta 2
Enter fullscreen mode Exit fullscreen mode

The 6.8 branch is hit only by the SQL injection; the full RCE starts at 6.9.0. Fixed in
6.8.6 / 6.9.5 / 7.0.2.

The root bug: route confusion in /wp-json/batch/v1

The REST API batch endpoint exists to bundle several calls into a single HTTP request:
you send an array of sub-requests, WordPress processes them one after another and returns
an array of responses. Handy for a front-end that wants to create ten objects at once.
Internally, the controller keeps two parallel arrays indexed together: one for the
validation of each sub-request, one for the handler that will route it.

The bug fits in one sentence, the one from the researcher who reproduced it: "a sub-request
whose path fails wp_parse_url() is added to $validation but not to $matches, so the
arrays desynchronise." In other words: slipping in one sub-request with a deliberately
malformed path is enough to offset the two arrays by one. From there, every following
sub-request is validated under one index but executed under its neighbour's handler.
Method and authentication checks apply to one route; the code runs for another.

Schematically (reconstruction). The exact WordPress core code is not reproduced here.
The idea, without the implementation detail: two foreach loops over the same array of
sub-requests, but one skips invalid paths and the other does not. One array advances by
N, the other by N-1.

POST /wp-json/batch/v1
  (a batch of sub-requests, one with a deliberately malformed path, placed first)
        |
        v
  $validation array   the malformed sub-request FAILS wp_parse_url() but is still counted here
        |
        v
  $matches array      it does NOT enter here. Indexes slip: request i runs with handler i-1
        |
        v
  Result              an anonymous read lands in a code path that thought it handled something else
Enter fullscreen mode Exit fullscreen mode

The shape of the request is nothing fancy: standard batch JSON, with a broken path slipped
in at the right spot. We show the structure (the endpoint is public and documented), not a
working exploit:

POST /wp-json/batch/v1 HTTP/1.1
Host: target
Content-Type: application/json

{
  "requests": [
    { "method": "GET", "path": "deliberately-invalid-path" },
    { "method": "GET", "path": "/wp/v2/users?author_exclude=..." }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Worth noting: the batch route is also reachable via ?rest_route=/batch/v1, which matters
for detection and filtering (both forms must be watched).

From index slip to SQL injection (CVE-2026-60137)

The slip is used to deliver a sub-request to the wrong handler. Concretely, the exploit
sends a GET /wp/v2/users carrying an author_exclude parameter. That parameter does not
exist in the users endpoint schema, and would normally be ignored. But thanks to the route
confusion, the request is dispatched to the posts handler, where author_exclude is
recognised and mapped onto WP_Query's author__not_in query variable.

And that is where it breaks. author__not_in is meant to be a list of numeric IDs. The
core expects integers; the exploit hands it a string, which is not normalised and ends
up interpolated straight into the SQL query. SQL injection, on the WordPress server's
database account, unauthenticated. The fix is as telling as the bug: it forces the input
through wp_parse_id_list(), which coerces it into a list of clean integers. One forgotten
line of type-safety; that is often all that stands between an API and an injection.

GET /wp/v2/users ?author_exclude=...   (unknown to the users schema)
        |
        v
  Handler /wp/v2/posts                 here author_exclude is valid, becomes author__not_in
        |
        v
  WP_Query author__not_in = string     expected a list of ints, got a string, straight into SQL
        |
        v
  Database                             pre-auth SQL injection, arbitrary reads incl. wp_users
Enter fullscreen mode Exit fullscreen mode

From "SQL" to "shell": the "2shell" part

A read-only SQL injection is already a full database leak. But wp2shell goes all the way:
from SQL read to code execution. The approach, as documented by the public PoC, abuses
WordPress internals rather than a dangerous function:

  • Forge fake wp_posts rows via a UNION injection, rows crafted to be rendered through the oEmbed cache mechanism.
  • Recover the real IDs of the cache posts through the same SQLi, then recast them as customizer changesets and navigation menu items.
  • Create a "manufactured" administrator account in a single poisoned batch request.
  • Log in with that admin and drop a webshell plugin, the step that yields command execution.

A precondition that limits the RCE (but not the SQLi). According to the analysis
relayed by Cloudflare, the RCE path applies when no persistent object cache (Redis,
Memcached and so on) is in place: the oEmbed cache trick depends on a database-backed
cache. The underlying SQL injection stays exploitable in every case. Translation: an
external object cache mitigates the shell, it does not fix the data leak. Only the patch
does.

The full code exists in the public PoCs; the point here is that you understand the chain
well enough to recognise it in your logs, not to replay it against the first WordPress you
find.

Reproducing in a lab: the public PoC

An independent PoC, Icex0/wp2shell-poc (MIT
license), automates the chain in a tool plainly named wp2shell.py. On your lab, its
sub-commands tell the progression well: a non-destructive test, a database read, then
execution:

# non-destructive detection: is the target vulnerable?
./wp2shell.py check http://lab-wordpress

# confirm the SQL injection (still read-only)
./wp2shell.py check http://lab-wordpress --confirm-sqli

# extract data from the database (union / error / blind)
./wp2shell.py read  http://lab-wordpress --preset users

# command execution through the pre-auth bridge to admin
./wp2shell.py shell http://lab-wordpress --cmd id
./wp2shell.py shell http://lab-wordpress -i        # interactive shell
Enter fullscreen mode Exit fullscreen mode

Searchlight Cyber also put up a non-offensive checker, wp2shell.com,
which simply says whether an instance is exposed. On the defensive tooling side, the
disclosure has already triggered detection Nuclei templates and authenticated
verification checks at scanner vendors (Rapid7 among others). As of writing, however, no
Metasploit module or Exploit-DB entry
specific to CVE-2026-63030 is confirmed. Do not
confuse it with the old generic wp_admin_shell_upload module, which assumes you are
already admin.

Do not confuse wp2shell with WP-SHELLSTORM. Around the same time (July 2026) another
WordPress story is circulating: WP-SHELLSTORM, an access-resale campaign that
backdoored around 25,000 sites through 27 old plugin flaws (including the Breeze
plugin, CVE-2026-3844) by dropping down.php-style webshells. That is a campaign
exploiting known bugs
, with no relation to wp2shell: different flaws, core untouched.
The two names just both end in "shell".

Detecting exposure in blackbox, without exploiting it

Good news for anyone auditing a fleet: the whole chain is gated by the core version. No
need to fire a payload to know whether an instance is exposed; just read its version and
compare it to the vulnerable ranges. That is exactly the logic of our open-source detector,
wp2shell-detect (Python, zero dependency,
detection only). Here it is step by step.

1. Fingerprint the core version

WordPress leaks its version through several public, read-only channels. We query them and
cross-check:

  • The home page generator tag: <meta name="generator" content="WordPress 6.9.3">.
  • The RSS feed (/feed/): ?v=6.9.3 in the <generator> tag.
  • /readme.html, which shows Version 6.9.3 in plain text.
  • The /wp-json/ index, sometimes chatty about the version in its description.

Parsing must be strict: accept only a purely numeric X.Y or X.Y.Z, otherwise return
"unknown" rather than guess. A misread version is a guaranteed false positive (or worse, a
false negative).

# vulnerable ranges (inclusive), from the disclosure
RCE_RANGES  = [((6, 9, 0), (6, 9, 4)), ((7, 0, 0), (7, 0, 1))]
SQLI_RANGES = [((6, 8, 0), (6, 8, 5))]          # SQLi only, no RCE
# fixed in 6.8.6 / 6.9.5 / 7.0.2

def parse_version(v):
    m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", v.strip())   # strict
    return (int(m[1]), int(m[2]), int(m[3] or 0)) if m else None

def verdict(v):
    if any(lo <= v <= hi for lo, hi in RCE_RANGES):  return "pre-auth RCE"
    if any(lo <= v <= hi for lo, hi in SQLI_RANGES): return "SQL injection"
    return "out of range (likely patched)"
Enter fullscreen mode Exit fullscreen mode

The hardened install case. A well-configured WordPress removes the generator tag
and blocks readme.html. The version then becomes invisible in pure blackbox. Our detector
says so plainly ("WordPress confirmed, version hidden") instead of bluffing: confirming
wp2shell without the version would amount to exploiting it, which we refuse to do for a
mere detection. The healthy reflex: patch anyway.

2. Confirm the batch route is reachable

The version says "vulnerable"; the remaining question is whether the chain's entry point is
exposed or already filtered at the edge. We check that without ever triggering it, with an
OPTIONS request: the REST router returns the route schema (the allowed methods) without
running the handler
. A 200 with Allow: POST confirms that /wp-json/batch/v1 responds;
a 403 signals a mitigation is already in place.

# OPTIONS = route introspection, no handler fired, zero payload
curl -s -X OPTIONS -i http://target/wp-json/batch/v1 | grep -i '^Allow:'
# -> Allow: POST   (batch route exposed)   |   403/401 (filtered at the edge)
Enter fullscreen mode Exit fullscreen mode

3. The verdict

  version readable?
    +- no   -> WordPress, version hidden: patch as a precaution
    +- yes  -> 6.9.0-6.9.4 or 7.0.0-7.0.1 ? -> PRE-AUTH RCE   (critical)
               6.8.0-6.8.5 ?                 -> SQL INJECTION  (high)
               otherwise                     -> likely patched
Enter fullscreen mode Exit fullscreen mode

Across a whole fleet, in one command:

git clone https://github.com/own2pwn-fr/wp2shell-detect && cd wp2shell-detect
./wp2shell_detect.py --targets fleet.txt        # one URL (or host) per line
# [!!] https://blog.example.com -> VULNERABLE (pre-auth RCE)  6.9.3  CVE-2026-63030
Enter fullscreen mode Exit fullscreen mode

It exits with status 2 as soon as one target is vulnerable: handy for a nightly sweep in CI
or cron. Nothing in there touches the flaw; these are requests a plain crawler could send.

Detecting exploitation in your logs

Exposure detection answers "am I vulnerable". Your logs answer "am I being attacked". No
vendor has published a definitive IOC list: the flaw is a few days old. But the mechanism
dictates its own signatures. What to hunt for:

  • Anonymous requests on the batch endpoint: POST /wp-json/batch/v1 or ?rest_route=/batch/v1 with no admin session. Legitimate anonymous use of this route is rare: treat it as suspicious by default.
  • Batch bodies with a requests array where one path is clearly malformed (the desync trigger).
  • An author_exclude on /wp/v2/users (illegitimate for that schema), especially if its value contains SQL syntax (UNION, quotes, comment markers).
  • Post-exploitation signs: an administrator account out of nowhere, abnormal wp_posts rows tied to the oEmbed cache, customize_changeset or nav_menu_item, and above all a new plugin with an unknown PHP file under wp-content/plugins/.

Two simple passes over your access logs and filesystem clear most of the ground:

# 1) anonymous hits on the batch route (both forms)
grep -E '/wp-json/batch/v1|rest_route=/batch/v1' access.log \
  | grep -iv 'wp-admin' | less

# 2) author_exclude passed to the users endpoint (has no business there)
grep -E '/wp/v2/users' access.log | grep -i 'author_exclude'

# 3) recently dropped plugins (suspect webshell)
find wp-content/plugins -name '*.php' -mtime -7 -printf '%T+ %p\n' | sort
Enter fullscreen mode Exit fullscreen mode

Mitigating: patch first, filter second

The only real answer is the patch. Move to WordPress 7.0.2 or 6.9.5 (or 6.8.6
for the 6.8 branch, which only carried the SQLi). WordPress force-pushes automatic updates to
affected installs, but do not rely on it: verify. Rapid7 puts it bluntly, workarounds do not
replace the patch.

If you really cannot patch right away, filter at the edge. Block the batch endpoint at the
reverse proxy, in both its forms:

# nginx: cut anonymous access to the batch API while waiting for the patch
location = /wp-json/batch/v1 { return 403; }

# and the query-string form
if ($arg_rest_route = "/batch/v1") { return 403; }
Enter fullscreen mode Exit fullscreen mode

Finer, without breaking a legitimate authenticated use: a mu-plugin (must-use) that
rejects anonymous batch requests before they reach the controller. Drop it in
wp-content/mu-plugins/:

<?php
// wp-content/mu-plugins/000-block-anon-batch.php
// Temporary measure: reject /wp-json/batch/v1 for logged-out users.
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
    if (strpos($request->get_route(), '/batch/v1') === 0 && !is_user_logged_in()) {
        return new WP_Error('rest_forbidden', 'Batch API disabled', ['status' => 403]);
    }
    return $result;
}, 0, 3);
Enter fullscreen mode Exit fullscreen mode

Finally, restrict anonymous access to the REST API in general (through a security
plugin), and post-incident, audit admin accounts and unknown plugins, rotate the
salts and credentials, and review the cache/oEmbed tables. This flaw is a useful reminder:
vulnerability management is
not won at the moment of the CVE, but in your ability to know, within minutes, where you
run the vulnerable version.

What wp2shell says about your external attack surface

The real trap with a WordPress core CVE is not the WordPress you administer and monitor. It
is the other one: the corporate blog spun up three years ago for a campaign, a
subsidiary's events. subdomain, the test install a contractor never shut down. Those
instances are in no inventory, and they are exactly the ones still running 6.9.3 while your
known assets are patched the same day. You cannot fix what you do not know you expose: that is
the whole point of
asset discovery.

This is precisely the job of an
External Attack Surface Management
platform: continuously discover every exposed WordPress you have (including the ones nobody
manages), fingerprint their version, and surface first those carrying a critical CVE like
wp2shell. A scan that probes /wp-json/batch/v1 and compares the detected version to the
fixed threshold turns a Friday-night disclosure into a list of URLs to patch on Monday
morning. That is what our
EASM does. And where
automated detection stops, a
blackbox web pentest confirms real
exploitability, with no false positives. For the exact boundary between the two, we detailed
it in
pentest vs vulnerability scan.

Takeaways

  • wp2shell (CVE-2026-63030) is a pre-authenticated WordPress core RCE, not a plugin: no third-party dependency, a default install is enough.
  • It chains two bugs: a route confusion in /wp-json/batch/v1 ($validation / $matches desync) and a SQL injection via author__not_in (CVE-2026-60137).
  • RCE-risk versions: 6.9.0-6.9.4 and 7.0.0-7.0.1. The 6.8.x branch only has the SQLi. Fixes: 6.8.6 / 6.9.5 / 7.0.2.
  • Detection: anonymous hits on the batch route, author_exclude on /wp/v2/users, new admin accounts and unknown PHP plugins.
  • Mitigation: patch first; failing that, block /wp-json/batch/v1 (WAF or a rest_pre_dispatch mu-plugin). An external object cache hinders the RCE but leaves the SQL leak.
  • The real risk is the WordPress you do not know you expose: asset discovery and version fingerprinting make the difference between a Monday patch and a compromise.

Not sure how many WordPress instances carry your name on the internet? That is exactly the
first thing
own2pwn surfaces.
Otherwise, talk through a specific exposure with a human on the
contact page.

Top comments (0)