WordPress wp2shell (CVE-2026-63030) — Auditing, Patching, and Cleaning Up Every Site
· 12 min read
On Friday 18 July 2026, WordPress pushed emergency releases for two chained vulnerabilities collectively known as wp2shell. CVE-2026-63030 is a route-confusion bug in the REST API batch processor. CVE-2026-60137 is a SQL injection in WP_Query's author__not_in parameter. Chained together, they give an unauthenticated attacker full remote code execution on a default WordPress install — no plugins required, no special configuration, no login.
I manage 70+ WordPress sites across multiple servers. When a pre-auth RCE drops in core, everything else stops until every site is verified. Here's exactly what I did over the weekend, and what you should do if you haven't already.
What's affected
The full RCE chain affects WordPress 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1. WordPress 6.8.x has the SQL injection component but isn't exploitable for the full chain — 6.8.6 patches the injection on its own.
The patched versions are 6.9.5, 7.0.2, and 6.8.6.
WordPress.org force-pushed automatic updates for sites with auto-updates enabled. But plenty of sites don't have auto-updates on — managed setups that pin versions, staging clones, sites locked down by agencies, or anyone who turned auto-updates off years ago and forgot. Those sites are still vulnerable right now.
Step 1: Version audit across every site
First thing I did was check which of my sites were running affected versions. If you manage multiple WordPress installs on the same server, WP-CLI makes this fast:
find /var/www -maxdepth 3 -name wp-config.php -exec dirname {} \; | while read site; do
version=$(wp --path="$site" core version --skip-plugins --skip-themes 2>/dev/null)
echo "$site: $version"
done
This gives you a list of every WordPress install and its version. Flag anything showing 6.9.0–6.9.4 or 7.0.0–7.0.1.
For sites spread across multiple servers, I ran this via SSH in a loop:
for host in server1 server2 server3; do
echo "=== $host ==="
ssh "$host" 'find /var/www -maxdepth 3 -name wp-config.php -exec dirname {} \; | while read site; do
version=$(sudo -u www-data wp --path="$site" core version --skip-plugins --skip-themes 2>/dev/null)
echo "$site: $version"
done'
done
Step 2: Check access logs for exploitation attempts
Even if your sites are already patched (auto-update may have caught them), you need to check whether they were exploited before the patch landed. The exploit was being used in the wild from at least 20 July, and public proof-of-concept code appeared within hours of the disclosure on 17 July.
The attack hits the REST API batch endpoint. Search your nginx or Apache access logs:
grep -E "batch/v1" /var/log/nginx/access.log* | head -50
On an Apache server:
grep -E "batch/v1" /var/log/apache2/access.log* | head -50
What you're looking for:
- POST requests to
/?rest_route=/batch/v1or/wp-json/batch/v1— these are the exploit entry point - HTTP 207 responses on batch requests — a high-fidelity indicator of successful exploitation on unpatched versions
- User-Agent strings containing
wp2shellorrezwp2shell— known tool signatures from automated exploit frameworks
Not every batch endpoint request is malicious — WordPress itself uses this endpoint legitimately. But a POST to batch/v1 from an unfamiliar IP returning a 207, especially with a suspicious user-agent, warrants investigation.
grep -E "batch/v1" /var/log/nginx/access.log* | grep "207" | awk '{print $1}' | sort -u
This pulls unique IPs that got 207 responses on the batch endpoint.
Step 3: Scan for webshells
The wp2shell exploit chain creates a webshell disguised as a WordPress plugin. It lands in wp-content/plugins/ with a plausible-sounding name and a six-character hex suffix — something like wp-content/plugins/developer-tools-a3f19c/developer-tools-a3f19c.php.
Scan every site for this pattern:
find /var/www -path "*/wp-content/plugins" -type d | while read plugindir; do
# Look for plugin directories ending in a hex suffix
find "$plugindir" -maxdepth 1 -type d -regextype posix-extended \
-regex '.*-[0-9a-f]{6}$' 2>/dev/null
done
Also check for the webshell's calling pattern — it uses $_GET['c'] to accept commands:
grep -rl "\$_GET\['c'\]" /var/www/*/wp-content/plugins/ 2>/dev/null
If either of these returns results, that site has been compromised.
Step 4: Check for rogue admin accounts
The exploit chain also creates a rogue administrator account. Check each site:
find /var/www -maxdepth 3 -name wp-config.php -exec dirname {} \; | while read site; do
echo "=== $site ==="
wp --path="$site" user list --role=administrator --fields=ID,user_login,user_registered \
--skip-plugins --skip-themes 2>/dev/null
done
Look for any admin account you don't recognise, especially one created after 17 July 2026. The rogue account is typically created programmatically and won't match any real person on the team.
Step 5: Patch everything
For a single site, update immediately:
wp --path="/var/www/example.com" core update --skip-plugins --skip-themes
Fleet patching on hosting panels
The generic find /var/www approach works for custom setups, but if you're running VestaCP/HestiaCP or cPanel/WHM, those panels already know where every site lives.
On HestiaCP/VestaCP, iterate over panel users and their domains:
for USER in $(/usr/local/vesta/bin/v-list-users plain | awk '{print $1}'); do
for domain in $(/usr/local/vesta/bin/v-list-web-domains "$USER" plain | awk '{print $1}'); do
DOCROOT="/home/$USER/web/$domain/public_html"
[ -f "$DOCROOT/wp-includes/version.php" ] || continue
VER=$(wp core version --path="$DOCROOT" --allow-root 2>/dev/null)
echo "$domain ($USER): $VER"
done
done
On cPanel/WHM, pull docroots from the panel's userdata — this catches addon domains and subdomains too, not just the primary:
for USER in $(ls /var/cpanel/users/); do
DOCROOTS=$({ echo "/home/$USER/public_html"
grep -hs "^documentroot:" /var/cpanel/userdata/"$USER"/* | awk '{print $2}'
} | sort -u)
for DOCROOT in $DOCROOTS; do
[ -f "$DOCROOT/wp-includes/version.php" ] || continue
VER=$(wp core version --path="$DOCROOT" --allow-root 2>/dev/null)
echo "$DOCROOT ($USER): $VER"
done
done
Pin the target version — don't trust --minor
I hit a gotcha on servers with en_GB locale: wp core update --minor can report "WordPress is at the latest version" when the localised package hasn't been built yet. The fix is to pin the exact patched release per branch:
case "$VER" in
6.8*) TARGET="6.8.6" ;;
6.9*) TARGET="6.9.5" ;;
7.0*) TARGET="7.0.2" ;;
*) TARGET="" ;;
esac
[ -n "$TARGET" ] && wp core update --version="$TARGET" --path="$DOCROOT" --allow-root
Post-update verification
After every update, verify checksums and flush the object cache:
wp core verify-checksums --path="$DOCROOT" --allow-root
wp cache flush --path="$DOCROOT" --allow-root
If you ran WP-CLI as root (which you'll need to on most panel setups), repair file ownership afterwards — otherwise the web server can't write to its own directories:
find "$DOCROOT" -user root -exec chown "$USER:$USER" {} + 2>/dev/null
Temporary mitigation if you can't patch yet
If you can't update immediately (staging environments, version-pinned setups), block the exploit at the web server level. In nginx, add this before your WordPress location block:
location ~* /wp-json/batch/v1 {
deny all;
return 403;
}
location ~* "rest_route=/batch/v1" {
deny all;
return 403;
}
Reload nginx after adding the block:
sudo nginx -t && sudo systemctl reload nginx
This is a temporary measure. The batch endpoint has legitimate uses — some plugins and Gutenberg use it. Patch and remove the block as soon as possible.
Step 6: Full cleanup if compromised
If you found a webshell, a rogue admin, or suspicious batch endpoint activity, assume the attacker read wp-config.php. That means your database credentials, authentication salts, and any API keys stored in that file are compromised.
The cleanup checklist:
Remove the webshell:
rm -rf /var/www/example.com/wp-content/plugins/suspicious-name-a3f19c/
Delete the rogue admin account:
wp --path="/var/www/example.com" user delete <rogue_user_id> --reassign=1
Verify WordPress core file integrity:
wp --path="/var/www/example.com" core verify-checksums
If checksums fail, reinstall core:
wp --path="/var/www/example.com" core download --force --skip-content
Regenerate authentication salts (this force-logs-out all sessions, including any attacker sessions):
wp --path="/var/www/example.com" config shuffle-salts
Rotate database credentials. Change the MariaDB/MySQL password for the site's database user, then update wp-config.php to match.
Reset all admin passwords:
wp --path="/var/www/example.com" user list --role=administrator --field=ID | while read uid; do
wp --path="/var/www/example.com" user reset-password "$uid"
done
Rotate any API keys stored in wp-config.php or the database — payment gateway keys, SMTP credentials, third-party service tokens. If it was readable from the server, treat it as leaked.
What this means for ongoing maintenance
wp2shell is the most critical WordPress core vulnerability I've seen in 14+ years of managing WordPress sites. A default install, no authentication required, full code execution. The window between disclosure and mass exploitation was hours, not days.
The sites on my maintenance plans were patched the same day the advisory dropped. The ones with auto-updates enabled were already patched before I even ran the audit. The ones without auto-updates — staging clones, version-pinned installs — needed manual intervention.
If you're still manually checking WordPress versions and applying updates when you get around to it, wp2shell is the clearest possible argument for automated, monitored maintenance. The next core RCE won't give you a week to react either.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off security audit — I can help.
