A Self-Hosted ERP for a Digital Studio's Finance Team
Sales documents and the books in one system
Read case studyWhen two WordPress servers turned out to be compromised, I cleaned close to 50 web apps, closed the way in, locked every site's code, and set up a daily report. Then I cleared the spam Google still held and fixed the overloads that kept the servers unstable afterward.
A company that runs two RunCloud servers with about 50 WordPress web apps: its own brand sites and their training sites, plus websites it hosts for clients, such as a design studio, a tile store, and a laundry business. Most of the sites were built years ago and had not been maintained since.
In October 2026 one of the brand sites returned a 500 error. WordPress Core files were missing, and about 14,500 files had changed in the same minute. The logs showed an attacker who had been on the server since August 27.
The way in was wp2shell, a chain of two WordPress Core bugs that lets anyone create an administrator account without logging in. Around nine sites on the first server and around 21 on the second had been taken over. Several things made it worse:
The cleanup did not end the trouble. Google still listed almost a million spam URLs under the clean domains, and the servers stayed unstable: one went down for about ten minutes, and the other ran at a load average of about 210.
Evidence first, then cleanup
Before deleting anything, I saved a tarball and an SQL dump of every affected site, which later let me trace the timeline back to August 27. On the second server, infected sites went behind a 503 maintenance page while I worked. I removed the rogue administrators, the fake plugin, and every webshell and dropper, restored Core from a clean copy, and updated every site to WordPress 7.1.3, infected or not.
The way in, closed twice
An NGINX rule blocks the exploited batch endpoint for the whole server, and a must-use plugin blocks it inside every WordPress install. Testing later showed the plugin had not loaded on 14 sites because of file ownership, and that ACF Pro undid its answer on others. After the fix, every site on that server answered 401 to an anonymous batch request.
Credentials replaced
I reset administrator passwords, database passwords, and salts on the affected sites, and replaced every site’s SES key. The email side is its own case study: an automatic kill switch for Amazon SES.
Code that a webshell can’t change
Every site’s code is now immutable with chattr +i, so even a working webshell can’t edit or add a PHP file, and NGINX refuses to run PHP from folders WordPress can write to. Unlocking for an update leaves a marker, and the daily report flags a site that stays unlocked for more than 24 hours.
A daily report that always sends
Every morning a Telegram message covers each site’s status, checksums, lock state, and a live probe of the PHP block. A quiet day still sends a message, so a missing report is itself a warning. A weekly job also checks Core and plugin files against official checksums.
Spam that outlived the hack
Google kept the attacker’s URLs because WordPress answered 200 for them. From the access logs I mapped about 300,000 spam URLs on one server and 660,000 on the other, and wrote an NGINX rule that answers them with 410 Gone. I tested it against five weeks of real logs: no page, asset, or ad landing URL matched. Gone sitemaps and prefix removals in Search Console sped up the recrawl, including about 67,000 URLs indexed under one client’s alias domain.
Servers that stay up
A Facebook crawler flood made one site’s PHP pool grow to 50 workers, and the memory pressure stopped MariaDB and every site with it. Every pool now has a cap, and crawlers get cached pages, with at most two uncached renders at a time per site. On the second server, an undersized opcache had caused thousands of out-of-memory kills an hour. Sizing it brought the load from about 210 to 7. When a scanner later queued 6,673 PHP connections on one server, smaller queues and an NGINX rule for missing files under /wp-content/ cut those requests from 0.571 to 0.016 seconds.
| Layer | Tool | Role |
|---|---|---|
| Servers | RunCloud, NGINX, PHP-FPM | Hosting, endpoint blocks, 410 rules, and pool caps |
| WordPress | wp-cli, must-use plugins | Cleanup, checksums, the batch block, and crawler cache |
| Lock | chattr, shell scripts |
Immutable code, with a tracked unlock for updates |
| Monitoring | Telegram, cron, New Relic | Daily report, weekly checksums, per-site uptime |
| Search | Google Search Console | Gone sitemaps and prefix removals for spam URLs |
| Amazon SES, IAM | New per-site keys and an automatic kill switch |
The sites are clean and running again, on patched Core, with code the web server can’t change.
| Before | After |
|---|---|
| About 30 sites taken over through Core | Every site cleaned and on WordPress 7.1.3 |
| A public endpoint that created admin accounts | Blocked in NGINX and inside WordPress, tested on each site |
| Code any webshell could rewrite | Immutable code, unlocked only for updates |
| Problems found when a site broke | A daily report, plus weekly checksums |
| Almost a million spam URLs in Google | Spam URLs answer 410, submitted for recrawl and removal |
| One crawler could take a whole server down | Capped PHP pools, cached crawlers, a sized opcache |
Google drops spam URLs as it recrawls them, which takes weeks, and the brand sites are moving to a headless setup so the public never reaches WordPress at all. The details are in the notes: Two Servers, 50 Sites, One Bug, Cleaning Up a wp2shell Compromise, Locking and Watching WordPress, Spam That Outlives the Hack, When the Server Fell Over, and When Scanners Overwhelmed WordPress.
If your WordPress sites need a cleanup or a setup that stays clean, get in touch.
New websites and landing pages, maintenance, migrations, and technical SEO for businesses and agencies.
Sales documents and the books in one system
Read case studySending stopped on its own, every site on a new key
Read case studyProducts and articles published without a deploy
Read case studyNo health data sent to Google, bookings measured
Read case studyThe company's main hiring platform, built in-house
Read case studyFive chat channels in one inbox on every phone
Read case studyConsent to conversion, end to end, in 2 days
Read case study8+ issues fixed in 2 days, every conversion verified
Read case studyOne event standard across every data flow
Read case studyNext step
Tell me what you have and what you need. I usually reply within one working day with how I'd approach it.