Two Servers, 50 Sites, One Bug
In October 2026 I found out that two of the WordPress servers I run had been compromised. Around nine sites on the first server and around 21 on the second had been taken over, and I spent the following days cleaning up close to 50 web apps.
It started with a 500 error on one of our brand sites. WordPress Core files had disappeared, and about 14,500 files had their metadata change in the same minute. I assumed a restore or migration had been interrupted. The logs later showed an attacker who had been on the server since late August. This note covers how they got in, what they left, how I cleaned both servers, and what I would tell anyone running old WordPress sites.
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 license 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 last 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 license 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. Work I had done in the weeks before limited all three.
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.
The Amazon SES Review
The compromised sites still sent more than 18,000 emails through Amazon SES. The bounce and complaint rates rose with them, and AWS put our SES sending in that region under review, with both rates marked as at risk.
It was the second time. In September an attacker got hold of one of our SES keys and used our domains to send phishing. More than 38,000 messages went out before the account hit its 50,000 sending limit, and AWS paused sending in that region. A second pause would stop every site’s email, including the many sites that had nothing to do with the breach.
So I answered the review with what happened and what had already changed:
- The cause. A WordPress Core exploit on old sites, not our own mailing or a bought list.
- Every key replaced. A script rotated the SMTP credentials of more than 40 IAM mail users and deleted the old keys, so nothing the attacker read still works.
- The sites fixed. Every infected site cleaned and patched, with the exploited endpoint blocked on the server and inside WordPress.
- Sending watched. SES Guard stops sending in the region on a send spike or a high bounce or complaint rate, and a deny policy can cut off a single site without touching the others.
I took the review seriously because it is fair. AWS has to protect the reputation of the addresses it sends from, and twice in two months our account was part of the problem.
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 did not stop every email, but they showed me which site was sending and let me cut it off without stopping the rest. Without them, the spam would have run until AWS stopped it for me.
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 recognize 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.
Sources
Related Notes
- Cleaning Up a wp2shell Compromise How an unauthenticated WordPress Core exploit spread across seven sites on one RunCloud server, the signs it leaves, the cleanup I repeated, and how to stop it.
- A Circuit Breaker for Amazon SES SES Guard, a CloudFormation stack that watches Amazon SES for sending spikes and high bounce or complaint rates, stops sending, and says who was sending.
- Headless CMS for a Brand Site A headless CMS for a company brand site with WordPress and Next.js on RunCloud: Docker, Supervisor, on-demand revalidation, and scripted deploys with rollback.
- Spam That Outlives the Hack Why Google kept almost a million gambling spam URLs after the WordPress sites were clean, and how I found them in access logs and answered them with a 410 rule.
Next step
Have a project in mind? Let's talk.
Tell me what you have and what you need. I'll usually reply within one working day with how I'd approach it.