TechEarl

How to Remove WordPress Malware: The Practitioner's Playbook

A step-by-step methodology for finding and removing malware from a compromised WordPress site, written by a Security+ certified engineer who's been cleaning sites since the early WordPress 2.x era. Covers every attack vector: file backdoors, database injections, .htaccess hijacks, wp-config tampering, and recurring reinfection. Originally written in 2016, updated regularly as new patterns emerge.

Ishan KarunaratneIshan Karunaratne⏱️ 25 min readUpdated
Step-by-step WordPress malware removal: identify the attack vector (files, database, .htaccess, wp-config), clean every layer, rotate credentials, and lock down to prevent reinfection. Cross-platform scripts for Linux and macOS.

A WordPress malware removal is a forensic operation. You're not just deleting bad files. You're identifying every place the attacker established persistence (files, database rows, scheduled tasks, hidden admin users, server-level cron, mail-queue hooks), removing all of them in a single pass, rotating every credential the site touched, and putting controls in place so the next compromise gets noticed before the site is repurposed for SEO spam, credit-card skimming, or a phishing redirect.

This article is the playbook I use when I'm called in to clean a hacked site. I've been working with WordPress since the 2.x days, hold a CompTIA Security+ certification, and spend a lot of time on the Linux / web-server side where most of these compromises actually live. My own sites have never been hacked. The sites I clean were almost always built by another developer, run too many plugins, and were not being updated. Your situation is fixable, but only if you treat every layer below.

I first wrote this article in 2016 and have updated it periodically as new attack patterns emerge. The methodology has held up: the seven-layer cleanup flow, the file-system + database + credential triage, and the specific places attackers hide have not really changed in a decade. What has changed is the specific plugin CVEs and malware families I see most often. Those get refreshed in this article roughly once a year.

What this guide covers (and what it doesn't)

This is the hub article in the WordPress Security series. It walks through the full remediation flow end to end. For deep dives into individual attack vectors I link out to dedicated articles as we go. If you arrived here from a specific symptom (recurring wp-config.php injection, suspicious <script src="//..."> tags, mysterious admin users, search-result hijack), skip to the matching section.

What this guide does not cover:

  • Buying a commercial scanner. Wordfence and Sucuri make solid products and I've used both, but a scanner is one input. It is not a substitute for understanding where to look.
  • Restoring from backup as a first move. Backups taken after the compromise contain the malware. I'll show you how to verify a backup's clean date before relying on it.
  • Hosting-provider one-click "malware cleanup" buttons. These usually clear file-level signatures only and leave database persistence in place, so reinfection happens within hours.

The cleanup flow at a glance

In the order I work through them on a real site:

  1. Confirm the compromise and take the site offline (or to maintenance mode)
  2. Snapshot the current state for forensics, before anything is changed
  3. Find the entry point in the access and error logs
  4. Clean the file system: core, themes, plugins, uploads, and the weird directories attackers leave behind
  5. Clean the database: wp_options, wp_posts, wp_users, wp_usermeta, scheduled tasks
  6. Clean .htaccess and wp-config.php specifically
  7. Rotate every credential the site touched
  8. Lock down to prevent reinfection
  9. Verify with a clean second-opinion scan

The order matters. Cleaning files but skipping the database is the single most common reason malware reappears within hours. Cleaning everything but leaving the entry-point credential rotated is the second most common.

Set up the cleanup workspace

Most of this article uses shell commands. Set the WordPress root once below and the commands and scripts will pick up your value.

Try it with your own values

The commands work on Linux (Debian, RHEL, Alpine) and macOS. Where BSD find, sed, or stat differs from GNU, I call it out. If you're on a managed host without shell access, every section also has a method that works through SFTP plus phpMyAdmin or Adminer.

Step 1: Confirm the compromise and take the site down

Common signs the site is infected:

  • Google Search Console shows the site flagged as "deceptive" or "social engineering content"
  • Browser SafeBrowsing or similar warns visitors
  • Search results show pharmaceutical, gambling, or replica-watch terms instead of your titles ("Japanese SEO spam")
  • Mysterious <script src="//some-cdn-or-other.com/..."> tags appear in the HTML output
  • New admin users you didn't create
  • WordPress login redirects somewhere unexpected
  • wp-config.php, index.php, or .htaccess modification time is newer than your last deploy
  • Mail-queue backed up with spam your server didn't send (the server did send it, your code did)
  • CPU pinned by suspicious PHP processes
  • Site is suddenly slower than usual

Put the site behind a maintenance page or a Deny from all .htaccess while you work. Cleaning a site that's still serving traffic means the attacker can re-establish persistence behind you, and any visitor who hits an infected page during the cleanup is the next vector.

For Apache, drop this into <VarInputs storageKey:wp_root>/.htaccess:

apache
RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.42$
RewriteCond %{REQUEST_URI} !^/maintenance\.html$
RewriteRule .* /maintenance.html [R=503,L]

ErrorDocument 503 /maintenance.html
Header always set Retry-After "3600"

Replace 203.0.113.42 with your own public IP. Now only you can reach the site.

For Nginx:

nginx
location / {
    set $allow_ip 0;
    if ($remote_addr = "203.0.113.42") { set $allow_ip 1; }
    if ($allow_ip = 0) {
        return 503;
    }
}
error_page 503 /maintenance.html;

Step 2: Snapshot the current state for forensics

Before you delete anything, capture the evidence. This serves three purposes: lets you compare what was there before vs after cleanup, gives you something to file with your insurance / a security firm if needed, and lets you reverse a mistake if you over-clean.

bash
# Files
tar czf /tmp/wp-forensic-$(date +%Y%m%d).tar.gz :wp_root

# Database
mysqldump --single-transaction --routines --events --triggers \
:db_name > /tmp/wp-forensic-db-$(date +%Y%m%d).sql

# Web server access log (last 14 days is usually enough to find the entry point)
sudo tail -n 1000000 /var/log/nginx/access.log > /tmp/wp-access.log
# or for Apache:
sudo tail -n 1000000 /var/log/apache2/access.log > /tmp/wp-access.log

Move these off the server to your local machine. If the attacker still has access they can delete the evidence.

Step 3: Find the entry point in the access logs

If you skip this step, the cleanup is incomplete by definition. The same vulnerability is still open and you'll be cleaning the same site again next week.

The four signatures I look for first:

XML-RPC brute force: a flood of POSTs to /xmlrpc.php. WordPress shipped XML-RPC enabled by default for years, and even sites where the feature is unused still expose the endpoint. The flood pattern hasn't gone away.

bash
grep -E 'POST .*/xmlrpc\.php' /tmp/wp-access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20

wp-login.php brute force: same pattern against the login form.

bash
grep -E 'POST .*/wp-login\.php' /tmp/wp-access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20

Vulnerable plugin probes: requests to specific plugin endpoints known to be exploitable. This is the most common entry point on every site I've cleaned. Plugins with file-upload, SQL-injection, or unauthenticated AJAX vulnerabilities get added to attacker scanning lists within hours of any public disclosure. The exact plugin names change every year (RevSlider was the famous one in 2014-2016; the list keeps growing); the pattern of probing for known weak endpoints does not.

bash
# Adjust this regex to the plugins you actually have installed, plus the
# usual suspects with a long history of file-upload / RCE bugs.
grep -aiE '(revslider|timthumb|wp-mobile-detector|wp-symposium|gravityforms.*upload|fusion-builder|file-manager|filemanager)' /tmp/wp-access.log \
| head -50

Uploaded shell access: requests to PHP files in wp-content/uploads/ (uploads should be media files, never PHP). This is the smoking gun for "they got in via X plugin, dropped a shell here, came back through this URL repeatedly."

bash
grep -aE 'GET|POST .*/wp-content/uploads/.*\.php' /tmp/wp-access.log \
| head -50

Once you find the entry point, write it down. You'll come back to close it in Step 8: Lock down.

For the deep dive on log forensics, see the WordPress access log forensics guide for the timestamp patterns, geolocation triage, and wp-cron poisoning indicators that go beyond what fits here.

Step 4: Clean the file system

This step has three substeps: identify suspicious files, replace known files from clean sources, and verify nothing was missed.

4a. Find recently modified PHP and JavaScript files

Attackers add or modify files. Comparing modification times against your last deploy date narrows the search dramatically. I usually look for files modified in the last 60 days unless the compromise is older.

bash
# Linux + macOS compatible: find PHP files modified in the last 60 days
find :wp_root -type f -name '*.php' -mtime -60 \
-not -path '*/node_modules/*' \
| sort
bash
# Same for JavaScript files (commonly modified to inject ad-fraud loaders, SEO spam, or redirect shims)
find :wp_root -type f -name '*.js' -mtime -60 \
-not -path '*/node_modules/*' \
| sort

If you have a known deploy date (you should, from your git or your hosting panel), use -newer <reference-file> instead of -mtime for a sharper window:

bash
# Make a reference file at the moment of your last clean deploy
touch -t 202401010000 /tmp/last-deploy
find :wp_root -type f -newer /tmp/last-deploy \
\( -name '*.php' -o -name '*.js' -o -name '.htaccess' \) \
| sort

4b. Find PHP files in directories that should never contain PHP

The single most reliable signature for "this is malware." wp-content/uploads/ is for images, PDFs, and media. Any PHP file in there is malicious. Same for wp-includes/ subdirectories that don't ship PHP in the core, and most theme images/ directories.

bash
find :wp_root/wp-content/uploads -type f -name '*.php' 2>/dev/null
bash
find :wp_root/wp-content -type f -name '*.php' -path '*/images/*' 2>/dev/null

Anything that turns up here is a backdoor. The common filenames I see: wp-conf.php, wp-tmp.php, radio.php, class-wp-cache.php, random hex names like a3f9b2.php, or just plain index.php placed in directories that shouldn't have one.

For each one, read the file (cat, less) and confirm it's malicious before deleting. Common indicators:

  • Starts with <?php eval(
  • Uses base64_decode(, gzinflate(, str_rot13(, assert(, or preg_replace('/.*/e', ...) to obfuscate code
  • Reads input from $_GET, $_POST, $_COOKIE, $_REQUEST, or $_SERVER['HTTP_*'] and feeds it to eval, assert, exec, system, shell_exec, passthru, or popen
  • Looks like a single line of unreadable hex/base64 with no comments and no recognizable WordPress code

4c. Scan content for known-malicious patterns

Search for the obfuscation signatures across the entire installation. Important: this finds candidates. It does NOT delete. You inspect each result manually before removal because legitimate code (caching plugins, some optimizer plugins) sometimes uses eval and base64_decode for non-malicious reasons.

Save the script below as wp-scan-suspicious.sh:

bash
#!/usr/bin/env bash
# wp-scan-suspicious.sh
# Scan a WordPress installation for files containing obfuscation patterns
# commonly used by PHP malware. Reports candidates only; does not delete.
# Cross-platform: works on Linux (Debian/RHEL/Alpine) and macOS (BSD tools).

set -e
WP_ROOT="${1:-/var/www/html}"

# Patterns ranked by their malware-correlation strength. Pattern alone is not
# proof; a caching plugin can legitimately use base64_decode. Inspect each hit.
PATTERNS=(
  'eval\(base64_decode'
  'eval\(gzinflate'
  'eval\(str_rot13'
  'eval\(\$_(GET|POST|REQUEST|COOKIE|SERVER)'
  'assert\(\$_(GET|POST|REQUEST|COOKIE)'
  'preg_replace.*/e'
  'system\(\$_(GET|POST|REQUEST|COOKIE)'
  'shell_exec\(\$_(GET|POST|REQUEST|COOKIE)'
  'FilesMan'
  'WSO Web Shell'
  'c99shell'
  'r57shell'
  '@\$_\[?[A-Z_]+\]?[ ]*=[ ]*[\x22'"'"']'
)

echo "Scanning $WP_ROOT for suspicious patterns..."
echo "========================================================"

for p in "${PATTERNS[@]}"; do
  echo
  echo "--- Pattern: $p ---"
  grep -rlE "$p" "$WP_ROOT" \
    --include='*.php' \
    --include='*.phtml' \
    --include='*.phps' \
    --exclude-dir='node_modules' \
    2>/dev/null || echo "  (none)"
done

echo
echo "========================================================"
echo "Done. Review each file before deleting."
echo "Core WordPress files (wp-admin/, wp-includes/, wp-login.php, etc.)"
echo "should be replaced from a fresh download, not edited."

Run it:

bash
chmod +x wp-scan-suspicious.sh
./wp-scan-suspicious.sh :wp_root | tee /tmp/wp-scan.log

Read every hit. For each file ask: was this here before the compromise (check git, check your backup), does the pattern make sense in context (a caching plugin's class-cache.php using base64_decode to store cache keys is benign), and does removing this break the site (test in a staging environment first).

For the deep dive on identifying PHP backdoors specifically (the obfuscation patterns, the eval families, the file-upload shells), see the PHP backdoor identification guide which goes through 30+ real samples I've seen.

4d. Replace core files from a known-clean source

This is the part most cleanup guides hand-wave. Here's how I actually do it.

WordPress core: download the version that matches WP_VERSION (visible in wp-includes/version.php of a clean copy, or in the admin dashboard if accessible). Do NOT take whatever version is on the compromised site as "correct". The attacker may have downgraded WordPress to a vulnerable version.

bash
# Download the latest WordPress, OR a specific version if needed
curl -L -o /tmp/wp.tar.gz https://wordpress.org/latest.tar.gz
# Specific version:
curl -L -o /tmp/wp.tar.gz https://wordpress.org/wordpress-6.7.2.tar.gz
tar xzf /tmp/wp.tar.gz -C /tmp/

Then replace wp-admin/, wp-includes/, and all the root PHP files (index.php, wp-load.php, wp-settings.php, etc.) but keep wp-config.php and wp-content/ for now (you'll handle them next). Use rsync for a precise replacement:

bash
rsync -av --delete /tmp/wordpress/wp-admin/    :wp_root/wp-admin/
rsync -av --delete /tmp/wordpress/wp-includes/ :wp_root/wp-includes/
# Root files (exclude wp-config.php and wp-content)
rsync -av --include='*.php' --include='*.html' --include='*.txt' \
--exclude='wp-config.php' --exclude='wp-content/' --exclude='*' \
/tmp/wordpress/ :wp_root/

The --delete flag removes any extra files that didn't ship with core. That's how you nuke the wp-admin/css/style.php or wp-includes/wp-tmp.php backdoor without having to identify each one.

Plugins from the WordPress.org repo: download a clean copy of each, replace wp-content/plugins/<plugin-name>/ the same way.

bash
# Example: replace WooCommerce with a fresh 9.5.1 copy
curl -L -o /tmp/woo.zip "https://downloads.wordpress.org/plugin/woocommerce.9.5.1.zip"
unzip /tmp/woo.zip -d /tmp/
rsync -av --delete /tmp/woocommerce/ /var/www/html/wp-content/plugins/woocommerce/

Themes from the WordPress.org repo: same approach against wp-content/themes/<theme-name>/.

4e. Handling paid, custom, and orphaned plugins or themes

This is the case nobody else's guide handles. Some of the plugins and themes on the site were not free WordPress.org downloads:

  • A paid commercial plugin or theme (Yoast Premium, ACF Pro, a Themeforest theme): log into the vendor's account, download the current clean version, replace from that.
  • A custom-built plugin or theme with no public source: this is where you need a known-good backup. If your last verified-clean backup is older than the compromise window, restore the plugin/theme from that backup. If you don't have one, you have to read the code line-by-line and remove what doesn't belong. This is slow but doable. The malware will be visually distinct from the developer's code style if the developer wrote anything more than a hello-world stub.
  • An orphaned plugin whose vendor has gone out of business: same as custom. You may also need to switch the site to an alternative plugin entirely; nothing in the compromised plugin's code is trustworthy.
  • A plugin where the vendor's GitHub or homepage is gone: try the Wayback Machine (web.archive.org) for the plugin's download page, or check whether a fork exists on GitHub by searching the plugin's main file header (the Plugin Name: ... comment).

For any plugin or theme you cannot get a clean copy of, assume it is the source of the compromise until proven otherwise. Stop using it on this site, and on every other site you maintain that has it installed.

Step 5: Clean the database (the often-forgotten layer)

A file-system cleanup that doesn't touch the database is the single most common reason malware reappears. WordPress malware regularly writes a copy of itself to the database so that, when the request loads (and WordPress autoloads the wp_options table), the malicious code re-emits itself to fresh PHP files. You can clean the files all day; on the next page load, they're back.

The places I always check, in order of frequency:

5a. The wp_options table

This is the worst offender. WordPress autoloads many rows from this table on every request. Anything stored here with autoload = 'yes' runs.

bash
mysql -u :db_user -p :db_name <<'SQL'
-- Suspiciously large autoloaded options (malware often hides as base64 blobs)
SELECT option_id, option_name, LENGTH(option_value) AS size, autoload
FROM :table_prefixoptions
WHERE autoload IN ('yes','on') AND LENGTH(option_value) > 5000
ORDER BY size DESC
LIMIT 30;

-- Options containing executable PHP-ish or JS-ish patterns
SELECT option_id, option_name, LEFT(option_value, 200) AS preview
FROM :table_prefixoptions
WHERE option_value REGEXP 'eval\\(|base64_decode|gzinflate|<script|document\\.write|String\\.fromCharCode';
SQL

Anything that shows up here gets read carefully. Common malicious option names use names that look plausible but aren't standard WordPress: wp_options_cache, _transient_doing_cron_lock, class-wp-cache, siteurl_backup, or random base64-looking strings.

To delete a confirmed malicious row:

bash
mysql -u :db_user -p :db_name -e "DELETE FROM :table_prefixoptions WHERE option_name = '<the-name-you-confirmed>'"

5b. The wp_posts table for injected <script> and <iframe> content

Attackers append <script src="//evil.example/..."> to post content or post titles to drive ad-fraud or hijack search traffic.

bash
mysql -u :db_user -p :db_name <<'SQL'
SELECT ID, post_title, LEFT(post_content, 200) AS preview, post_status, post_modified
FROM :table_prefixposts
WHERE post_content REGEXP '<script|<iframe|document\\.write|eval\\('
AND post_status IN ('publish','draft','private')
ORDER BY post_modified DESC
LIMIT 50;
SQL

If you find injected scripts, the cleanup is a careful REPLACE(). Back up the relevant rows first:

bash
mysqldump -u :db_user -p :db_name :table_prefixposts > /tmp/posts-backup.sql

Then remove the exact injected string (find the literal <script src="//...">...</script> in the data and use REPLACE to strip it). Don't try to do this with a regex inside MySQL; that's too easy to mangle legitimate content.

5c. Hidden admin users

bash
mysql -u :db_user -p :db_name <<'SQL'
-- List every user with admin capabilities
SELECT u.ID, u.user_login, u.user_email, u.user_registered, m.meta_value AS caps
FROM :table_prefixusers u
JOIN :table_prefixusermeta m ON u.ID = m.user_id
WHERE m.meta_key = ':table_prefixcapabilities'
AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;
SQL

Compare against the users you know. Anyone you don't recognize is a backdoor.

To remove a malicious admin (this also cleans their usermeta and reassigns their content to the user with ID 1):

bash
wp user delete <ID> --reassign=1 --allow-root --path=:wp_root

If WP-CLI isn't available, use the SQL form, but you'll need to manually clean three tables (users, usermeta, and either reassign or delete their posts).

5d. Scheduled tasks (wp_cron)

Malware frequently installs WordPress cron tasks to re-establish persistence: every hour, a hook re-creates the deleted file or re-inserts the deleted option. If you skip this, you fight the cleanup forever.

bash
mysql -u :db_user -p :db_name -e "SELECT option_value FROM :table_prefixoptions WHERE option_name='cron'" | head -c 8000

The output is a PHP-serialized array. Look for hook names that don't match anything WordPress, your plugins, or your themes actually register. Common malicious hook names: wp_resetwp, _wp_cleanup, wp_check_url, random strings. To wipe ALL scheduled tasks and let WordPress re-register the legitimate ones:

bash
wp cron event delete --due-now --all --path=:wp_root --allow-root
# Or the heavy-handed version:
mysql -u :db_user -p :db_name -e "DELETE FROM :table_prefixoptions WHERE option_name='cron'"

For the deep dive on cleaning wp_options and finding the specific patterns malware uses there, see How to clean malware out of the WordPress options table.

Step 6: Clean .htaccess and wp-config.php

These two files deserve their own step because they're rewritten by malware constantly. A clean file-system replace from Step 4 already touched core but didn't touch these two.

.htaccess

Compare your current .htaccess against the WordPress default block. Anything outside the # BEGIN WordPress ... # END WordPress block that you didn't add yourself is suspicious. Common malicious additions:

  • RewriteCond %{HTTP_USER_AGENT} rules that redirect Googlebot to a spam page (so users see the legitimate site but search engines see the spam, which is how "Japanese SEO spam" hides)
  • RewriteCond %{HTTP_REFERER} rules that redirect traffic from search engines to a third-party site
  • AddType application/x-httpd-php .jpg lines that let attackers execute PHP from disguised image files
  • php_value auto_prepend_file lines that load a backdoor before every PHP request

Reset to a clean WordPress default:

apache
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

If you had custom rewrites (HTTPS redirect, hotlink protection, custom 404), add them back from your own notes, not from the compromised file.

wp-config.php

This file is a high-value target because it holds the database credentials. Attackers who write to it can run arbitrary PHP on every WordPress request via auto_prepend-style tricks at the top of the file, or via injected define() lines that change WP_CONTENT_DIR or WP_PLUGIN_DIR to a malicious directory. Specific campaigns that target wp-config.php come and go (the gsyndication.com family became prevalent in the early 2020s and is the one I see most in current cleanups), but the underlying attack shape has been the same since I started cleaning sites.

Compare against a freshly downloaded wp-config-sample.php. Anything in your wp-config.php that isn't recognizable as:

  • The define( 'DB_*', ... ) block
  • The salts (AUTH_KEY, etc.)
  • The $table_prefix line
  • define( 'WP_DEBUG', ... ) and similar tunables
  • if ( ! defined( 'ABSPATH' ) ) define( 'ABSPATH', ... )
  • require_once ABSPATH . 'wp-settings.php';
  • Comments

...is suspicious. The most common pattern I see: a single <?php eval(base64_decode("...")); line inserted right after the opening <?php tag, OR a file_get_contents() call pulling code from a remote URL. Remove the injection. Then rotate the salts immediately. Generate fresh ones from https://api.wordpress.org/secret-key/1.1/salt/.

If your site is seeing the specific 2020s-era gsyndication script-injection pattern, my gsyndication.com malware removal walkthrough covers exactly how that family persists across cleanups and how to stop it.

Step 7: Rotate every credential the site touched

Mandatory. Skipping any one of these is how reinfection happens through a "clean" cleanup.

  • All admin user passwords. Use the WordPress password change guide for the four methods.
  • The database password. Update in wp-config.php and in MySQL (ALTER USER ... IDENTIFIED BY ...).
  • The WordPress salts in wp-config.php (generate fresh from api.wordpress.org/secret-key/1.1/salt/). This invalidates every existing session cookie, so attackers who stole session cookies lose them.
  • The SFTP / SSH passwords or keys the deployment uses.
  • The hosting control panel password.
  • Any API keys stored in wp-config.php or in plugins (Stripe, SendGrid, Cloudflare, S3, Mailgun, Algolia). Treat them as exposed.
  • Any third-party service password that was reachable from the compromised server (database admin tool, monitoring agent, mail-relay user).

Yes, this is a lot. Yes, you have to do all of it. The cost of skipping one is "you'll be cleaning the site again in a week."

Step 8: Lock down to prevent reinfection

The cleanup is the easy part. Preventing the next compromise is the work. The list of controls I install on every site after a cleanup:

  • Force HTTPS everywhere (Let's Encrypt at the edge, HSTS header)
  • Disable XML-RPC entirely if you don't use the mobile app or remote publishing: add_filter( 'xmlrpc_enabled', '__return_false' ); or block /xmlrpc.php at the web-server level
  • Block direct PHP execution in upload directories: drop an .htaccess into wp-content/uploads/ with <FilesMatch "\.(php|phtml|phps)$">Deny from all</FilesMatch> (Apache), or the Nginx equivalent: location ~* /wp-content/uploads/.*\.php$ { deny all; }
  • Limit login attempts at the web-server layer (fail2ban with a wp-login.php jail) or with a small plugin
  • Two-factor authentication for every admin user (the WP-2FA plugin or similar)
  • Auto-update WordPress core, plugins, and themes. The risk of an update breaking the site is lower than the risk of an unpatched plugin getting exploited.
  • Remove unused plugins and themes entirely. An inactive plugin with a vulnerability is still exploitable; its code is reachable if the file is on disk.
  • File integrity monitoring: wp core verify-checksums and wp plugin verify-checksums --all (WP-CLI) on a daily cron, with alerts if anything fails
  • A web application firewall at the edge. Cloudflare's free tier with the WordPress managed rules turned on stops the obvious bots. For paid: Wordfence Premium or Sucuri Firewall.
  • Server-side malware scanning on a schedule. ClamAV with the Linux Malware Detect (maldet) signatures is the free option. Run nightly, email on hit.

For the deep dive on WordPress hardening beyond the cleanup, see WordPress hardening checklist which goes into specific server-level controls, fail2ban configs, and the maldet setup I use on production sites.

Step 9: Verify with a second-opinion scan

Run two scanners you didn't already use during cleanup. The point is not to trust any single tool. Browser-based scanners I use:

  • Google Search Console Security & Manual Actions report (free, authoritative for whether Google currently flags the site)
  • VirusTotal: paste your URL at virustotal.com, 80+ engines scan it
  • Sucuri SiteCheck: free remote scanner at sitecheck.sucuri.net

Server-side, run wp core verify-checksums and wp plugin verify-checksums --all from WP-CLI one more time:

bash
cd :wp_root
wp core verify-checksums --allow-root
wp plugin verify-checksums --all --allow-root

If anything is flagged, go back and clean it. If everything passes and the external scanners are clean, take the site out of maintenance mode and watch the access logs for the next 48 hours. Any repeat attempt to the entry point you identified in Step 3 should now hit a 403 or a closed plugin endpoint.

Common attack vectors at a glance

The recurring patterns in the cleanups I've done, ranked by how often I see them across the sites I clean. The specific plugin names in the first row have changed over the years (TimThumb and RevSlider dominated 2014-2016, the list keeps growing through every CVE cycle); the pattern is the same.

VectorSymptomFix
Vulnerable plugin (RevSlider, TimThumb, and every CVE-of-the-year since)PHP shell in wp-content/uploads/Update or remove plugin, delete shells, rotate DB password
wp-login.php brute forceHidden admin user, no other file changesReset all user passwords, enable 2FA, rate-limit wp-login
XML-RPC brute forceSame as wp-login but no record of failed loginsDisable xmlrpc.php, rate-limit, rotate passwords
Stolen FTP credentialsFiles modified outside any WordPress code pathRotate SFTP / SSH credentials, prefer SSH keys, audit ~/.ssh/authorized_keys
Compromised admin passwordHidden admin user, plugins suddenly installedReset passwords, enable 2FA, check for password reuse on other sites
Server-level compromise (other site on the same hosting account)Files in WordPress modified by a user other than the WordPress userMigrate to isolated hosting; cleanup alone doesn't fix this
wp-config.php injection (various campaigns over the years; the gsyndication family is the current example)Mysterious script tags in front-end HTML; wp-config.php is modifiedSee dedicated walkthrough
Database-only persistenceCleanup keeps reverting; files reappearClean wp_options, posts, cron (Step 5) before recleaning files

Common mistakes I see in cleanups

The bugs that turn a one-day cleanup into a one-month cleanup:

Restoring from a backup taken after the compromise. Your hosting provider's "automatic" backups are dated. If they all started after the compromise, every backup carries the malware. Check the backup contents by mounting and grepping the same patterns as Step 4c. If every available backup is dirty, you do the cleanup from scratch, no shortcut.

Cleaning files but not the database. Single most common reason for reinfection. The malware lives in wp_options, runs on autoload, rewrites the deleted file, and laughs. Always do Step 5.

Trusting the malware scanner as the source of truth. Scanners find known signatures. Custom-built or new malware can sit in plain sight and pass every scan. Your eyes on the access log and the file modification times are the only thing that catches novel patterns.

Putting the site back online before closing the entry point. If you cleaned everything but didn't update the vulnerable plugin, the same attacker visits the same URL and reinstalls everything in under 60 seconds. They're scanning continuously.

Skipping credential rotation. "But nobody else has the password." If the site was compromised, you don't know what was stolen. Rotate everything.

Reinstalling a paid plugin from the compromised site's existing files. Those files are infected. Get the plugin from the vendor's account, not from the server's wp-content/plugins/.

Forgetting wp-config.php and .htaccess. These two files are NEVER in rsync --delete from the core download (you exclude them deliberately to preserve config and rewrite rules), so they survive the file-system cleanup. They're also the most common single hiding place. Always read them line by line.

Cleaning one site on shared hosting without cleaning the others. If the attacker is in via the hosting account, they're in everyone's site. Clean ALL sites in the account at once, or move the recovered site to fresh hosting.

Frequently asked questions

See also

External references: the Wordfence cleanup guide and Sucuri cleanup guide are reasonable second references, both biased toward their own paid scanners. For the standards perspective, the OWASP WordPress Security Cheat Sheet covers the authentication side, and WordPress's own hardening documentation is the official baseline.

If you got hit by a specific malware family and want it covered as its own walkthrough, get in touch. The most-asked compromises end up as their own articles in this WordPress Security series.

TagsWordPressSecurityMalwareHackingBackdoorwp-confightaccessPHPDatabaseForensicsDevOps
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Remove empty, null, false, or empty-string values from a PHP array. Covers array_filter, the '0 gets removed' gotcha, array_values re-indexing, multidimensional cleanup, and a performance comparison.

How to Remove Empty Values from an Array in PHP

Drop empty, null, or false values from a PHP array with array_filter and the right callback. Includes the '0 gets removed' gotcha, the array_values re-index pattern, multidimensional cleanup, and a performance comparison.

Four reliable ways to change a WordPress password: admin dashboard, WP-CLI, direct in the database, or email reset. Includes the WP 6.8+ bcrypt hash format.

How to Change a WordPress Password

Four reliable ways to change a WordPress password: admin dashboard, WP-CLI, directly in the database with the correct phpass or bcrypt hash, and the lost-password email reset.