When the cleanup went wrong

The malware came back a few days after you cleaned the site

A cleanup removes the payload and leaves whatever the attacker used to get in. When the same redirect or the same file returns within days, the entry point is still open. Three things account for almost all of it, and you can name yours in about half an hour.

Checked against a live WordPress install on .

Confirm the malware is back

A cached copy of the old page looks exactly like a fresh infection. Settle that first, before you spend an evening on it.

  • Your page cache and your CDN. Both hold the version of the page that existed during the infection, and both keep serving it after the file on disk is clean. Purge each one and load the page again.
  • Your own browser. A private window costs five seconds and rules it out.
  • The search result. Spam titles in Google are a snapshot of the day the crawler came. Open the live URL and look at what the server returns today.
  • The scan report. Most scanners show the last run's findings until the next run replaces them, so a report listing the file you already cleaned proves nothing. Run a fresh scan.

Ask the server directly and skip every cache between you and it. From a terminal, fetch the page with a query string nothing has ever cached:

  • curl -s "https://example.com/?nocache=$(date +%s)" | head -40

If the injected script is in that output, the file on the server has it. Now check when it got there. Over SSH, from the WordPress root:

  • stat -c "%y %n" wp-content/themes/yourtheme/header.php

A modification time later than your cleanup means something wrote the file again. That is a reinfection, and the rest of this page applies. A modification time from before your cleanup means the cleanup missed that copy, which is a different job.

Find how they got back in

Three things get an attacker back onto a cleaned site: a backdoor file the scan never looked at, a credential or session they still hold, and the hole they walked in through the first time. The evidence for each sits in a different place, and you can collect all of it in one sitting.

Work from the first infection, not the second

The files that came back this week tell you what the payload is. The files written on the day of the first infection tell you how they arrived. Pick the date you first noticed anything wrong, then list every PHP file written in that window:

  • find . -type f -name "*.php" -newermt "2026-08-01" ! -newermt "2026-08-03" -printf "%TY-%Tm-%Td %TH:%TM %p\n" | sort

Read that list from the top. An upload written seconds before the first backdoor appeared usually names the plugin that accepted it. A file inside a plugin directory whose timestamp matches nothing else in that directory is the file somebody edited. To sweep the last week instead, replace both dates with a single -newermt "-7 days".

Modification times lie when the attacker sets them, and a full-site restore rewrites all of them at once. Treat a clean timeline as weak evidence and a suspicious one as strong evidence.

Accounts that appeared since

A second administrator is the cheapest persistence there is, and it survives every file cleanup you run.

  • wp user list --fields=ID,user_login,user_email,user_registered,roles --orderby=registered --order=desc
  • wp user list --role=administrator

Read the email addresses as carefully as the names. An account can carry a plausible login and an address on a domain you have never dealt with.

Sessions and application passwords

A live login cookie outlasts a cleanup, and an application password outlasts a password reset. Both are listed per user:

  • wp user session list <user>
  • wp user application-password list <user>

The session list gives you a login time, an IP and a browser string for every open session. The application password list gives you a name, a created date, a last-used date and a last-used IP. An entry you did not create, or one whose last-used IP is nowhere near you, answers the question of how they got back in without touching a single file.

The access log

Your host keeps one, and it is the only record of the request that dropped the file. Pull the log for the day of the first infection and search it for two things: POST requests to a path that has no business receiving one, such as a file under your uploads directory, and repeated requests to a single unfamiliar filename from one address. Match the timestamps against the file list you built above. Where the two agree, you have the request that started this.

The backdoor your scan skipped

A scanner that reported a clean site can be right about everything it looked at and still miss the file that matters. The standard WordPress tooling has documented blind spots, and attackers plant into exactly those places.

Where a backdoor hides Why the usual check misses it How to look
wp-content/uploads The core checksum command skips wp-content in its entirety, by an explicit rule in the command. find wp-content/uploads -type f -name "*.php"
wp-content/mu-plugins Plugin checksum verification excludes the whole directory, and it loads before regular plugins on every request. wp plugin list --status=must-use
A deactivated plugin Deactivating removes a plugin from the load path and removes nothing from the server. The files stay where they were. wp plugin list --status=inactive
A premium or custom plugin With no WordPress.org manifest, plugin verification skips it and still reports success: "Verified 0 of 1 plugins (1 skipped)", exit code 0. Diff against the vendor's copy of the same version
Any theme There is no theme checksum command and no theme checksum API. The subcommand people cite does not exist. Download the same version and diff the directory
The database A file check reads files. Nothing in that chain reads a table. wp db search "<string>" --stats
A scheduled task Cron entries are rows, not files, so a file scan never sees the schedule that rewrites your payload every hour. wp cron event list --fields=hook,next_run_gmt,recurrence,args
wp-config.php It appears in no checksum set and in no release archive, so no tool reports a change to it and no download restores it. Read it yourself, at the path wp config path prints

Two of those rows deserve more than a line.

Uploads is worth searching and a poor place to delete on sight. A clean test install returned seven PHP files under wp-content/uploads, and every one of them belonged to a plugin: empty index.php guards that block directory listing, and two generated cache files. Open each hit and read it. A backdoor there tends to be one long line, or a short file whose only job is to take a parameter and run it.

Must-use plugins load first and cannot be switched off from the admin. There is no activation record to flip, so the only way to stop one is on disk. WordPress loads top-level .php files from that directory, so renaming the file to something ending in .bak takes it out of the load path while keeping the evidence.

For the database and the cron table, the string you search for comes from the payload you already found: the domain the site redirects to, the name of the function the injected code calls, or a distinctive chunk of the encoded blob. Search for the same string in both places. A scheduled hook that maps to no plugin on your site, or a table row containing your redirect target, is the thing that has been putting the file back.

One more blind spot worth knowing before you trust a clean report. Plugin checksum verification walks the files on disk and checks whether each one belongs in the manifest, so it catches an added file and a modified file and never notices a deleted one, so a missing file is invisible to it. A plugin with one file removed reported "Success: Verified 1 of 1 plugins." and exited 0.

Close the door

Do this before you clean anything. A cleanup that runs first buys you a day.

  1. Patch what is behind. Run wp plugin list --update=available, wp theme list --update=available and wp core check-update. Update everything they list. If your timeline in the previous section pointed at one plugin, update or remove that one first, and remove it outright if you were not using it.
  2. Delete the accounts you did not create. Remove any administrator that appeared during the incident. Then close the door behind them: wp option get users_can_register should return 0 unless your site needs open registration, and wp option get default_role should return subscriber. A site with registration open and a privileged default role hands out accounts on request.
  3. Change every administrator password. This invalidates that account's login cookies as a side effect, because WordPress builds each cookie's signing key from four characters of the stored password hash.
  4. Rotate the salts. wp config shuffle-salts replaces the eight keys in wp-config.php. Those keys feed the same signing key, so every cookie issued before the change stops validating, including yours. Run wp config path first: wp-config.php is allowed to sit outside the docroot, and the command takes --config-file=<path> when it does.
  5. Destroy the sessions. Salts handle the cookies. Clear the session rows too, per user with wp user session destroy <user> --all, or across the site with the loop the command documents itself: wp user list --field=ID | xargs -n 1 wp user session destroy --all.
  6. Revoke the application passwords. Nothing above touches them. List them per user, then delete the ones you cannot account for with wp user application-password delete <user> <uuid>, or clear the lot with --all and re-issue what you need.
  7. Change the credentials that live outside WordPress. Your hosting control panel, your SFTP or FTP accounts, and the database user in wp-config.php. Somebody holding any of those does not need WordPress to write a file.

Step 4 signs you out. Have the new administrator password to hand before you run it, and expect to log in again.

Clean the site, in that order

With the door shut, the second cleanup holds. Work outwards from core, because each file class has a different pristine copy and a different trap.

Core

Run wp core verify-checksums --include-root and read the warning lines rather than the exit code. On a live install with four unknown files present, that command printed Success: WordPress installation verifies against checksums., listed the four files as warnings underneath, and exited 0. One of them appeared only because of --include-root.

Then act on both halves of the result. A modified or missing core file comes back with a fresh copy of the same version. A file that should not exist, you remove by hand, because overwriting core restores the files WordPress ships and deletes nothing the attacker added. Five attacker-planted files survived a forced core download in testing, and the install verified against checksums afterwards with those five still on disk. The full procedure, including the paths that must never be overwritten, is in restore modified core, plugin and theme files.

Plugins

Reinstalling a plugin from WordPress.org with wp plugin install <slug> --force deletes the old directory before unpacking the new one, which takes any injected file inside it with it. That is the behaviour you want here, and it has a cost: anything the site legitimately kept inside that directory, such as a licence file or a generated config, goes too. Copy out what you need first. Two more edges worth knowing. Asking for the version you already have can resolve to the unversioned latest download, so you may get a newer release than you asked for. A request for a version that does not exist exits with an error and leaves the installed copy untouched, so a failed attempt costs you nothing.

Themes and everything with no upstream

There is no checksum route for a theme, so the only check is to download the same version and diff the directory. A custom theme, a child theme and your own code have no upstream copy at all, so they get read rather than compared. Same for wp-config.php, which no download restores.

If the site stops loading part way through, stop and fix that before continuing. The malware cleanup broke my site covers reading the fatal error and getting the one file back.

Check it stayed clean

A scan on the day you finish tells you the cleanup worked. Only the week after tells you the door is shut. Capture a baseline now, while the site is in the state you want it to stay in.

  1. Today. Save the output of five commands to a file: wp core verify-checksums --include-root, find wp-content/uploads -type f -name "*.php", wp cron event list, wp user list --role=administrator, and wp user session list for each administrator. This is your reference, and it is worth more than any single scan result.
  2. Day three and day seven. Run find . -type f -name "*.php" -newermt "-3 days" -printf "%TY-%Tm-%Td %TH:%TM %p\n" | sort and account for every line. On a site where nobody edits code, the only recent PHP files should come from an update you ran yourself.
  3. Day seven. Re-run the five commands from day one and diff against your saved copy. A new administrator, a new scheduled hook, a new PHP file under uploads or a session from an address you do not recognise puts you back at the top of this page with better evidence than you had the first time.

A clean week means nothing wrote to the site for a week. It is good news and it is not proof, so keep the checks running rather than declaring the incident over on day eight.

Questions

I cleaned the site and it was back within the hour. What does that mean?
Something on the site rewrote it, so start with the code that runs without anyone logging in. List the scheduled tasks with wp cron event list and match every hook to a plugin you recognise. Then list the must-use plugins with wp plugin list --status=must-use, because that directory loads on every request before the regular plugins and has no activation record to switch off. If both look right, the attacker still holds a way to sign in and did it by hand, which puts you in the credentials part of this page rather than the backdoor part.
wp core verify-checksums said Success. Does that mean the site is clean?
No. On a live install with four unknown files present, that command printed "Success: WordPress installation verifies against checksums." and exited 0, then listed each unknown file as a Warning underneath. Only a checksum mismatch or a missing file sets the error flag; a file the attacker added is a warning. Read the warnings and ignore the exit code. Pass --include-root as well, because a dropper sitting at the web root shows up only with that flag.
Can the backdoor be in my database rather than in a file?
Yes, and a file scanner has nothing to say about it, because it reads files. Search the tables yourself with wp db search "<a string from the injection>" --stats, using a distinctive fragment of the code or the domain the site redirects to. That command defaults to the tables WordPress registers, so add --all-tables-with-prefix when a plugin created its own table without registering it. It also prints which tables it skipped, and that line is worth reading before you conclude there were no matches.
I changed the admin password. Is that enough?
It kills the login cookies for that account, because WordPress builds each cookie's signing key from four characters of the stored password hash plus the salts in wp-config.php. It does not touch application passwords. Those are separate credentials with their own rows, and the only core code that removes them is an explicit revoke, never a password change. Run wp user application-password list <user> for every administrator and read the last_used and last_ip columns before you decide you are done.
There are PHP files under my uploads folder. Should I delete them all?
Read them first. On a clean test install, every PHP file under wp-content/uploads belonged to a plugin: empty index.php guards that stop directory listing, and two generated cache files. A PHP file there is worth opening because uploads is a favourite place to leave a backdoor, and it is a poor place to delete on sight.
Do I need to wipe the site and reinstall WordPress from scratch?
Rarely, and a partial reinstall is the trap. Overwriting core restores the files that shipped with WordPress and removes nothing the attacker added, so a dropper in wp-includes survives it and the install verifies against checksums afterwards. Reinstalling a plugin behaves the opposite way: the old directory is deleted before the new one unpacks, so an injected file inside it goes with it. Know which of the two you are doing before you run either.

Next

The week you cannot watch

The gap this page cannot fix is the week between your cleanup and the next time you think to look. Segurium closes that gap by watching the files: hourly change detection against a stored baseline of every path, plus a full scan at an hour you pick, daily or weekly. A file that changes after you thought you were finished shows up within the hour instead of when a visitor tells you the site redirects again. What it changes is how long you go on believing the job is finished. Keep the day-seven checks from this page on your own calendar beside it, because a quiet week is good news and not proof.