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:
- Confirm the compromise and take the site offline (or to maintenance mode)
- Snapshot the current state for forensics, before anything is changed
- Find the entry point in the access and error logs
- Clean the file system: core, themes, plugins, uploads, and the weird directories attackers leave behind
- Clean the database:
wp_options,wp_posts,wp_users,wp_usermeta, scheduled tasks - Clean
.htaccessandwp-config.phpspecifically - Rotate every credential the site touched
- Lock down to prevent reinfection
- 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.
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.htaccessmodification 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:
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:
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.
# 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.logMove 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.
grep -E 'POST .*/xmlrpc\.php' /tmp/wp-access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20wp-login.php brute force: same pattern against the login form.
grep -E 'POST .*/wp-login\.php' /tmp/wp-access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20Vulnerable 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.
# 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 -50Uploaded 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."
grep -aE 'GET|POST .*/wp-content/uploads/.*\.php' /tmp/wp-access.log \
| head -50Once 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.
# 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# 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/*' \
| sortIf 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:
# 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' \) \
| sort4b. 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.
find :wp_root/wp-content/uploads -type f -name '*.php' 2>/dev/nullfind :wp_root/wp-content -type f -name '*.php' -path '*/images/*' 2>/dev/nullAnything 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(, orpreg_replace('/.*/e', ...)to obfuscate code - Reads input from
$_GET,$_POST,$_COOKIE,$_REQUEST, or$_SERVER['HTTP_*']and feeds it toeval,assert,exec,system,shell_exec,passthru, orpopen - 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:
#!/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:
chmod +x wp-scan-suspicious.sh
./wp-scan-suspicious.sh :wp_root | tee /tmp/wp-scan.logRead 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.
# 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:
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.
# 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.
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';
SQLAnything 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:
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.
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;
SQLIf you find injected scripts, the cleanup is a careful REPLACE(). Back up the relevant rows first:
mysqldump -u :db_user -p :db_name :table_prefixposts > /tmp/posts-backup.sqlThen 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
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;
SQLCompare 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):
wp user delete <ID> --reassign=1 --allow-root --path=:wp_rootIf 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.
mysql -u :db_user -p :db_name -e "SELECT option_value FROM :table_prefixoptions WHERE option_name='cron'" | head -c 8000The 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:
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 siteAddType application/x-httpd-php .jpglines that let attackers execute PHP from disguised image filesphp_value auto_prepend_filelines that load a backdoor before every PHP request
Reset to a clean WordPress default:
# 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 WordPressIf 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_prefixline define( 'WP_DEBUG', ... )and similar tunablesif ( ! 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.phpand in MySQL (ALTER USER ... IDENTIFIED BY ...). - The WordPress salts in
wp-config.php(generate fresh fromapi.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.phpor 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.phpat the web-server level - Block direct PHP execution in upload directories: drop an
.htaccessintowp-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-checksumsandwp 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:
cd :wp_root
wp core verify-checksums --allow-root
wp plugin verify-checksums --all --allow-rootIf 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.
| Vector | Symptom | Fix |
|---|---|---|
| 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 force | Hidden admin user, no other file changes | Reset all user passwords, enable 2FA, rate-limit wp-login |
| XML-RPC brute force | Same as wp-login but no record of failed logins | Disable xmlrpc.php, rate-limit, rotate passwords |
| Stolen FTP credentials | Files modified outside any WordPress code path | Rotate SFTP / SSH credentials, prefer SSH keys, audit ~/.ssh/authorized_keys |
| Compromised admin password | Hidden admin user, plugins suddenly installed | Reset 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 user | Migrate 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 modified | See dedicated walkthrough |
| Database-only persistence | Cleanup keeps reverting; files reappear | Clean 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
- How to Remove gsyndication.com Malware from WordPress: the dedicated walkthrough for one specific modern
wp-config.phpinjection family that became prevalent in the early 2020s - How to Change a WordPress Password: the four reliable methods (admin dashboard, WP-CLI, direct database, lost-password email), needed for Step 7 of this guide
- How to Reset a Forgotten MySQL Root Password: for the database-layer credential rotation when you don't have the current root password
- MySQL Cheat Sheet: the full reference for the SQL patterns in Step 5
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.





