Next note →
WordPress2 min read

Cleaning up a compromised WordPress site, step by step

The checklist I follow when a client's site starts redirecting to spam: contain, find the entry point, rebuild from clean sources, then harden.

The call usually starts the same way: "Google says my site is dangerous" or "it keeps redirecting to a pharmacy site, but only on my phone." After cleaning up a fair number of these, I follow the same order every time, because skipping a step is how the infection comes back a week later.

1. Contain it and take a backup first

Put the site in maintenance mode or restrict it by IP, then take a full backup of files and database, infected as it is. It sounds backwards, but that backup is your evidence. When you need to know how they got in, or you accidentally delete something the client needs, you'll want it.

Then change every password that touches the site: hosting panel, SFTP/SSH, the database user, and every WordPress administrator. Assume all of them have leaked until you know otherwise.

2. Find what changed

Malware rarely hides well from a few shell commands. Recently modified PHP files, obfuscation patterns and PHP files inside uploads catch the large majority of what I see:

bash
# PHP files modified in the last 10 days, newest first
find . -type f -name "*.php" -mtime -10 -printf '%TY-%Tm-%Td %p\n' | sort -r

# Common obfuscation patterns
grep -rlE "eval\s*\(\s*(base64_decode|gzinflate|str_rot13)" --include=*.php .

# PHP never belongs in the uploads folder
find wp-content/uploads -type f -name "*.php"

Also check .htaccess files in every directory (mobile-only redirects usually live there as RewriteCond rules on the user agent), any mu-plugins you didn't install, and the server's cron jobs.

3. Check the database too

Cleaning the files and ignoring the database is the most common reason a cleanup fails. Look for administrator accounts nobody recognises, and for scripts injected into options and posts:

bash
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
sql
SELECT option_name, LEFT(option_value, 120)
FROM wp_options
WHERE option_value LIKE '%<script%'
   OR option_value LIKE '%eval(%';

SELECT ID, post_title
FROM wp_posts
WHERE post_content LIKE '%<script%src=%';

Confirm siteurl and home in wp_options still point to the right domain, and remove any user you can't account for. Check with the client before deleting, though, since the odd forgotten developer account is real.

4. Rebuild from clean sources

Don't try to cut the bad lines out of infected core files. Replace them. WP-CLI makes this quick and tells you exactly what differs from the official release:

bash
wp core verify-checksums
wp plugin verify-checksums --all

# Replace core files without touching wp-content
wp core download --force --skip-content

# Reinstall each plugin from wordpress.org
wp plugin install contact-form-7 --force

Premium plugins and themes don't have public checksums, so download fresh copies from the vendor. Anything nulled or "free premium" gets deleted outright, since pirated plugins are one of the most common ways in to begin with.

5. Harden it so it stays clean

bash
# New auth keys and salts log everyone out, including the attacker
wp config shuffle-salts
wp-config.phpphp
define( 'DISALLOW_FILE_EDIT', true );
define( 'FORCE_SSL_ADMIN', true );
wp-content/uploads/.htaccessapache
<FilesMatch "\.(php|phtml|php\d)$">
  Require all denied
</FilesMatch>
  • Update core, every plugin and every theme, and remove the ones that aren't active.
  • Turn on two-factor authentication for every administrator.
  • Put a WAF in front of the site. Cloudflare's free plan or a security plugin's firewall both stop a lot of automated attacks.
  • Set up off-site daily backups, so the next incident is a restore instead of a forensic job.

The cleanup itself is rarely the hard part. Finding out how they got in, usually an outdated plugin or a reused password, and closing that door is what keeps the client from calling again next month.

Next noteIntegrating PayHere payments into a Next.js + Express stack →