When the cleanup went wrong
The malware cleanup broke your site. Here is how to get it back.
Most cleanup tools take the whole file when a few injected lines were the problem. If that file belonged to a working plugin or theme, WordPress still calls it, and what breaks depends on whether the tool deleted the file, emptied it, or renamed it. Those three look nothing alike, so start by working out which one you have.
Find the file the error names
If the site returns a fatal error, the error already contains the answer. WordPress hides it from visitors by default, so your first job is making it visible to you.
- Switch the log on. Open
wp-config.phpand setWP_DEBUGto true,WP_DEBUG_LOGto true andWP_DEBUG_DISPLAYto false, above the line that says to stop editing. With display off, visitors keep seeing WordPress's error page while every line still lands in the log. - Read the log. It is written to
wp-content/debug.log.WP_DEBUG_LOGalso takes a path string if you would rather put it somewhere the public cannot reach. - Reload the broken page once, then read the last entry. The fatal names the file that failed and the line that tried to use it. That path is the file you are going to restore.
- If the log stays empty, check the host's error log. Most hosting panels expose it under a Logs or Errors section. A fatal that kills PHP before WordPress configures its own logging shows up there and nowhere else.
A deleted plugin file produces a recognisable trace. The first frame sits
in wp-settings.php, in the loop that includes every active
plugin, and the message carries the plugin's own path. That tells you the
damage is in wp-content/plugins rather than in core, before
you look at anything else.
Leave WP_DEBUG_DISPLAY at false for the whole of this. A
fatal printed to the page leaks absolute paths, and on a site that was
just cleaned those paths are worth something to whoever wrote the
injection.
Deleted, emptied, or renamed
Cleanup tools take a file in three different ways, and each one produces a completely different symptom. The table comes from breaking a WordPress 7.0.4 install one way at a time and watching what happened.
| What the cleanup did | What a visitor sees | What the log says | What the admin says |
|---|---|---|---|
| The file was deleted and a plugin still requires it | HTTP 500 and the critical error page | A fatal error naming the missing file, raised inside the plugin include loop | Down with the same error |
| The plugin's main file was renamed or moved | HTTP 200, the site renders | Nothing | Silent until you open the Plugins screen, which deactivates the plugin and says the plugin file does not exist |
| The plugin's main file was emptied to zero bytes | HTTP 200, the site renders | Nothing | The plugin disappears from the list while staying active in the database |
The emptied file is the one that fools people
A truncating cleanup leaves the file exactly where it was, at zero bytes.
WordPress serves HTTP 200, the page renders, and debug.log
receives nothing. The plugin stays in the active_plugins
option in the database. But the function WordPress uses to read plugin
headers returns nothing for an empty file, so the plugin vanishes from the
Plugins screen and from wp plugin list at the same time. The
feature stops working and no error is raised anywhere.
That makes wp plugin list an unreliable inventory after a
cleanup, which matters because it is the first thing most people run.
Cross-check it two ways. Read the option directly with
wp option get active_plugins, or with
get_option('active_plugins') if you are already in PHP, and
list the directory with a plain ls -l wp-content/plugins. A
plugin present in the option, present on disk, and missing from the list
has an empty or unreadable main file.
The renamed file fixes itself, once
A missing main file also produces HTTP 200 and no log line, and WordPress leaves the plugin active until somebody opens the Plugins screen. That screen is the only place in WordPress that validates the active list. When it runs, it drops the entry and prints that the plugin has been deactivated due to an error, and that the plugin file does not exist. So opening the Plugins screen both fixes this case and tells you which plugin it was.
An emptied file gets no such treatment. WordPress never validates it, because as far as the validator is concerned the file is right there.
Put that one file back
Restore the single file the error named, from the upstream copy of the exact version you have installed. Copying one file out of an archive changes nothing else on the site, which is what you want while you still do not know how far the damage goes.
A core file
Download
https://wordpress.org/wordpress-<version>-no-content.zip
for the version you run, unzip it somewhere outside the site, and copy the
one file across. That archive is the right one for this job: it carries no
wp-content directory at all, so nothing you have can be
overwritten by accident. For 7.0.4 it holds 3,901 files against the full
archive's 4,401.
The full archive also restores the bundled default themes, Akismet and
Hello Dolly into wp-content, so if you removed those on
purpose they come back.
On some hosts, wp core download --force without
--skip-content takes the tar.gz route, and PHP's archive
reader cuts long paths at 100 bytes. On the install these tests ran on it
happened twice in a row, and about 25 files under
wp-includes/php-ai-client/ landed with truncated names while
the real files were absent. Pass --skip-content, which forces
the zip route, or unzip a manual download yourself.
A plugin or theme file
Plugins come from
https://downloads.wordpress.org/plugin/<slug>.<version>.zip
and themes from
https://downloads.wordpress.org/theme/<slug>.<version>.zip.
Match the version you had, not the newest. Read it off the Plugins or
Themes screen if the admin loads, or out of the component's own
readme.txt if it does not.
To see which versions exist, open
https://wordpress.org/plugins/<slug>/advanced/ and look
at the Previous Versions list. Themes have no such page, and that URL
returns a 404 for them. Use the theme's directory listing at
https://themes.svn.wordpress.org/<slug>/ instead, which
shows one directory per released version.
Copy the file out of the zip by hand rather than reinstalling the
component. wp plugin install --force and
wp theme install --force delete the existing directory before
unpacking, and they say so in their output. That is useful when a dropper
is sitting inside the plugin folder, and expensive when the folder also
holds a licence file, a config file or a cache the site needs.
A withdrawn version returns HTTP 404 with a nine-byte body reading "Not found", and it still sends a filename header, so a browser saves a file that looks like a successful download. Check the size before you unzip. A real plugin zip is not nine bytes.
A custom theme, a child theme, or a paid component
None of these has an upstream copy on WordPress.org. A child theme can be
a single style.css with a parent named in its header, and
reinstalling the parent never touches it. Trying to install a child theme
by slug fails with a theme-not-found error, because it was never published
anywhere. Paid plugins come from your vendor account, at the version you
licensed. For everything else it is your host's backup, your version
control, or your staging copy. The per-class procedure, including the
paths you must never overwrite, is in
restore modified core, plugin and theme files.
Check the restored file for the injection
You have just written the upstream copy over the file, so the injected lines that were in that file are gone with it. That settles one file. It settles nothing else, and the two most common mistakes at this point both come from assuming otherwise.
A re-download restores, it never removes. Forcing core back onto the site rewrites the files the archive contains and deletes nothing. In a test with five attacker-added files present, all five survived the run, and the install afterwards verified against checksums while still reporting five warnings about files that should not exist. A backdoor added next to core is untouched by every step on this page.
The verification commands are quieter than they look.
Run wp core verify-checksums --include-root and read the
warning lines, not the exit code. That command prints its success message
before the warnings and exits 0 with unknown files sitting in
wp-includes and wp-admin, which was captured
live with four unknown files present, two of them named like droppers.
Without --include-root it also misses unknown files at the
web root.
Know what those commands do not cover before you trust a clean result.
Core checksums throw away every wp-content entry before
comparing, so they tell you nothing about your themes, plugins, uploads or
wp-config.php.
wp plugin verify-checksums skips any plugin with no
WordPress.org manifest and still reports success, so premium and custom
plugins are invisible to it, and it walks the files on disk rather than
the manifest, so a deleted file is never noticed. There is no theme
equivalent at all: wp theme verify-checksums is not a real
subcommand and the theme checksum URL returns a 404. Much of the web says
otherwise. A theme can only be checked by downloading the same version and
diffing it.
If the file you restored was flagged in the first place, work out whether the detection was right before you decide the incident is over. Telling a real detection from a false flag covers that, and what each scanner does with a file it removes tells you whether your own bytes are still recoverable from the tool that took them.
When the broken file is an mu-plugin
Must-use plugins load from wp-content/mu-plugins before
regular plugins, and there is no activation record for them. A fatal in
one takes the whole site with it, admin included, and none of the usual
escapes work.
This was tested rather than assumed.
wp plugin list --status=must-use dies with the same fatal.
wp plugin deactivate dies. So does
wp option get siteurl --skip-plugins --skip-themes, because
--skip-plugins does not skip must-use plugins. WP-CLI's own
help says so.
The fix is on disk. Rename the file so it no longer ends in
.php, for example from loader.php to
loader.php.off. WordPress picks up only top-level
.php files in that directory, so the renamed file stops
loading and the site comes back. Then restore or repair it at your leisure
and rename it back.
Two things make mu-plugins worth a second look after any cleanup. They
have no upstream to compare against unless you wrote them,
and wp plugin verify-checksums --all excludes them from its
run entirely. Silence there means nothing was checked.
When the broken file is wp-config.php
Nothing can restore this file for you, and the reason is worth
understanding before you waste an hour looking. No WordPress release
archive contains a wp-config.php. Both the full 7.0.4 zip and
the content-free one ship wp-config-sample.php and nothing
else. The checksums API has no entry for it either, and WP-CLI names it
explicitly in the exclusion list built into the tool, so a forced core
download can neither restore it nor overwrite it. In one test its md5 was
identical before and after.
Find it before you assume where it is. Run wp config path.
WordPress allows wp-config.php to sit one directory above the
WordPress root, and on the install these tests ran on it did exactly that:
the config was at /var/www/html/wp-config.php while WordPress
itself was in /var/www/html/wp/.
To rebuild it, take wp-config-sample.php from the release
archive for the shape, then fill in your database name, user, password and
host, your table prefix, and fresh authentication keys and salts. Changing the salts logs everybody out, which after a
cleanup is a feature. Your host's control panel holds the database
credentials, and your host's backup holds the original file if you would
rather copy than rebuild.
Stop the next cleanup doing it again
One question separates a cleanup that can break a site from one that cannot: when the tool finds an injection inside a working plugin or theme file, does it write the file back without the injected lines, or does it take the file?
- Ask before you let it act, not after. Across the popular scanners, deleting is the norm and keeping a copy is the exception. Every one of them was read from its shipped source on 18 August 2026, and the split is not what the marketing pages suggest.
- Do not read Repair as restore. In every plugin that offers it, repair means downloading the vendor's published copy and writing it over what is there. Your edits to that file are gone either way, and the tool never reads your bytes first.
- Assume custom code has no remedy at all. Every repair path in every one of these plugins is gated on recognising the file as WordPress core, a WordPress.org plugin, or a WordPress.org theme. A custom theme and a paid plugin match none of the three, so the only offer left on those files is removal.
- Turn automatic removal off on a site with custom code. Review costs you a click. An unattended cleanup on a file you cannot download again costs you the file.
After any cleanup, check the result the way this page started. Load the front page and the admin, then compare the file the tool touched against the upstream copy of the same version. A file that now matches upstream byte for byte was restored. A file that is zero bytes was truncated, and you have the problem in the middle of this page.
Questions
- The site is up but a feature stopped working and there is no error anywhere. What happened?
- Look for a file of zero bytes. A plugin main file that exists but is empty produces nothing at all: HTTP 200, the page renders, and debug.log stays empty. WordPress still carries the plugin in the active list in the database, but the function that reads plugin headers returns nothing for an empty file, so the plugin drops off the Plugins screen and out of wp plugin list at the same moment. That is exactly what a cleanup that truncates a file leaves behind. List wp-content/plugins with sizes and look for a main file at 0 bytes.
- wp plugin list no longer shows the plugin. Did the cleanup delete it?
- Maybe, and that command cannot tell you. wp plugin list reads plugin headers, so a plugin whose main file was emptied is absent from the output while its directory is still on disk and its entry is still in the active_plugins option. A renamed or deleted main file drops out of the same list. Read the option and list the directory before you conclude anything, because those cases need different fixes and the list looks identical for all of them.
- Can I re-download WordPress to undo the cleanup?
- It restores files, and it removes none. A forced core download rewrites wp-admin, wp-includes and the root wp-*.php files from the archive, and it deletes nothing outside them. Measured across 9,124 files, one run repaired a tampered core file, added three 28-byte index.php placeholders under wp-content, and left every other wp-content file byte-identical. In the same test five attacker-added files survived untouched, and the install then verified against checksums while still printing five warnings about files that should not exist. So the re-download fixes core damage and does nothing about a backdoor sitting beside it.
- WP-CLI dies with the same fatal no matter what I skip.
- You are looking at a must-use plugin. The --skip-plugins flag does not skip mu-plugins, and WP-CLI says so in its own help. Every command dies, including wp option get with both skip flags set, because mu-plugins load before anything gets to decide not to load them. There is no activation record to switch off either. Rename the file on disk so it no longer ends in .php and the site comes back.
- Where do I get a clean wp-config.php?
- Nowhere public. No WordPress release archive contains one, only wp-config-sample.php, and the checksums API has no entry for it. WP-CLI names it in the exclusion list read from its own phar, so a forced core download cannot overwrite it, and the file's md5 was identical before and after such a run. No published copy of your database credentials exists to restore from. Your host's backup or your own copy is the only route. Run wp config path before you go looking, because wp-config.php is allowed to live one directory above the WordPress root and on the install these tests ran on it did.
- I put the file back and the site loads. Is the site clean?
- That one file is clean if you copied it out of the upstream archive for the version you have. The rest of the install is unmeasured. Core checksums cover wp-admin, wp-includes and the root files, and the check discards every wp-content entry before comparing, so they say nothing about your themes, plugins, uploads or wp-config.php. Plugin checksums skip any plugin with no WordPress.org manifest and still print Success, and they cannot see a deleted file at all. Themes have no checksum route in either WP-CLI or the API.