wp2shell Dropped on a Thursday — How I Patched and Audited 70+ Sites Before the Weekend
· 8 min read
The Patchstack advisory landed in my inbox at 14:12 on Thursday 17 July. WordPress 7.0.2 and 6.9.5 had just shipped. Two chained CVEs — a REST API batch-route confusion and a SQL injection — combined into a pre-authentication remote code execution path against every stock WordPress install running 6.8 through 7.0.1. No plugin required, no credentials needed. The exploit was already public. Attackers were scanning within 90 minutes of the patch release.
I manage north of 70 WordPress sites across multiple servers. I had roughly two hours before opportunistic scanning turned into active exploitation at scale.
What wp2shell Actually Does
The short version: CVE-2026-63030 is a logic flaw in WP_REST_Server::serve_batch_request_v1(), the handler for WordPress's /wp-json/batch/v1 endpoint. The batch processor maintains three parallel arrays — parsed requests, matched handlers, and validation results. When a malformed sub-request triggers a WP_Error during URL parsing, the error gets pushed to the validation array but not the handler array. From that point, every subsequent entry reads the wrong handler. An attacker-controlled request reaches a handler it was never validated against.
CVE-2026-60137 is the payload delivery mechanism. The author__not_in parameter in WP_Query expects an array of integers. When a string arrives instead (thanks to the batch confusion bypassing schema validation), the sanitisation path is skipped entirely and the raw value is concatenated into a SQL WHERE clause without $wpdb->prepare(). That's a textbook UNION-based SQL injection.
Chain them together with nested batch calls and you get unauthenticated RCE on a default WordPress install. The exploit extracts admin credentials via UNION SELECT, creates a rogue administrator through a poisoned oEmbed cache that triggers wp_insert_user(), and drops a webshell to disk.
One important detail: the full RCE chain only works on sites using WordPress's default file-based object cache. Sites running Redis or Memcached as a persistent object cache are not vulnerable to the RCE component, though the SQL injection still applies. Every site on my maintenance plans runs Redis — which bought some breathing room, but not enough to skip patching.
The Response: Version Audit Across Every Server
First priority: confirm which sites had already received the forced auto-update and which hadn't. I SSH'd into each server and ran a version sweep:
find /var/www -maxdepth 3 -name wp-config.php -exec dirname {} \; | while read site; do
version=$(wp core version --path="$site" 2>/dev/null)
echo "$site: $version"
done
Out of 70+ installs, 11 were still running unpatched versions. The reasons broke down predictably:
- Three sites had
AUTOMATIC_UPDATER_DISABLEDset totrueinwp-config.php— a constant I'd inherited from previous developers and hadn't removed - Two had
DISALLOW_FILE_MODSenabled (Git-managed deployments where the filesystem is intentionally read-only) - Four had
DISABLE_WP_CRONset with no system cron replacement configured — background updates never triggered because cron never fired - Two had restrictive file permissions that blocked the update process
For every unpatched site, the fix was straightforward:
wp core update --version=7.0.2 --path=/var/www/example.com
For the Git-managed installs, I pulled the upstream WordPress tag and deployed through the normal pipeline. Every site was on 7.0.2 or 6.9.5 within two hours of reading the advisory.
Checking for Compromise
Patching stops future exploitation. It doesn't tell you whether someone got in during the window. The patch dropped at roughly 14:00 UTC. Public proof-of-concept exploits appeared within hours. I needed to check whether any of my sites had been hit.
Access Log Scan
The exploit targets /wp-json/batch/v1 with POST requests. I searched nginx access logs on every server:
grep -E 'batch/v1|rest_route=.*batch' /var/log/nginx/access.log* | grep POST
I also checked for the known User-Agent strings from early exploit tooling:
grep -iE 'wp2shell|rezwp2shell' /var/log/nginx/access.log*
Several sites had POST requests to the batch endpoint, but all were from legitimate Gutenberg editor activity (the block editor uses the batch API for autosaves). None carried the distinctive wp2shell User-Agent. None returned 207 Multi-Status responses, which is the batch endpoint's signature for partial success — and a high-fidelity indicator of exploit attempts.
Webshell Check
The documented payload drops a PHP file into wp-content/cache/ with a randomised filename. The shell returns a fake 404 unless the correct token is passed as a query parameter. I searched for unexpected PHP files:
for site in /var/www/*/; do
find "$site/wp-content/cache" -name "*.php" -type f 2>/dev/null
find "$site/wp-content/uploads" -name "*.php" -type f 2>/dev/null
done
Clean across the board. But I also checked for the secondary indicators — rogue admin accounts and recently modified core files:
find /var/www -maxdepth 3 -name wp-config.php -exec dirname {} \; | while read site; do
admins=$(wp user list --role=administrator --field=user_login --path="$site" 2>/dev/null | wc -l)
echo "$site: $admins administrator(s)"
done
No unexpected accounts. No modified core files. No signs of compromise. The Redis object cache on every managed site had blocked the full RCE chain, and the two-hour patch window was narrow enough that the SQL injection component hadn't been exploited either.
Why Auto-Updates Failed on 11 Sites
This is the part that stung. WordPress's forced security updates are supposed to handle exactly this scenario. They failed on 15% of my fleet. The common thread was configuration constants that made sense in isolation but created blind spots:
AUTOMATIC_UPDATER_DISABLED is the nuclear option — it blocks every automatic update. I'd inherited it on three client sites from previous agencies who'd been burned by a bad auto-update years ago. The constant had been sitting in wp-config.php ever since, quietly preventing security patches from arriving.
DISABLE_WP_CRON without a system cron replacement is the silent killer. Four sites had this enabled for performance reasons (legitimate — wp-cron.php firing on every page load is wasteful), but nobody had set up the corresponding system-level cron job. No cron means no background update checks means no auto-updates.
I've since audited every wp-config.php across the fleet. Sites that need DISABLE_WP_CRON now have a proper system cron replacement:
*/5 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1
And I've replaced blanket AUTOMATIC_UPDATER_DISABLED with the more targeted WP_AUTO_UPDATE_CORE set to minor — which still allows security patches through while blocking surprise major version jumps.
What I Changed After This
wp2shell exposed a gap in my monitoring. I was checking plugin vulnerabilities daily but wasn't alerting on core version drift across the fleet. That's fixed now:
Daily version sweep. A cron job runs the version audit script above every morning and sends a Slack notification if any site falls behind the latest security release.
Config constant audit. Every new client onboarding now includes a sweep of update-blocking constants. If AUTOMATIC_UPDATER_DISABLED is set, I remove it and replace it with targeted controls.
Batch endpoint monitoring. I've added a log-based alert for unusual POST traffic to /wp-json/batch/v1 — specifically watching for 207 responses and non-browser User-Agent strings.
Redis on every site. This was already standard across my plans, but wp2shell proved its value as a defence-in-depth measure. The object cache didn't just improve performance — it blocked the most dangerous exploitation path entirely.
The Broader Lesson
wp2shell was the fastest turnaround from patch to mass exploitation I've seen in WordPress. Ninety minutes. If your maintenance strategy is "I'll update it this weekend," you were already too late by Thursday evening.
This is what proactive maintenance actually means. Not just keeping plugins updated — monitoring core versions across every site, ensuring auto-updates can actually fire, knowing which sites use Redis and which don't, and having the WP-CLI scripts ready to audit and patch the entire fleet in under two hours.
The 11 sites that didn't auto-update would have sat unpatched until someone manually logged into each WordPress admin, noticed the update banner, and clicked the button. For a pre-auth RCE with public exploit code, that's an unacceptable window.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. When the next zero-day drops — and it will — you want someone who's already patching before you've read the headline.
