When the cleanup went wrong
Put the original files back without wiping the site
A scanner reported that core, plugin or theme files no longer match the originals. Each of the three needs a different source, two of them have traps that hand you a file which looks right and is not, and one class of file must never be restored at all.
What modified actually means
A scanner calls a file modified when its contents differ from the published copy of the version you are running. That is a factual statement about bytes. It is not a statement that the file is malicious, and several honest reasons produce it.
- Your host patched it. Managed hosts routinely ship small adjustments to core files, and some bundle their own must-use plugin that edits behaviour.
- A developer edited it. Editing core is a bad idea and it still happens, usually years ago, usually by someone who has left.
- The version is newer than the published checksums. When a release lands before the directory publishes its checksums, every file in that component comes back unrecognised at once. A whole plugin lighting up in the same instant is this, far more often than it is an attack.
- Something was injected. The case you are here for.
Read the difference before you overwrite it. A restore is not reversible unless you kept a copy first, and the file you are about to discard may contain the only record of what was done to your site. If you are not yet sure the detection is real, settle that first with the three checks that tell a real detection from a false flag.
Every command and URL below was run against a real install on 18 August 2026: WordPress 7.0.4, PHP 8.2.31, WP-CLI 2.12.0. Output is quoted as it appeared.
WordPress core
Core is the easy one. Every release stays downloadable and the file list is fixed, so a byte-for-byte original is always available.
First find out what you are running. Do not guess, because downloading a neighbouring release replaces working files with almost-right ones.
$ wp core version --extra
WordPress version: 7.0.4
Database revision: 61833
Package language: en_US Now download that exact version. There are two archives and the difference matters more than it looks.
-
wordpress-7.0.4-no-content.zipholds 3,901 files and contains nowp-contententry at all. Nothing in it can reach your themes, your plugins or your uploads. -
wordpress-7.0.4.zipholds 4,401 files, and the extra 500 are the bundled default themes, Akismet and Hello Dolly. If you deleted those on purpose, this archive puts them back.
Use the first one. Swap the version number for yours.
curl -O https://wordpress.org/wordpress-7.0.4-no-content.zip
unzip wordpress-7.0.4-no-content.zip
Everything unpacks under a single wordpress/ directory.
Replace wp-admin/ and wp-includes/ wholesale,
then copy the root wp-*.php files over. Leave
wp-content/ alone, and leave wp-config.php
alone.
A version that does not exist returns a clean 404, so a typo fails
loudly here rather than quietly:
wordpress-9.9.9.zip is a 404 with no body worth saving.
The same thing with WP-CLI
$ wp core download --version=7.0.4 --skip-content --force
Downloading WordPress 7.0.4 (en_US)...
Success: WordPress downloaded. Three things about that command are worth knowing before you run it, and all three were measured rather than assumed.
--skip-content does not skip wp-content.
WP-CLI downloads the whole archive and then drops the bundled themes and
plugins during extraction. Measured across 9,124 files, the run added
exactly three 28-byte index.php files under
wp-content/, wp-content/plugins/ and
wp-content/themes/, and left every other file there
byte-identical with unchanged timestamps. Those three are stock files
that belong in a normal install. Pass the flag anyway: without it the
full archive restores the bundled themes and Akismet as well.
--force restores and never deletes. In a
test with five attacker-added files present, all five survived the run
untouched. The install then verified against checksums while still
carrying five warnings about them. Re-downloading core is repair, and it
is not a cleanup.
It cannot touch wp-config.php. Not because
WP-CLI protects it, though it does, but because no official archive
contains one. They ship wp-config-sample.php and nothing
else. The same is true of a root .htaccess.
Plugins and themes
These come from their own source, and the archive is per version. The
URL pattern is the slug, a dot, the version, and .zip.
https://downloads.wordpress.org/plugin/akismet.5.7.zip
https://downloads.wordpress.org/theme/twentytwentyfour.1.5.zip Drop the version and you get the current release instead, which is not what you want mid-incident. Find your installed versions first:
wp plugin list --field=name --field=version
wp theme list --field=name --field=version
To browse what is available, a plugin has a Previous Versions list at
wordpress.org/plugins/<slug>/advanced/. Themes have no
such page, and that URL is a 404 for them. Use the version directories at
themes.svn.wordpress.org/<slug>/ instead. Plugins have
an equivalent at plugins.svn.wordpress.org/<slug>/tags/.
The 404 that saves a file anyway
This one costs people an hour. A withdrawn version returns HTTP 404, and
it returns it with a content-disposition header naming the
zip you asked for and a nine-byte body containing the words "Not found".
Your browser saves a file with the right name. It is not an archive.
Check the size before you unzip anything. A real plugin zip is measured in tens or hundreds of kilobytes. Nine bytes is a 404 wearing a filename.
Reinstalling with WP-CLI, and how it differs from core
$ wp plugin install akismet --version=5.7 --force
Downloading installation package from https://downloads.wordpress.org/plugin/akismet.5.7.zip...
Removing the old version of the plugin...
Plugin updated successfully.
Success: Installed 1 of 1 plugins. Read the third line. Unlike a forced core download, this
deletes the existing directory before it unpacks the new one. That is
good news for an infection: an injected file inside a plugin directory
goes with it, and in the test that produced this output an injected
akismet/dropper.php was gone afterwards, with
verify-checksums moving from an error to a pass.
It is bad news for anything you legitimately kept inside a plugin or theme directory. Custom configuration, a licence file, a generated cache or an edit somebody made to a template goes too. Copy the directory somewhere else before you run it.
A version that cannot be found is safe. All of
--version=9.9.9, --version=1.0 and a
nonexistent theme version exited with an error and left the installed
copy exactly as it was. Nothing is removed until the download succeeds.
One oddity worth knowing. If the version you pin happens to equal the
current release, WP-CLI may resolve it to the unversioned download URL. A
plugin was observed installing from the plain
<slug>.zip under
--version=8.0.4, even though the versioned URL for 8.0.4 is
a 404. If the vendor ships a new release between your check and your run,
you get that one.
When the version is gone, or never existed
Three situations have no published copy waiting for you, and each needs a different answer.
The version was withdrawn. Try the SVN listing, which often still carries tags the download server has dropped. Failing that, read the changelog for the nearest version you can get and decide whether the difference is acceptable. A file you cannot verify is worse than a file that is one patch newer.
The component is paid. Your account with the vendor has the build you licensed. Download it there and match the version number rather than taking the newest, because a major upgrade in the middle of an incident gives you two problems.
It is a child theme, or your own code. A child theme is
a sibling directory of its parent, linked only by a
Template: line in its style.css. In a test, the
child theme directory contained exactly one file and every other template
resolved from the parent. Reinstalling the parent from WordPress.org
never touches the child, and asking WP-CLI to install the child fails
with "Theme not found", because it was never published anywhere.
For that last case, restoring from upstream is not a thing that exists. Your sources are your own version control, a staging copy, or your host's backup. When none of those has a clean version either, the file has to be cleaned by hand, which is its own procedure.
The files you must not overwrite
Some files are site-specific by design. Restoring them from anywhere breaks the site more thoroughly than the infection did.
-
wp-config.php. Your database credentials, your salts and your table prefix. No archive contains it, no checksum set covers it, and no tool in this chain will even tell you it was modified. It also need not be in your docroot: on one install checked for this page it sat a level above. Runwp config pathrather than assuming. -
.htaccess. Rewrite rules, redirects and whatever your host added. The core archive contains no root.htaccess, so there is nothing to restore from. If yours was tampered with, read it and remove what does not belong. WordPress regenerates only its own block, between theBEGIN WordPressandEND WordPressmarkers. -
wp-content/uploads/. Your media library. It is in no archive and it survived every forced restore in testing. A PHP file found in there is worth removing on its own, and the images around it are not replaceable. -
wp-content/mu-plugins/and drop-ins. Often placed by your host. They load before regular plugins and they have no activation record, so a broken one cannot be turned off from the admin at all.
Checking the result, and what the check misses
For core, the check is one command.
$ wp core verify-checksums --include-root
Success: WordPress installation verifies against checksums. A file that differs, or one that should be there and is not, reports like this:
Warning: File doesn't exist: wp-includes/atomlib.php
Warning: File doesn't verify against checksum: wp-includes/rss.php
Error: WordPress installation doesn't verify against checksums. Now the part that catches people out, and it is the reason this section exists at all.
An unexpected file is a warning, not a failure. Here is real output from an install with four unknown files present, two of them named the way a dropper is named:
$ wp core verify-checksums
Success: WordPress installation verifies against checksums.
Warning: File should not exist: wp-includes/unexpected_wp_includes.php
Warning: File should not exist: wp-config-backup.php
Warning: File should not exist: wp-admin/includes/index-injected.php
Warning: File should not exist: wp-admin/unexpected_wp_admin.php
$ echo $?
0 Success, printed before the warnings, and an exit code of zero. Only a missing or mismatched file sets the failure flag. Read the warnings, and never gate a script on the exit code alone.
The scan for unexpected files is narrow. By default it
looks at wp-admin/, wp-includes/, and root
files whose names begin with wp-. In testing, a file dropped
at the web root under any other name was invisible, while
wp-config-backup.php was caught only because of how it was
named. Always pass --include-root, which widens the root
check.
Core checksums say nothing about your themes, plugins, uploads or
wp-config.php. The command discards every
wp-content entry before comparing. The published data for
7.0.4 does carry 444 of them out of 3,945 entries, covering the bundled
default themes and Akismet, and the command ignores all of them.
Plugins
$ wp plugin verify-checksums --all
Error: Only verified 7 of 9 plugins (2 failed).
plugin_name file message
akismet dropper.php File was added
Two limits, both measured. It cannot see a deleted file,
because it walks what is on disk and checks whether each file belongs. A
plugin with a file removed reported "Success: Verified 1 of 1 plugins"
and exited zero. And readme.txt and readme.md
are treated as soft changes and ignored unless you add
--strict.
A plugin that is not on WordPress.org is skipped, and the word Success still appears:
Warning: Could not retrieve the checksums for version 1.0.0 of plugin acme-pro, skipping.
Success: Verified 0 of 1 plugins (1 skipped). Read the count, not the verb. Verified 0 of 1 means your premium plugin passed by not being examined.
Themes
There is no check. wp theme verify-checksums is not a
command, and the checksum endpoint behind it does not exist either.
$ wp theme verify-checksums
Error: 'verify-checksums' is not a registered subcommand of 'theme'. Plenty of guides tell you to run that. They did not try it. To check a theme, download the same version, unzip it somewhere else, and compare the two directories:
diff -rq wp-content/themes/twentytwentyfour /tmp/twentytwentyfour The check that matters most
Verification tells you your files match the originals. It does not tell you the site is clean, and after a restore those two feel like the same sentence. They are not. A backdoor added as a new file passes core verification with a warning and a zero exit code, sits in a plugin directory you did not reinstall, or lives somewhere neither command looks at all, such as your uploads folder or a must-use plugin.
Finish by looking at what changed recently rather than at what differs from upstream. If the site is still broken after the restore, the file you fixed was not the only one, and reading the fatal error properly will name the next one. If it comes back a week later, the entry point is still open and that is a different problem.
Questions
- Will restoring core files delete my posts, pages or images?
- No. Your content is in the database and your uploads are in wp-content, and neither is in the core archive. The archive that carries no wp-content at all is the safest one to work from, because then there is nothing in it that could reach your themes, your plugins or your uploads.
- Does re-downloading core remove the malware?
- Only the part of it that was written into a core file. Anything the attacker added as a new file is untouched, because a forced re-download writes the files the archive contains and walks past everything else. This was measured: five added files survived a forced core re-download, and the install then verified against checksums while still carrying five warnings about them.
- Is there a wp theme verify-checksums?
- No. WordPress.org publishes checksums for core and for plugins, and nothing at all for themes. The command does not exist and neither does the API endpoint behind it. Any guide that tells you to run it is repeating something it did not try. Download the same version of the theme and compare the directories instead.
- Why did my premium plugin pass verification?
- Because it was never checked. A plugin with no manifest on WordPress.org is skipped, and the command still reports success. Read the count in the output rather than the word Success: verified 0 of 1 plugins with 1 skipped means nothing was examined.
- The exact version I have is no longer downloadable. What now?
- Check the SVN listing for the plugin or theme, which usually still carries tags the download server has withdrawn. If it is genuinely gone, restoring the neighbouring version and accepting the difference is safer than leaving a file you cannot verify, so long as you read the changelog first. For code that never had an upstream copy at all, the file has to be cleaned by hand.
- Can I just restore everything from my host's nightly backup?
- You can, and it is the right move when the site is unusable and you know the backup predates the infection. What it does not do is close the way in. A backup from before the infection still contains whatever hole was used, so the site is available again and equally reachable.