Why "Self-Healing" WordPress Malware Keeps Coming Back
By Stu 8 min read
There is a particular kind of WordPress infection that makes people think they are losing their minds. You delete a malicious file. You refresh the page. The file is back, sometimes in under a second. You replace WordPress core with fresh files from wordpress.org. You reinstall every plugin from source. You replace the theme. The site is still infected.
People call this self-healing malware, and the name is doing real damage, because it suggests the files are the thing repairing themselves. They are not. Something else is running on every request and writing them back. The files you keep deleting are the output, not the cause, and until you find what is producing them you are mopping the floor with the tap running.
Here is where that something usually lives.
The problem with replacing core
Replacing WordPress core feels like it should be decisive. It is a known-good copy of the software from a trusted source, so surely anything malicious is gone.
The catch is what "core" means. When you replace core, you replace wp-admin, wp-includes, and the PHP files in the root. You do not replace wp-content, because that directory is yours. Your themes, your plugins, your uploads all live there, and any installer that wiped it would destroy the site.
So wp-content survives. And wp-content contains several places where a single PHP file loads automatically, with no entry in the plugins screen and no way to switch it off from the admin.
Three files that load themselves
wp-content/mu-plugins/ is the must-use plugins directory. Any .php file in it runs on every request. There is no activate or deactivate. Most installs have nothing in there at all, so anything you find is worth explaining. Some hosts legitimately use it, which is what makes it a comfortable hiding place.
wp-content/db.php is a drop-in. If that file exists, WordPress loads it on every request that touches the database, which is effectively every request. It needs no setting enabled and no configuration. Creating the file is sufficient.
wp-content/advanced-cache.php is the same idea, loaded when WP_CACHE is defined as true in wp-config.php. Do not treat "I do not use a caching plugin" as a reason for it to be absent. An attacker who can write to wp-config.php can define that constant themselves.
Both drop-ins have entirely legitimate uses. Plenty of caching and database tools install one. That is exactly why a malicious one does not look out of place at a glance.
The one that runs before WordPress
The three above are WordPress mechanisms, so replacing WordPress at least gets you into the right neighbourhood. This next one is not.
A .user.ini file lets you override PHP settings for a directory when PHP runs under CGI or FastCGI, which covers most modern WordPress hosting, PHP-FPM included. It is the FastCGI equivalent of putting PHP directives in an .htaccess file. One of the settings it accepts is auto_prepend_file, which names a PHP file to run before the requested script.
Sit with that for a second. If a .user.ini in your web root points auto_prepend_file at a file the attacker controls, that file executes on every PHP request, before index.php, before WordPress bootstraps, before any security plugin exists to have an opinion.
At that point you can replace core, replace every plugin, replace the theme, and change every password, and the site is still compromised, because none of those things is what is running. This is the mechanism behind most infections that feel supernatural. Nothing is healing. Something upstream of WordPress is being asked to rebuild the payload several times a second, and it is obliging.
Where else it hides
Two more worth knowing about, because they survive things people assume are thorough.
WP-Cron. WordPress keeps its schedule in the database, and any code running on the site can add to it. An event that fires every few hours and re-downloads a payload will happily outlive a file cleanup. List what is scheduled with wp cron event list, and check the intervals too with wp cron schedule list, because malware sometimes registers its own custom schedule. You are looking for hooks you cannot attribute to core, a plugin, or your own code.
Database triggers. This one is rarer and nastier. A MySQL trigger is code stored in the database that fires when a table is written to. Attached to wp_options, it can re-inject a value every time the row is updated. It is not in any file, so file integrity monitoring will not see it. It is not in the table contents either, so a search and replace of your data misses it. Check with SHOW TRIGGERS. A stock WordPress install has none.
The defence against the trigger case is one you may already have. Creating a trigger requires the TRIGGER privilege, which the WordPress database user does not need and should not have. If you granted ALL PRIVILEGES when you set the site up, as most tutorials and control panels do, you handed it over without meaning to.
Stop the site before you clean it
This is the step that decides whether the cleanup works, and it is the one most often skipped.
If code runs on every request, then every request is another chance for the infection to rebuild. Cleaning a site while it is still serving traffic is a race you will lose. Maintenance mode is not enough either, because maintenance mode is still WordPress running, and a .user.ini prepend fires just as reliably on a maintenance page as a real one.
Stop the web server, or firewall the site down to your own IP, and do the work with nothing else able to reach it.
One detail that catches people out. PHP caches .user.ini contents for five minutes by default. Delete one and the directive can stay live until that cache expires or PHP-FPM restarts. If the infection appears to survive removal, restart PHP before concluding there is a second one.
If you have more than one site on the account
Everything above gets considerably worse when several sites share a single hosting account, and this is common. A cPanel account will host as many sites as you point at it, at no extra cost and with no warning, so people accumulate ten or twenty over the years without ever making a decision about it.
Sites in one account usually run as one Linux user. They are not neighbours, they are the same tenant. A vulnerability in one site's contact form plugin gives an attacker write access to every other site in the account, because to the file system it is all the same person's files.
Which means cleaning one site is pointless while the rest are infected and reachable. The moment any of them serves a page, the payload writes itself back across the account. People describe cleaning sites in sequence for days and watching the first ones reinfect while they work on the last. It is not twenty infected sites. It is one compromised account with twenty entry points, and the whole account has to go down at once and be treated as a single unit.
The fix afterwards is separate accounts, not separate folders. On a reseller plan that is what the plan is for. On a VPS it is a separate system user per site. It is worth asking your host whether they run CageFS or similar isolation as well, though note that isolates accounts from each other, so it does nothing for sites sharing one.
The audit, in short
If you are checking a site right now, this is the list:
ls -la wp-content/*.phpfor drop-ins, and account for every resultls -la wp-content/mu-plugins/for must-use pluginsfind . -name ".user.ini"and read any that turn upfind wp-content/uploads -type f -name "*.php", which should return nothing at allwp cron event listfor scheduled events you cannot explainSHOW TRIGGERSin the database- Unexpected administrator accounts, which tend to reappear if a persistence mechanism is still live
One more, for when you rebuild. Do not restore wp-content/uploads wholesale from a backup without scanning it. It is the one directory that cannot be replaced from a known-good source, so it gets restored on the reasoning that it is just images. It is also the most reliably writable place on the install and a natural spot for a dropper wearing a .jpg on the end. Blocking PHP execution under uploads at the web server level makes anything written there inert, which closes the route rather than cleaning up after it.
Where this fits
None of this is exotic. It is a handful of documented WordPress and PHP features being used exactly as designed, by someone who is not you. The reason these infections feel unkillable is that almost everyone attacks them at the file level, and the file level is the symptom.
The Protect My WP handbook covers the auto-loading files in Chapter 8, the account isolation problem in Chapter 13, and the cleanup ordering in Chapter 11. If you take one thing from this post, make it the ordering: stop execution, then find what runs first, then clean. In that sequence it is a bad afternoon. In any other sequence it is a bad fortnight.
Get the book for £19.
Get the free WordPress Security Checklist
The security checks I'd run through on any WordPress site, delivered straight to your inbox.
Want to go deeper?
The first chapter of Protect My WP is free. Start with the foreword, then read Chapter 1 on hosting and server security. There is also a shorter guide that walks the same ground faster if you want the shape of the book first.