Anatomy of a Persistent WordPress Infection: Five Layers Deep
When a customer reports “something is filling up disk space,” the instinct is to look for a runaway log file or a misconfigured backup job. Most of the time, that’s exactly what it is. This time, it wasn’t, and what we found underneath is a good case study in how modern WordPress compromises are built for redundancy, not just initial access.
Here’s what one investigation turned up, layer by layer, and why each layer alone wouldn’t have been enough to call the site clean.
It started with a disk space complaint
The first /tmp sweep turned up nothing but LiteSpeed cache noise, normal, expected, not the problem. The actual signal only appeared once we checked disk usage per site-user rather than per directory: one site was consuming nearly 10GB, most of it in wp-content, and a chunk of that in a backup plugin’s local archive folder that hadn’t been touched in over two years. Old, harmless, unrelated to the active problem.
The real lead came from watching /tmp for newly created files, owned by the site’s own system user, appearing every couple of hours. That pointed at something running as part of the site itself, not a stuck cron job, not a backup process, but the site actively creating files on every execution.
Layer 1: A disguised cloaking script
The first malicious file we found was sitting at a path designed to look like legitimate WooCommerce core code, a plugin internals directory, a filename that fit the surrounding structure. Its actual function: detect whether the visitor was Googlebot (by checking known crawler IP ranges) or carried a specific URL parameter, then fetch spam content from a remote server and serve it in place of the real page. Classic SEO cloaking, inject spam pages that only search engine crawlers see, while real visitors get the normal site.
Critically, it cached its fetched payload to /tmp on every execution using PHP’s tempnam(). That’s what was filling the disk. The disk-space complaint and the malware were the same finding, we just didn’t know it yet.
Layer 2: A full file-manager webshell
Checking for other recently-modified files in the site root turned up a second artifact: a PHP file disguised with an innocuous, theme-adjacent name, containing a complete file manager, upload, edit, delete, create folders, all through a password-gated web interface. Multi-language, rotating page titles (to make each pageview look different and evade simple detection), full write access to anything the web server user could touch.
This is the kind of tool that explains everything else. Once an attacker has this, they don’t need a second vulnerability, they can drop, edit, or remove anything they want, any time they want.
Layer 3: An auto-loading dropper in mu-plugins, and its overlooked sibling
This is the one that mattered most, and the one that took longest to find. WordPress’s “must-use plugins” directory (mu-plugins) loads its contents automatically on every single bootstrap, every page view, every cron run, every WP-CLI command, with no toggle, no deactivation option, nothing visible in the normal plugin list.
A second copy of the same cloaking-dropper family was sitting there. It explained a detail that had been quietly derailing the investigation for a while: WP-CLI commands were returning a mysterious 404 HTML blob instead of their normal output. Once the mu-plugin was found and removed, that particular artifact stopped — but not before it had cost real time chasing red herrings (a legitimate backup sleep-loop, some hosting-platform files, and eventually a completely unrelated firewall plugin that turned out to be silently blocking CLI traffic on its own, for its own reasons).
Later in the same investigation, a third copy of this exact payload turned up, same file size, same obfuscation, sitting inside a directory named to look like a web server config folder, nowhere near wp-content at all. Both malicious files shared a distinctive naming convention: three or four alphanumeric characters, an underscore, a single digit, and a .php extension. That pattern, combined with matching file size, turned out to be a reliable fingerprint for finding every copy of this specific payload across the filesystem, a useful lesson in itself, since attackers frequently reuse the same drop-tooling (and its naming habits) even when they scatter copies into multiple unrelated locations.
The takeaway: mu-plugins, and genuinely unexpected directories anywhere in the docroot, should be part of every incident-response file sweep by default. They’re obvious persistence targets precisely because most admins, and a lot of tooling, don’t think to look there first.
Layer 4: An exposed emergency-recovery backdoor
Sitting in the site root was a legitimate, vendor-supplied WordPress recovery tool, the kind designed to be manually uploaded when an admin is locked out, used once, and deleted. It was still present, with its access password sitting in plaintext inside the file itself.
Whether this was a forgotten leftover from a previous legitimate use or something an attacker planted deliberately, its capabilities made it moot either way: full database access, the ability to create a new administrator account, disable every plugin, and view raw database credentials, all gated behind a six-character password anyone could read directly from the file.
Legitimate tools left in place past their useful life are backdoors regardless of who put them there.
Layer 5: Bulk rogue admin accounts
The last layer was the most numerically significant. A site security scan surfaced fifteen “administrator” accounts. Ten of them followed an obvious template: randomly paired first/last names, each with a disposable Gmail address suffixed with a random number string. Two of them used a username pattern that was “admin” with its letters transposed, designed to look unremarkable in a glance at a user list without literally matching a name anyone would flag.
This is what full webshell access gets you: not just file changes, but silent, persistent account-level control that survives a plugin reinstall or a password reset on the accounts you do know about.
Why this matters: redundancy is the point
No single one of these five things was “the” infection. Removing the cloaking script alone would have left the webshell. Removing the webshell would have left the mu-plugin dropper still firing on every page load, and its twin sitting quietly in an unrelated directory. Removing those would have left ten admin accounts with full standing access, no exploit required. Removing those would have left an exposed recovery tool capable of creating more.
This is the pattern worth internalizing: modern WordPress compromises are rarely a single foothold. They’re built in layers specifically so that a partial cleanup — patch the plugin, delete the obvious shell, change the admin password, leaves the door open somewhere else. A thorough response has to check:
- Site root and all wp-content subdirectories for unfamiliar files, especially ones with names that plausibly blend into a real plugin’s structure
- mu-plugins, every time, it’s autoloaded, invisible in the plugin list, and increasingly a first choice for attacker persistence
- Genuinely unexpected directories anywhere in the docroot, including ones named to resemble server infrastructure rather than WordPress content
- The full admin user list, cross-referenced against who the site owner actually recognizes, not just a glance for “administrator” role, since role-detection tooling can itself be fooled by non-standard table prefixes or altered capability metadata
- Any recovery/emergency-access tools that may have been left in place past their intended single use
- Outbound network behavior, not just file contents, a file that “looks fine” but phones home on every load is still active
None of this replaces a full credential rotation and clean reinstall once a compromise of this depth is confirmed. But knowing to look for all five layers — rather than stopping at the first thing you find — is the difference between a clean site and a site that looks clean for a week.
