The Setup
I manage two RunCloud servers that host websites for the company’s brands and for clients. Most of the sites were built years ago and nobody has maintained them since.
| Server | Web apps | Sites |
|---|---|---|
| First | 15+ | Company brand sites and their training sites |
| Second | 30+ | A design studio, client brand sites, a tile store, a laundry business |
Four things about this setup made the breach worse:
- WordPress Core was months out of date, and automatic updates were switched off.
- Several sites shared one Linux user, so an attacker in one site could write to the files of the others.
- Old page builders and premium plugins (Avada, Elementor Pro, WP Rocket, ACF Pro, LearnDash) made every update risky. A Core update could break a site whose builder licence I no longer had.
- Every site sends email through Amazon SES with its own SMTP key, so each hacked site could also send spam.
How They Got In
The way in was wp2shell, a remote code execution chain in WordPress Core that works without logging in. It needed no plugin and no password. It chains two bugs:
- CVE-2026-63030: route confusion in the REST API batch endpoint,
/wp-json/batch/v1, also reachable as/?rest_route=/batch/v1. - CVE-2026-60137: an SQL injection in
WP_Query. On its own it rates as moderate. Reached through the batch bug, it gives an anonymous visitor an administrator account.
WordPress 6.9.0 to 6.9.4 and 7.0.0 to 7.0.1 are affected, and 6.9.5 and 7.0.2 fix it. The batch endpoint is on by default, so any outdated site with a public REST API could be taken over. Exploit scripts showed up on GitHub soon after disclosure, which explains how quickly it reached sites like mine.
- 27 August: the first rogue administrator appears on one site
- 6 September: a second site on the same server
- Early October: the attacker spreads through shared system users
- A brand site returns 500, with Core files missing
- I clean the first server, then find the same compromise on the second
What the Attacker Left Behind
Once inside, the attacker set up several ways back in. If you run WordPress, search for these:
- Administrator accounts with random or hex usernames, and email addresses containing
wp2shell,w2s_orwp2_. - A fake plugin called
background-image-cropper. - Droppers and webshells:
accesson.php, files with hex names, and shells in folders nested inside a copy of themselves (name/name/). .htaccessfiles dropped into many directories at once.- A
php.inithat turnsexecback on, and anauto_prepend_filesetting that runs malicious code before every page. - Extra spam sitemaps added to
robots.txt.
When working out what changed and when, use file ctime (find -newerct) instead of mtime. rsync and tar preserve modification times and attackers fake them, while ctime records when a file’s metadata actually changed. The full list of indicators and the commands I used are in Cleaning Up a wp2shell Compromise.
Cleaning the First Server
I worked through the infected sites in roughly this order:
- Preserve the evidence. Before deleting anything, I put a tarball and an SQL dump of every affected site into one folder under
/root. - Remove the attacker’s access. I deleted the rogue administrators, the fake plugin and every webshell and dropper, then rebuilt the configs.
- Restore Core from a clean copy. I used
rsync -ac --exclude=wp-content, then brought broken plugins back one at a time. The site that first threw the 500 only booted after I disabled Filester, All-in-One WP Migration, Elementor, Yoast SEO Premium, WP Rocket and Better Search Replace Pro. - Patch every site. All of them went to WordPress 7.1.3, infected or not.
- Block the endpoint twice. An NGINX rule blocks the batch endpoint for the whole server, and a small must-use plugin blocks it inside every WordPress install.
- Remove plugins I no longer trusted. All WPMU DEV plugins (Dashboard, Defender, Snapshot) went, along with their database tables, and All-in-One WP Migration went from every site.
- Check file integrity every week. A cron job runs
wp core verify-checksumsandwp plugin verify-checksumson every web app and emails me when something fails.
Cleaning the Second Server
Just as the first server settled down, I found the same compromise on the second one, which hosts about 32 web apps. Around 21 of them were infected. I first put the infected sites behind an NGINX maintenance page returning 503, so visitors stopped reaching them while I investigated.
This server was harder for three reasons:
- Some sites depend on a theme builder I no longer have a licence for, so a Core upgrade could break their layouts.
- Several client sites point at the server through the client’s own DNS instead of my Cloudflare account, so nothing like a WAF sat in front of them.
- Some were live business sites that had to be repaired in place, with no option to rebuild.
I kept the plan simple: every site clean and working, with nothing extra. I removed the WPMU DEV plugins here too, removed RunCache and RunCloud Hub along with their Redis caching from every site, upgraded Core carefully, and left the most fragile site for last.
I did most of this with Claude Code on my own machine. It could work freely inside the web apps and had to ask before touching anything outside them.
Containing the Damage
A hacked WordPress site can send spam, burn CPU and go down without anyone noticing. Some work I had done in the weeks before turned out to matter.
An email kill switch
I had already built SES Guard: CloudWatch alarms on send spikes, bounces and complaints trigger a Lambda that stops all SES sending in the region and alerts me on Telegram and PagerDuty. While testing it I found root-account access keys on my own CLI and deleted them. During the incident I used IAM and SES identity deny policies to cut off one site’s sending without touching the others.
Uptime monitoring per site
Every site has a New Relic ping monitor on /wp-json/, or on the homepage where that redirects, with its own alert. When something goes down I see which site it is.
CPU limits
On the second server, a PHP 7.4 pool hit 570% CPU from three client sites. I capped the RunCloud PHP service with Docker Swarm limits (6 CPUs, 4 GB) without downtime, and added NGINX rules so requests for missing images and probes for backup files no longer go through WordPress.
Fewer root scripts
I chose not to run a root script that would have written a health-check file into every site. On a server full of old WordPress installs, a root process writing into user folders is one symlink away from handing over the whole server.
Lessons
Update Core automatically
I had treated WordPress Core as the stable part and plugins as the risk. This time the hole was in Core, and with auto-updates off it stayed open for months.
Give every site its own user
Shared Linux users turned one breach into many. Separate users cost little compared to a cleanup like this.
Run fewer plugins
Every security, backup and migration plugin I removed was one less thing to patch and one less place to hide code.
Treat email as part of the attack
Per-site SMTP keys and an automatic kill switch meant a compromised site could not send phishing for hours before anyone noticed.
Keep evidence before you clean
The tarballs and SQL dumps let me trace the timeline back to 27 August instead of guessing.
Set boundaries for the AI
Claude helped me investigate, write the scripts and get through dozens of sites quickly. It was safe because I had told it clearly where it could act alone and where it had to ask first.
Hardening Recommendations
If you run WordPress sites, especially old ones nobody maintains, this is what I recommend after an incident like this one.
Rotate every credential a breached site could read
SES SMTP keys, database passwords, WordPress salts and administrator passwords. Assume the attacker read wp-config.php.
Reinstall premium plugins from the vendor
WP Rocket, Elementor Pro, ACF Pro, LearnDash and the like can’t be checked against wordpress.org checksums, and some of mine still held injected files. Download fresh copies from the vendor account and replace the folders.
Turn auto-updates back on
At least for Core security releases, on every site.
Check Search Console
Remove owners you don’t recognise and delete injected spam sitemaps on every domain.
Keep internal services off the internet
Confirm that MySQL, Redis and the Docker Swarm ports can’t be reached from outside.
Give each site its own system user
A webshell should only be able to write to the site it landed on.
Move brand sites to headless
Dozens of full WordPress installs that nobody maintains are the real weakness. I’m moving our brand sites to a headless setup, with WordPress kept as a locked-down CMS and separate Astro or Next.js frontends, so the public never reaches the WordPress admin or REST API.
The sites are clean and running again. Most of what I changed is ordinary maintenance that had been skipped for years, and a patched Core would have kept this attacker out.