Cleaning 60+ WordPress Installs on a Fully-Infected WHM Server
Over the last few days I cleaned a WHM/cPanel server that was infected from top to bottom: 22 cPanel accounts, more than 60 WordPress installs, and a backdoor that came back every single time the sites looked clean. This is the full write-up. What the malware actually was, why every file scanner on the box kept missing it, how we removed each layer safely, and how you can check your own site for the same thing.
If you have ever paid for a cleanup, watched the site go green, and then watched it get reinfected a day later, this post is going to feel familiar. The good news is that the pattern is understandable, and once you understand it, it is fixable.
The symptom: it kept coming back
The tell was simple and maddening. A file-level scan would report a site clean. An hour later, rogue administrator accounts were back, and the payload with them. Clean, reinfect, clean, reinfect. That loop is not bad luck and it is not a scanner having an off day. It is the signature of malware that keeps its ability to rebuild itself somewhere the scanner never looks.
When a site reinfects that fast, there is almost never a person sitting there re-hacking it by hand. Something already on the server, or already in the database, is doing it automatically. The job is not to delete the shell you can see. The job is to find the thing that keeps re-creating the shell, and remove that.
Why WordPress malware reinfects
Most scanners work by reading files and matching their contents against a list of known-bad patterns. That model is fine for a lone uploaded shell. It falls apart against a modern infection, because a modern infection does not live in one file. This one, an SC/SCOCV drop-in backdoor family, spread its persistence across at least nine different places:
- Fake must-use plugins. WordPress auto-loads everything in
wp-content/mu-pluginson every request, with no way to deactivate it from the dashboard. The malware dropped a loader there that re-installed the payload on the next page view. - Drop-in backdoors.
object-cache.php,db.php, andadvanced-cache.phpare WordPress drop-ins that load before plugins and themes. Poison one of those and your code runs before anything that might scan for it. - Database payload options. Chunks of the malware were stored as rows in
wp_options, some under random hex names. A file scanner never opens the database, so it cannot see any of this. - Cron guard hooks. WordPress stores scheduled tasks inside a single serialized
cronoption. The malware registered hooks in there that re-created its files on a schedule. - Rogue admin accounts. Generated usernames with administrator rights, some created a full day after the first cleanup pass, which is how we knew something automated was still firing.
- Theme functions.php injection. A block of code wedged between marker comments in the active theme, so a theme reinstall would not necessarily remove it.
- A cloaked .htaccess. Rules that served different content to one specific browser signature than to everyone else, so the owner and Google saw a normal site while victims got redirected.
- Zero-byte decoy files and directories. Empty files and folders placed to look like plugins or must-use plugins. A signature scanner skips them because there is no signature in an empty file to match.
- An SSH key backdoor. An extra key sitting in a compromised account’s
authorized_keys. This is the one that matters most, and I will come back to it.
The lesson hidden in that list
Look at where the persistence actually lived: the must-use plugin folder, three drop-in files, the options table, the serialized cron option, the users table, the theme, the web server config, empty decoys, and an SSH key. Exactly one of those categories, the drop-ins and the theme injection, is a normal PHP file with recognizable code in it. Everything else is either in the database, in a config format, or has no content to scan at all.
That is why the site kept coming back. You can delete every malicious PHP file on disk and the attacker still owns the box, because the reinstaller is a database row and the front door is an SSH key. Remove the files and the cron hook rebuilds them on its next run. Remove the files and the cron hook and the person with the SSH key logs back in and re-seeds everything. The layers protect each other on purpose.
What the file scanners missed
Everything that was not a PHP file with a known string. The database payloads. The serialized cron hooks. The zero-byte decoys. The binary state files the malware used to track itself. And the SSH key. A grep-based scanner cannot see any of those by design, not because it is a bad scanner, but because it is answering a narrower question than the situation requires.
Server-level scanners give half-cooked results
Here is the part worth sitting with. The server ran Imunify360, a paid, server-level security suite, the entire time. It did its job as designed. It recognized and quarantined individual malicious files it had signatures for. But it did not stop the reinfection, because the persistence did not live in the files it was scanning. The database payload options, the cron guard hooks buried in a serialized option, the rogue admin accounts, the zero-byte decoys, and the SSH key in authorized_keys were all outside its line of sight.
This is not a knock on one product. It is the limit of the whole file-signature model. Any server-level, file-based scan gives you a half-cooked result on an infection like this: it removes the files it recognizes and leaves everything that re-creates them, so the site reads clean and reinfects within the hour. And the malware is not standing still. This family actively self-creates fresh backdoors of every shape it can, so the shell it drops today is not the shell it drops tomorrow. In scans across this one server inside a single 24-hour window, we turned up every category on the list above: file droppers, database payloads, cron hooks, rogue admins, cloaked .htaccess rules, zero-byte decoys, and the SSH key. A scan that only answers “is this file known-bad?” cannot keep up with something whose entire design is to regrow from parts it never looks at.
A guided tour of the layers, and how we removed each one
Order of operations is everything here. If you delete the visible payload first, the reinstallers simply rebuild it while you work. So we went after the things that re-create the infection before the infection itself.
1. The must-use plugin reinstaller
The mu-plugins directory is the first thing to check on any reinfecting site, because it runs unconditionally and invisibly. We found a loader there, plus a scattering of zero-byte files whose only job was to look like legitimate plugins in a directory listing. Rule of thumb: any zero-byte file in mu-plugins is malware, and any plugin folder that contains only empty PHP files is a decoy. We quarantined the loader and swept the decoys.
2. The drop-ins
object-cache.php, db.php, and advanced-cache.php were replaced with obfuscated versions. These are dangerous precisely because WordPress loads them so early. We compared each against what a legitimate caching or database drop-in should contain, confirmed the injection, and replaced or removed them.
3. The database payloads
This is where a file scanner is blind. We queried wp_options for the malware’s known option names and for options with random hex names carrying an encoded payload, dumped them for evidence, and deleted them. The important safety detail: we only deleted a hex-named option when its value also carried a malware signature, because plenty of legitimate plugins use hashed option keys and you must not nuke those.
4. The cron guard hooks
The scheduled-task hooks lived inside the serialized cron option. Removing the malware’s files does not remove these, so a scheduled run would rebuild everything. We stripped the malicious hooks out of the cron option directly.
5. The rogue admins
We found administrator accounts with machine-generated names, and a second wave of them created a full day into the incident, which is how we knew an automated reinstaller was still alive. We quarantined each account’s data, reassigned its content to a real user so nothing was orphaned, and removed the accounts. Then we reset every remaining administrator password across all installs, so any credential the attacker had captured was dead.
6. The theme injection and the cloaked .htaccess
The theme’s functions.php carried an injected block between marker comments, which we stripped while leaving the legitimate theme code intact. The .htaccess carried a browser-cloak rule that we removed. A detail worth knowing: the cloak marker was a comment line, and a lot of tools skip comment lines when scanning, so the one line that gives the whole thing away is the one they ignore.
7. The SSH key
This was the keystone. An unlabeled key sat in a compromised account’s authorized_keys, added during the compromise window. That key is a permanent front door. An attacker with it walks straight back in over SSH after you have cleaned every file and every database row, and re-seeds the entire infection. We backed the key up for evidence and removed it. If you only take one thing from this post, let it be this: after a server compromise, audit ~/.ssh/authorized_keys for every account, and remove any key you cannot personally account for.
How to check your own site
You do not need this exact tooling to sanity-check a site that keeps reinfecting. Here is the short version of what to look at, in order:
- mu-plugins: list
wp-content/mu-plugins. Anything you did not put there, anything zero bytes, and any folder of empty PHP files is suspect. - Drop-ins: check for
object-cache.php,db.php, andadvanced-cache.phpinwp-content. If you do not run a cache or database drop-in on purpose, they should not be there. - Admins: list every administrator and its email. Look for generated usernames and for real accounts whose email was quietly changed to an address you do not recognize, which is a password-reset backdoor.
- Scheduled tasks: review WordPress cron for hooks you do not recognize.
- Options: if you are comfortable in the database, look for option names that are random hex strings with large encoded values.
- .htaccess: read the root
.htaccess, comments included, for redirect or user-agent rules you did not add. - SSH keys: read
~/.ssh/authorized_keysfor every account on the server and remove anything unlabeled or unexplained.
The discipline that actually ends a reinfection
Finding the layers is half the job. The other half is a set of habits that keep the cleanup honest.
Quarantine, do not delete. Every file and database row was archived before removal, so a mistaken flag is recoverable. On a live client site you never want a delete you cannot undo.
Preview, then confirm. Every destructive step showed exactly what it would do and waited for a go-ahead, and refused to run anything irreversible without a recent backup.
Verify after a delay. Because this family reinfects on a schedule, a clean result in the moment means nothing. We re-scanned an hour later, two hours later, three hours later, until it held. That delayed re-check is what separates a real cleanup from a screenshot of a green checkmark.
Kill the sources first. Reinstallers before payloads, always. Otherwise you are bailing water with the tap still running.
Then we hardened the tool that did it
Every gap the incident exposed went straight back into the open-source tool I used, so the next person does not have to learn these lessons at 2am. That is the same fast build-and-fix rhythm I have written about before: find a real gap in production, close it in the code the same week.
- SSH-key backdoor detection that flags unlabeled or forced-command keys.
- The non-signature scans, meaning database payloads, cron hooks, zero-byte decoys, and SSH keys, now run inside the default full scan instead of being opt-in extras a tired operator might skip.
- Known-vulnerable plugin detection by version, plus a standing policy that flags any file-manager plugin for removal, since it is one of the most common remote-code-execution entry vectors on WordPress.
- Command-injection and false-clean fixes, so a scan can never again report clean over a database it could not actually reach.
Building and maintaining tools like this is not free, and I have been candid elsewhere about what it actually costs to run this kind of work. Open-sourcing it is a deliberate choice: this class of infection should not be something only paying customers can survive.
Frequently asked questions
My security plugin says my site is clean. Can I trust it? If your site has never reinfected, probably. If it has been cleaned and came back, no. A clean result from a file scanner only tells you there are no known-bad files right now. It says nothing about the database, the cron table, or an SSH key.
Does reinstalling WordPress core fix this? No. Core reinstall replaces core files. It does not touch wp-content, the database, the theme, drop-ins, or SSH keys, which is where almost all of this lived.
Is this specific to cPanel or WHM? No. The layout details differ, but the persistence pattern, reinstaller plus payload plus a way back in, is the same on Plesk, on a plain VPS, and on a single shared-hosting site.
How do I know when it is really gone? When a full scan, including the database and SSH keys, comes back clean, and stays clean on a re-scan hours later, and no new admin accounts or files have appeared in between.
Can the malware really create new admins a day after cleanup? Yes, and that is exactly what we saw. A reinstaller sitting in the database or in a cron hook does not care that you cleaned the files this morning. On its next scheduled run it re-creates whatever it was told to, which is why a second wave of rogue admins appeared a full day into the incident.
Should I just restore from a backup instead? Only if you have a backup from before the compromise and you know the date the compromise started. On a site that has been infected for weeks, most backups are already infected, and restoring one simply reinstates the backdoor. You still have to identify the entry point and close it, or the restored site gets hit the same way.
Will changing all my passwords fix it? It is necessary but not sufficient. Resetting passwords kills stolen credentials, but it does nothing about a reinstaller in the database, a poisoned drop-in, or an SSH key. Do it as one step of a full cleanup, not as the whole cleanup.
Why infections like this are getting more common
The uncomfortable trend is that this is becoming the normal shape of a WordPress compromise, not an exotic one. Attackers have learned that a single visible shell gets caught, so they spread persistence across as many independent mechanisms as they can and let those mechanisms rebuild each other. The economics favor it: automate the reinfection once, and every cleanup that only removes files becomes free advertising for how resilient the infection is.
It is also getting more automated. The families we see now actively self-create fresh backdoors of different shapes, rotate file names, and plant decoys specifically to waste a human investigator’s time. That is why a static list of signatures is always a step behind, and why the questions that matter have shifted from what does this file contain to how is this infection re-creating itself and how did it get in. We track these families and their entry points as they appear, and publish the analysis on WP Vanguard.
Choosing a cleanup service: questions that separate real from theater
If you are paying someone to clean an infected site, the questions you ask up front tell you almost everything. A cleanup that will actually hold can answer all of these without hesitation:
- Do you check the database and the users table, or only files? If the answer is only files, the cleanup will not hold against a reinfecting family.
- Do you audit SSH keys and server cron, not just WordPress? The front door is often outside WordPress entirely.
- Do you re-scan hours later to confirm it did not come back, or do you close the ticket on the first green result?
- Do you identify how the attacker got in, or only remove what they left? An unpatched entry point means you will be back here in a month.
- Do you quarantine before deleting, so a mistake is recoverable, or is everything a permanent delete?
None of that requires a specific vendor or product. It requires treating the site as a whole system, database, files, server, and access, rather than as a folder of PHP to grep. That mindset is the actual difference between a cleanup that ends the problem and one that resets the timer.
How they got in, and why that matters most
Removing the infection is only half a cleanup. If you do not find and close the entry point, the same door lets the next attacker walk in, and you are back here in a few weeks. On this server the most likely entry vector was a vulnerable plugin, the kind of unauthenticated file-upload or remote-code-execution flaw that gets mass-exploited within hours of disclosure. File-manager plugins are a repeat offender: they exist to browse, edit, and upload files, which is to say they are a remote-code-execution surface by design, and they add nothing a site visitor ever sees.
That is why closing the entry point is now a fixed part of the work: update or remove any plugin with a known vulnerability, remove file-manager-class plugins entirely, and confirm nothing on the site is running a version with a public exploit. A cleanup that skips this step is not a cleanup, it is a pause. The infection you just removed tells you what the attacker did once they were in. The plugin they came through tells you how to make sure it does not happen again, and that second question is the one that actually protects the site over the next year.
It is free and GPL
The tool is open source under the GPL, the same license as WordPress core, and works on one site or a whole fleet, on cPanel, Plesk, or plain SSH.
- Repo: github.com/vapvarun/wp-malware-cleanup-mcp
- Full technical case study: the SC/SCOCV drop-in backdoor
- Ongoing WordPress threat research: WP Vanguard
If your site keeps getting reinfected after a successful cleanup, this is almost always why: the persistence chain is still there. Find all of it, or you find none of it.