psf-wordpress-wp2shell-check(8)
wp2shell, WordPress core pre-authentication RCE: checker, hunter and mitigation (v1)
CVE-2026-63030 (REST batch route confusion)
CVE-2026-60137 (SQL injection in WP_Query author__not_in)
If this software is helpful, consider a donation to our 501c3 nonprofit:
https://privacysafe.foundation
Disclosed and fixed on 17 July 2026, exploited in the wild from 20 July, and added to
CISA's Known Exploited Vulnerabilities catalog on 21 July. Chained, the two flaws give an
unauthenticated attacker remote code execution on a default WordPress install, with no
plugins and no login. Observed attacks create rogue administrator accounts and install
webshells.
CVE-2026-63030 REST API batch endpoint (/wp-json/batch/v1 or /?rest_route=/batch/v1).
A malformed sub-request path desynchronizes the matched handlers from
the requests, so later sub-requests run under the wrong permission
check. Affects 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1.
CVE-2026-60137 When author__not_in reaches WP_Query as a string rather than an array
it skips integer sanitization and is interpolated into the SQL WHERE
clause. Affects 6.8.0 through 6.8.5, 6.9.0 through 6.9.4 and 7.0.0
through 7.0.1. On 6.8.x the batch flaw is absent, so the SQL injection
needs another way to reach it and the full chain does not apply.
Fixed: 6.8.6, 6.9.5, 7.0.2, and 7.1 beta 2 (every 7.1 release after beta 1).
WordPress before 6.8.0 is not affected.
Object cache: when a persistent object cache (Redis, Memcached) is active, the
SQL injection sink is not reached and the chain does not fire on currently
affected installs. The site still needs the update; disabling or flushing the
cache later re-exposes it. Discovered by Adam Kues of Searchlight Cyber.
The fixes are recognizable in the source. The SQL injection fix replaces the is_array
test and implode() in wp-includes/class-wp-query.php with wp_parse_id_list(); the batch
fix adds a guard against starting a new top-level REST cycle while one is already
dispatching, in wp-includes/rest-api/class-wp-rest-server.php. A distribution package
can carry both fixes under an older version number, so this script reads the files as
well as the version.
Protective measure when you cannot update at once: refuse both URL forms of the batch
route at the web server, for example in nginx
location ~* "^/wp-json/batch" { return 403; }
if ($args ~* "rest_route=(%2F|/)batch") { return 403; }
or in Apache
RewriteRule ^wp-json/batch - [F,L]
RewriteCond %{QUERY_STRING} rest_route=(%2F|/)batch [NC]
RewriteRule ^ - [F,L]
Both forms must be covered. The block stops the chain's entry point; it does not fix
the SQL injection, so update anyway. The block editor uses the batch route for some
saves, so expect that editing may be affected until you update.
"check" and "hunt" are read-only; "mitigate" and "rollback" are the only commands that change
anything. It finds WordPress installs by wp-includes/version.php.
Usage:
./wordpress-wp2shell-check.sh [command] [DIR ...]
check (default) find WordPress under DIR (default /var/www /srv /home /opt
/usr/share/wordpress /usr/share/nginx /var/lib/wordpress), read each
version, read the two source files for the fixes, and look for a
web server block on the batch route. Exit 0 = nothing exposed,
1 = an install without the fixes, or one whose state is unclear.
mitigate [options] (root) changes the host. The documented protective measure: refuses
both URL forms of the REST batch route (/wp-json/batch and
?rest_route=/batch) at the web server, which closes the entry point of
the unauthenticated chain. It does not fix the SQL injection, so
update. Only when an exposed WordPress is found (--force overrides).
nginx: an include as the first line of every PHP-serving server block,
checked with nginx -t. Apache: psf-wp2shell-batch-block.conf (server
scope, so virtual hosts inherit it), checked with a configtest. If the
web server rejects the result everything is undone at once. Matches
the route anywhere in the path, so a WordPress in a subdirectory is
covered. The block editor uses the route for some saves.
--web auto|apache|nginx which web server (default auto)
--dir DIR where to look for WordPress (repeat)
--server-name NAME nginx: only the server block that lists NAME
--dry-run, --reload, --force
rollback [--dry-run] [--reload] remove what mitigate added and restore the files it edited.
hunt look for signs of exploitation: batch route requests and SQL
injection strings in web server logs, PHP files in wp-content/
uploads, and recent changes to mu-plugins. Exit 0 = nothing found,
1 = a strong lead. A clean hunt means none of the published indicators were found.
proof of safety.
version print the script version.
Env: HUNT_SINCE (YYYY-MM-DD, default 2026-07-17) for file changes;
WP2S_LOGS (space separated paths or globs) to name web server logs.
PSF_LOCK_WAIT seconds a run that changes the host waits for another one that is already
changing it (default 30), then stops with exit 2; the lock is STATE_DIR/.lockThe Security Patches software and this documentation are dedicated to the public domain under CC0 1.0 Universal. Other pages on privacysafe.app/docs are CC BY-SA 4.0 unless the page says otherwise.