wp2shell: Patching a WordPress Core RCE Across 70+ Sites in 48 Hours
· 14 min read
A forced auto-update notification from WordPress.org landed on Friday afternoon, 18 July. WordPress 6.9.5, 6.8.6, and 7.0.2 — all security releases, all pushed simultaneously. That alone tells you the severity. WordPress reserves forced auto-updates for the kind of vulnerability that keeps you at your desk through the weekend.
This one earned it. wp2shell is two chained vulnerabilities in WordPress core — not a plugin, not a theme, core itself — that give an unauthenticated attacker full remote code execution on a stock WordPress install. No plugins required. A bare WordPress installation with nothing but Twenty Twenty-Five and Hello Dolly is exploitable. Public proof-of-concept code hit GitHub within hours of disclosure.
I manage over 70 WordPress sites across multiple servers. Here's what the next 48 hours looked like.
What wp2shell Actually Does
Two CVEs chain together to go from anonymous HTTP request to arbitrary code execution:
CVE-2026-63030 is a route confusion bug in the REST API batch endpoint. WordPress lets you send multiple REST API requests in a single POST to /wp-json/batch/v1. The handler — WP_REST_Server::serve_batch_request_v1() — processes each sub-request and matches it to a route. The bug: when a sub-request returns a WP_Error during validation, it gets pushed into the $validation array but not $matches. This creates a +1 index shift. Sub-request i gets dispatched with sub-request i+1's route handler. An attacker crafts a batch where one request deliberately fails validation, causing the next request's data to be processed by a different endpoint's handler — one that accepts parameters the original endpoint would reject.
CVE-2026-60137 is a SQL injection in WP_Query's author__not_in parameter. The sanitisation guard checks is_array() — if you pass an array, each element gets cast to an integer. But if you pass a string, the guard is skipped entirely and the raw value is interpolated into a NOT IN (...) clause. Under normal circumstances, you can't reach this parameter as an unauthenticated user. The batch route confusion makes it reachable.
Chained: a single anonymous POST to /wp-json/batch/v1 triggers route confusion, which feeds attacker-controlled input into the SQL injection, which writes a PHP file to the uploads directory, which gives full RCE. The entire chain runs without authentication.
Affected versions: WordPress 7.0.0–7.0.1 and 6.9.0–6.9.4 are vulnerable to the full RCE chain. WordPress 6.8.0–6.8.5 is vulnerable to the SQL injection component only (the batch route confusion was introduced in 6.9).
Patched versions: 7.0.2, 6.9.5, 6.8.6.
The Triage
When a pre-auth core RCE drops, prioritisation is everything. I had 70+ sites to assess. Some are high-traffic WooCommerce stores processing orders around the clock. Some are brochure sites that get checked once a quarter. The vulnerability doesn't care about your traffic — it cares about whether you're running an affected version.
Step 1: Identify What Auto-Updated
Most of these sites have WordPress auto-updates enabled. The forced auto-update mechanism means WordPress.org can push a release even to sites that have opted out of major updates — security releases override that preference. But "can push" and "did push successfully" are different things.
I SSH'd into each server and ran a version audit:
for site in /var/www/*/; do
if [ -f "$site/wp-config.php" ]; then
version=$(wp core version --path="$site" 2>/dev/null)
name=$(basename "$site")
printf "%-40s %s\n" "$name" "$version"
fi
done
Out of 73 sites across four servers:
- 61 sites had auto-updated successfully to 7.0.2 or 6.9.5
- 7 sites were still on vulnerable versions (auto-update had failed silently)
- 3 sites were on 6.8.x (needed the SQLi-only patch to 6.8.6)
- 2 sites were on older branches I'd need to handle manually
The 7 failed auto-updates were the immediate priority. I checked why each one failed:
wp option get auto_update_core_major --path=/var/www/clientsite/
cat /var/www/clientsite/wp-content/debug.log | grep -i "update\|upgrade" | tail -20
Three had failed because a mu-plugin was hooking into pre_auto_update_check and returning early — a pattern I see on sites where a previous developer disabled auto-updates to "prevent breakage." Two had failed because the filesystem permissions were wrong (the web server user couldn't write to wp-admin/). Two had DISALLOW_FILE_MODS set to true in wp-config.php.
Step 2: Patch the Stragglers
For sites with auto-update disabled or broken, I updated manually:
wp core update --path=/var/www/clientsite/
wp core version --path=/var/www/clientsite/
For the two sites on older branches where wp core update wouldn't jump to a patched version, I downloaded the specific security release and applied it:
wp core update --version=6.8.6 --path=/var/www/legacysite/
All 73 sites were on patched versions within four hours of the advisory.
Verifying Each Site Is Clean
Patching stops future exploitation. It doesn't tell you whether someone already got in. The exploit code was public, mass scanning had started, and the window between disclosure and patch was measured in hours, not days. "Patched" does not mean "clean."
Core File Integrity
wp core verify-checksums --path=/var/www/clientsite/
This compares every core file against WordPress.org's official checksums. If an attacker modified a core file, this catches it. But verify-checksums only covers wp-admin/ and wp-includes/ — it doesn't check wp-content/, which is exactly where a wp2shell payload lands.
Access Log Analysis
The exploit hits a specific endpoint with a distinctive pattern. I grepped access logs across all servers:
grep -r "batch/v1" /var/log/nginx/access.log* \
| grep "POST" \
| grep -v "wp-cron\|heartbeat"
On two servers, I found POST requests to the batch endpoint from IP ranges I didn't recognise. Most were scanner probes — automated tools checking whether the endpoint responded. I filtered for the specific indicators the security researchers had documented:
grep -rE "(wp2shell-check|wp2shell-poc|author_exclude.*SELECT|author_exclude.*SLEEP|author_exclude.*CHAR_LENGTH)" \
/var/log/nginx/access.log*
One site had hits matching the wp2shell-check/1.0 User-Agent string. The requests came in at 03:47 UTC on July 18 — roughly six hours after disclosure and before the auto-update landed on that server at 09:12 UTC. That six-hour window was the concern.
Checking for Dropped Files
A successful wp2shell exploitation writes a PHP file — typically to wp-content/uploads/ or wp-content/plugins/. The payload may self-delete after execution, so absence of suspicious files doesn't rule out compromise. But presence confirms it.
find /var/www/clientsite/wp-content/uploads/ -name "*.php" -newer /var/www/clientsite/wp-login.php
find /var/www/clientsite/wp-content/mu-plugins/ -mtime -3
find /var/www/clientsite/wp-content/plugins/ -maxdepth 1 -name "wp2shell*"
I also searched for the marker strings that the public exploit code uses in its output:
grep -rl "WP2SHELL_OUT_START\|WP2SHELL_OUT_END" /var/www/clientsite/wp-content/
Checking for Rogue Admin Accounts
If an attacker achieves RCE, creating a persistent admin account is a common next step:
wp user list --role=administrator --fields=ID,user_login,user_registered \
--path=/var/www/clientsite/
I compared the admin user list against my maintenance records for each site. Any user registered after July 17 would have been suspicious. None of the 73 sites showed unexpected admin accounts.
Database Integrity
The SQL injection component can do more than write files. I checked for signs of data exfiltration or modification:
SELECT option_name, option_value
FROM wp_options
WHERE option_name IN ('siteurl', 'home', 'admin_email', 'users_can_register', 'default_role')
AND option_value != (SELECT option_value FROM wp_options WHERE option_name = option_name);
Specifically, I looked for users_can_register being flipped to 1 and default_role being changed to administrator — a common persistence technique that doesn't require file writes.
The One Site That Got Hit
The site with the wp2shell-check User-Agent hits needed deeper investigation. The access log showed three POST requests to /wp-json/batch/v1 between 03:47 and 03:48 UTC. The first two returned 400 status codes — probe attempts. The third returned 200.
A 200 on the batch endpoint isn't necessarily exploitation — legitimate batch requests return 200 too. But the timing, the User-Agent, and the six-hour pre-patch window made this a priority.
I checked for recently modified files in the uploads directory:
find /var/www/targetsite/wp-content/uploads/2026/07/ -name "*.php" -newer /var/www/targetsite/wp-login.php
Nothing. But the public exploit's payload can self-delete. I checked the server's inotifywait logs (I run file integrity monitoring on high-value sites):
grep "CREATE\|DELETE" /var/log/inotify/targetsite.log \
| grep "2026-07-18" \
| grep "\.php"
There it was. A file called wp-content/uploads/2026/07/.cache-compat.php was created at 03:48:12 UTC and deleted at 03:48:14 UTC. Two seconds of existence. Long enough to execute, short enough to leave no forensic trace on a server without file monitoring.
The file was gone, but I could see from the inotify log that something had been created, executed via a GET request (visible in the access log at 03:48:13), and then deleted. I treated the site as compromised.
Incident response for that site:
- Rotated all database credentials and
wp-config.phpsalts - Forced password resets for all admin and editor accounts
- Revoked all application passwords (
wp eval 'WP_Application_Passwords::delete_all_application_passwords( 1 );') - Reviewed the database for injected content in
wp_postsandwp_options - Checked
crontaband system cron for persistence - Rebuilt core files from scratch with
wp core download --force - Reviewed all plugin and theme files against known-good copies
The database review turned up nothing injected. The GET request to the self-deleting PHP file was likely a scanner verifying exploitability rather than a targeted attack — the payload executed and cleaned up, but no persistence mechanisms were installed. Still, I treated it as a full compromise and rotated everything. The investigation process is similar to what I documented in my CloudPanel server hack write-up — once an attacker has had any level of access, you assume the worst and verify everything.
Temporary Mitigations
For sites that couldn't update immediately — custom-forked core (it happens), or sites in a deployment freeze — I deployed a temporary block at the web server level.
Nginx:
location ~* /wp-json/batch/v1 {
deny all;
return 403;
}
if ($query_string ~* "rest_route=/batch/v1") {
return 403;
}
Apache/.htaccess:
RewriteEngine On
RewriteRule ^wp-json/batch/v1 - [F,L]
RewriteCond %{QUERY_STRING} rest_route=/batch/v1 [NC]
RewriteRule .* - [F,L]
You need to block both /wp-json/batch/v1 and ?rest_route=/batch/v1 — blocking only one leaves the other route open. These block the batch endpoint entirely. The trade-off: any plugin or theme using the REST API batch endpoint will break. In practice, almost nothing uses it — the Gutenberg editor doesn't rely on it, WooCommerce doesn't use it, and I've never seen a plugin that does. But test before deploying.
A more surgical approach is a mu-plugin that requires authentication on the batch route:
<?php
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
if (strpos($request->get_route(), '/batch/v1') !== false) {
if (!current_user_can('edit_posts')) {
return new WP_Error(
'rest_forbidden',
'Batch endpoint requires authentication.',
['status' => 403]
);
}
}
return $result;
}, 10, 3);
Drop that into wp-content/mu-plugins/block-batch-endpoint.php and the batch route is locked to authenticated users with at least editor-level capabilities. Remove it after updating to a patched version.
One additional hardening step worth doing regardless: revoke the MySQL FILE privilege from your WordPress database user. The wp2shell chain can use INTO OUTFILE to write a PHP shell directly to the web root if the database user has this privilege — common on shared hosting. Check and revoke it:
SHOW GRANTS FOR 'wp_user'@'localhost';
REVOKE FILE ON *.* FROM 'wp_user'@'localhost';
Your WordPress database user should never need FILE. While you're at it, verify secure_file_priv is set in your MySQL configuration — it restricts where INTO OUTFILE can write, even if FILE is granted.
What This Means for Maintenance
Sixty-one of my 73 sites auto-updated before I'd finished my first coffee. The auto-update infrastructure that WordPress has built over the last decade worked exactly as designed. For those sites, my job was verification — confirming the update landed, checking logs, looking for signs of exploitation in the pre-patch window. Methodical but not urgent.
The 12 sites that didn't auto-update were the problem. Seven had auto-updates disabled or broken by previous developers. Three were on older branches. Two had DISALLOW_FILE_MODS set. Every one of these was a site where someone had made a deliberate choice to turn off automatic updates — usually because "an update broke something once."
That choice has a cost. On a normal Tuesday, the cost is falling a few versions behind. When a pre-auth core RCE drops on a Friday afternoon, the cost is a six-hour window where your site is exploitable and nobody's watching.
This is the case for managed security maintenance. Not because I'm faster at running wp core update than you are — you can do that yourself. Because I'm watching when the advisory drops, I know which sites have auto-updates disabled and why, I have file integrity monitoring running on high-value sites, and I have a process for checking 70+ sites in four hours instead of realising there's a problem on Monday morning.
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.
