A site gets cleaned. Three weeks later the spam pages are back.
This is not bad luck and it is not unusually sophisticated malware. It means one of two things: the vulnerability that allowed the first compromise is still exploitable, or the attacker left something behind that let them return.
Removing malicious files is the visible half of the job. This is the other half, in the order that keeps the evidence you need.
Before you clean anything
Preserve the evidence
This is the step people skip in the rush to make the site clean, and skipping it usually means the entry point is never established.
# Snapshot the compromised state and the logs, off the server
tar czf /tmp/compromised-$(date +%F).tar.gz /var/www/example.com
mysqldump --single-transaction wordpress_db | gzip > /tmp/db-$(date +%F).sql.gz
cp -a /var/log/nginx /var/log/auth.log /tmp/logs-$(date +%F)/
Copy those somewhere else entirely. Once you start replacing files, the record of how the attacker got in goes with them.
Establish when it started
This determines which backups are trustworthy, which is the single most consequential fact in the whole incident. Restoring from a backup taken after the compromise reinstalls it.
# Files modified in a window, clustered by day
find /var/www/example.com -type f -newermt '2026-08-01' -printf '%TY-%Tm-%Td %p\n' \
| sort | awk '{print $1}' | uniq -c
# The first successful request to something that should not exist
grep -E 'POST .*(uploads|\.php)' /var/log/nginx/access.log | head -40
Treat timestamps as evidence rather than fact. An attacker with sufficient access can change them. Corroboration across sources that would have to be tampered with separately is worth far more than any single source.
Find the entry point
Reinfection almost always means this step was skipped. In our experience the cause is one of four things, in roughly this order of frequency.
An outdated plugin or theme with a public exploit
wp plugin list --fields=name,version,status,update
wp theme list --fields=name,version,status,update
Cross-reference against public vulnerability data for the versions that were installed at the time of the compromise, not the versions installed now. If you updated everything during cleanup, you have removed the evidence of which one it was, which is another reason to snapshot first.
A compromised credential
An administrator password reused elsewhere and exposed in an unrelated breach. Hosting or FTP credentials stored in a desktop client that was itself compromised.
# Successful logins from unexpected addresses
grep 'Accepted' /var/log/auth.log | awk '{print $11}' | sort | uniq -c | sort -rn
Something at the server layer
The compromise may have had nothing to do with WordPress. If the attacker got in through an exposed service or a vulnerability in another site on the same server, cleaning WordPress achieves nothing.
Nulled or pirated plugins
Commercial plugins distributed free are one of the most reliable malware delivery mechanisms in the WordPress ecosystem. If one is installed, treat it as the entry point until proven otherwise.
Hunt persistence at both layers
This is the part that determines whether you see the site again.
Application layer
Unexpected administrator accounts
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
Check registration dates against your timeline. Also check for accounts whose role was changed rather than created, which is quieter:
SELECT u.user_login, u.user_registered, m.meta_value
FROM wp_users u JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%';
Must-use plugins
This is the hiding place people miss most often. Anything in wp-content/mu-plugins/ loads
automatically on every request, cannot be deactivated through the admin interface, and does not
appear in the plugins list at all.
ls -la wp-content/mu-plugins/
On a site that never intentionally used them, any file here is suspicious by default.
Scheduled events
wp cron event list --fields=hook,next_run,recurrence
WordPress has its own scheduler. An attacker can register an event that re-downloads a payload, which is why a site can reinfect itself with no external access at all.
Modified core files
# Compare installed core against the official checksums
wp core verify-checksums
This is the highest-signal single command available. Core files should match exactly. Anything reported as modified needs an explanation.
Injected database content
SELECT option_name, LEFT(option_value, 120) FROM wp_options
WHERE option_value LIKE '%<script%'
OR option_value LIKE '%eval(%'
OR option_value LIKE '%base64_decode%';
Also check wp_posts for injected content, and the theme’s functions.php, which is a perennial
favourite.
Dropins
ls -la wp-content/*.php
object-cache.php, advanced-cache.php and db.php are loaded automatically and rarely inspected.
Server layer
If the attacker reached the operating system, everything above is insufficient. Check the same places you would on any Linux compromise:
# Every authorized_keys file, not just the one you expect
find / -xdev -name 'authorized_keys' -type f -exec sh -c 'echo "== $1"; cat "$1"' _ {} \; 2>/dev/null
# Per-user crontabs, which are not in /etc/crontab
for u in $(cut -f1 -d: /etc/passwd); do crontab -l -u "$u" 2>/dev/null | sed "s/^/$u: /"; done
systemctl list-timers --all
# Processes running from a deleted binary
ls -l /proc/*/exe 2>/dev/null | grep -i deleted
# Accounts with UID 0 other than root
awk -F: '$3 == 0 {print $1}' /etc/passwd
If any of this turns up something, you are no longer dealing with a WordPress incident.
Decide honestly: clean or rebuild
This decision gets deferred, and deferring it is expensive.
Rebuild if any of these are true:
- The attacker had server-level access, or you cannot establish that they did not
- You cannot date the initial compromise, so no backup is provably clean
- Persistence was found in more than one distinct mechanism, which indicates deliberate effort
- System binaries or WordPress core files were modified
wp core verify-checksumsreports differences you cannot explain
Cleaning in place is defensible when:
- The compromise was clearly confined to the application layer
- You have a reliable timeline and it is short
- Core verifies clean and the entry point is identified and closed
Rebuilding means: fresh WordPress core from source, fresh copies of every plugin and theme from official sources, and only your own content and uploads migrated across after inspection. Not a restore of the whole site directory, which is how the compromise comes back.
Rotate every credential
Anything the compromised environment could read must be assumed read. Not only the account that was obviously compromised.
- All WordPress administrator and editor passwords
- The database user password, in
wp-config.php - Hosting control panel, SSH and FTP credentials
- WordPress salts and keys in
wp-config.php, which invalidates every existing session - API keys for payment gateways, mail providers and any integration
- Any credential stored in a plugin’s settings, which is a place people consistently forget
# Regenerate salts, which logs everyone out including the attacker
wp config shuffle-salts
Close the door and add visibility
Patch the entry point. Remove plugins you do not need, because each one is standing risk. Then make a recurrence visible:
- Log authentication and user changes, and ship those logs off the host, because logs on a compromised server are logs the attacker can edit
- Record a baseline of expected files so future changes are a diff rather than an investigation
- Establish an update routine you will actually follow, since deferred updates are how most of these incidents start
- Verify a backup by restoring it, and note how long that took
The reporting and cleanup afterwards
If the site was flagged by a browser or search engine, request a review through Search Console’s security issues report once you are genuinely clean. Requesting review while still compromised extends the flag.
If customer data may have been accessible, that is a separate obligation with its own timelines depending on jurisdiction, and it is worth taking advice on rather than deciding informally.
The uncomfortable finding
The question every client asks is whether data was stolen. The honest answer is frequently that it cannot be established, because the logging needed to answer it was not in place beforehand.
If the attacker had database access, assume the data was readable. What logging existed determines how much more you can say than that.
This is the strongest practical argument for logging before you need it. It costs very little in advance and it is the difference between an answer and a shrug at the worst possible moment.