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

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 ------------ ------------------------------------ ------------ . The RSS feed (/feed/): ?v=6.9.3 in the 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 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/: 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.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.