Skip to content
  • FG-26
  • 14 min read
  • Last checked November 8, 2026

WordPress Hack Keeps Coming Back? How to Find the Backdoor and Stop Reinfection

You cleaned the site and the malware came back. Something survived: a backdoor, hidden admin, cron job, stolen password or unpatched plugin. Find it and prove the site is clean.

Run the health check

You removed the malware, the scanner came back clean, and a few days later the spam pages, the redirect or the strange admin user are back. Some people go through this cycle for months, paying for cleanups that each last a week. The malware is not regenerating by magic. Something from the original attack survived, and it is either reinstalling the payload or letting the attacker back in to do it again.

Attackers plan for cleanup. Alongside the visible damage they leave several quiet ways back, called persistence mechanisms, in places that scanners and quick cleanups miss. This guide walks through every one worth checking on a WordPress site, then gives you a step-by-step method to prove the site is clean and keep it that way. The first cleanup routine is in FG-05; this guide assumes you have done it at least once.

What is happening

Reinfection comes from one of two things: the attacker still has a way in, or something on the server reinstalls the malware by itself. Many sites have both.

Mechanism Where it hides Why cleanups miss it
Unpatched vulnerability An outdated or abandoned plugin or theme The malware is removed but the hole is not
Stolen credentials SFTP, hosting panel, database, mailbox, WordPress application passwords Only the WordPress admin password was changed
Hidden admin user The users table, hidden from the Users screen by code The admin screen shows only “real” users
Hidden or must-use plugin wp-content/mu-plugins, fake plugin folders, drop-ins Not shown, or filtered out, in the Plugins screen
Scheduled reinstaller WP-Cron events, the server crontab, a background process Runs hours or days after cleanup
Loader outside WordPress wp-config.php, root index.php, .user.ini, php.ini, .htaccess Scanners focus on plugin and theme folders
Payload in the database Options, widgets, post content File scans do not read the database
Neighbor site Another site in the same hosting account Only the site that showed symptoms was cleaned

Before you start

  • Confirm it really is back. Purge every cache (caching plugin, server cache, CDN) and test again with a live fetch. A CDN or page cache still serving an infected copy looks exactly like reinfection.
  • Record the reinfection time. The modified time of the reinfected file or the moment the symptom returned is your best lead; you will match it against logs.
  • Access: SSH, the hosting panel, the database, and your DNS and CDN accounts. Take a fresh off-server copy of files and database before you start.
  • Where commands run: everything below runs in an SSH terminal on the server, from the site folder that contains wp-config.php, unless a step says otherwise. Some hosts call WP-CLI wp-cli. Run wp db prefix once and, in every SQL query below, replace wp_ with the prefix it prints. Words in CAPITALS are placeholders explained under each command.

Step 1: Close the way in

The vulnerable plugin or theme

The most common reason for reinfection is that the hole that let the attacker in is still open. Automated attack tools rescan the web constantly, so an unpatched site gets reinfected even if the original attacker has moved on.

wp plugin list --fields=name,status,version,update_version
wp theme list --fields=name,status,version,update_version

Type both as shown. --fields chooses the columns: the slug, whether it is active, the installed version and the newest available version. A value in update_version means an update is waiting. Check every plugin and theme, active or not (inactive code can still be attacked directly), against a public vulnerability database such as WPScan, Wordfence Intelligence or Patchstack. Remove anything with a known unpatched vulnerability, anything closed on WordPress.org, anything not updated in years, and every nulled (pirated) premium plugin, which often ships with a backdoor built in.

Every credential, not just WordPress

FG-05 covers rotating hosting, SFTP, database and email passwords and shuffling salts. Two WordPress-specific items are often missed:

wp user application-password list USER_ID
wp user session destroy --all

Replace USER_ID with the ID of each administrator (from wp user list --role=administrator). Application passwords let programs sign in to the REST API without the normal password or two-factor code, and changing the main password does not revoke them. The first command lists them with their name, creation date and last use; any you did not create should be deleted with wp user application-password delete USER_ID UUID, replacing UUID with the value in its uuid column. The second command signs every user out of every device by deleting their login sessions. Good output for the second ends with a Success line.

SSH keys and SFTP accounts

ls -la ~/.ssh/ 2>/dev/null
cat ~/.ssh/authorized_keys 2>/dev/null

Type both as shown. The first lists the hidden .ssh folder in your home folder, the second prints every public key allowed to log in as this user, one per line, with a label at the end. 2>/dev/null hides the error if the file does not exist. Remove any key you cannot match to a person or computer you trust, and check your hosting panel’s SSH key and FTP account screens for extra entries. If your FTP client stores passwords, and the computer it runs on has had malware, assume those passwords were stolen; password-stealing malware on a designer’s computer is a well-known route to repeat infections across every client site they manage.

Step 2: Find hidden administrators

Malicious code can hook into WordPress’s user queries and remove its own account from the Users screen and the user count. WP-CLI loads plugins too, so it can be fooled the same way. Ask the database directly:

wp db query "SELECT u.ID, u.user_login, u.user_email, u.user_registered FROM wp_users u JOIN wp_usermeta m ON m.user_id = u.ID WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%';"

Replace both wp_ prefixes in the table names and the one in 'wp_capabilities' with your prefix. The query joins the users table to the user meta table and lists every account whose stored role includes administrator, with its login, email and registration date. Compare the result with wp user list --role=administrator. If the SQL shows an account that WP-CLI and the Users screen do not, code is hiding it: that is both a rogue admin and proof that a hiding plugin or snippet is active. Delete the account (FG-05 shows how), then find the code that hid it, starting with Step 3.

Step 3: Check plugins you cannot see

Folders versus the plugin list

A malicious plugin can remove itself from the plugin list with a filter. Compare what is on disk with what WordPress reports:

ls -1 wp-content/plugins
wp plugin list --field=name --skip-plugins --skip-themes

Type both as shown. The first lists every folder and file in the plugins directory, one per line. The second lists the plugins WordPress knows about; --field=name prints only slugs, and --skip-plugins --skip-themes stops regular plugins and the theme from loading, so they cannot filter the list (a must-use plugin still can, which is why the folder comparison matters). Any folder in the first list but not the second, other than a lone index.php, deserves a look. So does a plugin with a generic name (such as “WP Core Helper” or “Security Tools”) that you never installed.

Must-use plugins and drop-ins

Must-use plugins in wp-content/mu-plugins load automatically on every request, cannot be deactivated from the dashboard, and still load when you run WP-CLI with --skip-plugins. Drop-ins are specially named files directly in wp-content that replace parts of WordPress.

ls -la wp-content/mu-plugins/ 2>/dev/null
wp plugin list --status=must-use
wp plugin list --status=dropin

Type them as shown. The first lists everything in the must-use folder, including hidden files starting with a dot. The second and third show what WordPress recognizes as must-use plugins and drop-ins. Legitimate examples come from your host (management or caching helpers) and from plugins you use. Recognized drop-in names include advanced-cache.php and object-cache.php (caching plugins), db.php (database plugins such as Query Monitor), db-error.php, maintenance.php, php-error.php, fatal-error-handler.php and, on multisite, sunrise.php and the blog-*.php pages. Open each file you cannot attribute to something you installed. A drop-in with no caching or database plugin to explain it is a red flag.

Step 4: Check scheduled tasks and running processes

wp cron event list --fields=hook,next_run_relative,recurrence
crontab -l

Type both as shown. The first lists every WP-Cron event with when it runs next and how often it repeats. WordPress core hooks start with wp_ (such as wp_version_check); plugins usually prefix hooks with their own name. For a hook you cannot place, find what registers it with grep -rn "HOOK_NAME" wp-content, replacing HOOK_NAME with the name from the list; the file that matches is your suspect. Remove the event with wp cron event delete HOOK_NAME only after removing that code, or it will be rescheduled. The second command prints the server crontab for your user. Each line is a schedule (five time fields) and a command. Lines that download files with curl or wget, or run PHP files from odd places, are malicious; your hosting panel’s cron screen shows the same list.

Some malware runs as a background process that rewrites files seconds after you delete them:

ps -u "$(whoami)" -o pid,etime,args

Type it as shown. It lists processes owned by your user with their process ID, how long they have been running and their full command line. Short-lived PHP processes serving web requests are normal. A PHP or Perl process that has been running for hours or days, especially one started from /tmp or with an unreadable command name, is not. Note its details for your host, then stop it with kill PID, replacing PID with the number in the first column. On shared hosting you may not see web processes at all; ask your host.

Step 5: Check loaders outside WordPress

wp-config.php and index.php

grep -nE "include|require|eval|base64|@ini_set" wp-config.php index.php

Type it as shown. -n prints line numbers and -E enables the pattern. In a standard install, wp-config.php should match only on its final require_once line loading wp-settings.php, plus anything your host or a plugin documented (such as a WAF loader). The root index.php should match only on the line requiring wp-blog-header.php. Any other include pointing to a file in uploads, a hidden file, or a path with odd characters is a loader: note the file it loads, remove the line, and remove the file.

auto_prepend_file

PHP’s auto_prepend_file setting runs a chosen file before every PHP script on the site, which makes it a favorite hiding place. It can be set in a .user.ini or php.ini file in the site folder or any parent folder, or in .htaccess on servers running PHP as an Apache module.

find ~ -maxdepth 4 \( -name ".user.ini" -o -name "php.ini" -o -name ".htaccess" \) -exec grep -Hn "auto_prepend_file\|auto_append_file" {} \; 2>/dev/null

Type it as shown. It searches your home folder, four levels deep, for the three kinds of file, and prints each line that sets auto_prepend_file or its twin auto_append_file, with the file name and line number (-H and -n). A known legitimate match is Wordfence’s firewall, which prepends a file called wordfence-waf.php in the site root. Anything else, especially a path into uploads or a file with a random name, is malicious. After removing it, allow a few minutes: PHP caches .user.ini settings, by default for 300 seconds.

Modified core files

Run wp core verify-checksums --include-root. Anything listed as modified, or as a file that should not exist inside wp-admin or wp-includes, means core was altered; replace core entirely with a fresh download rather than editing files. --include-root also flags unknown files in the site root, a common place for loaders.

Step 6: Check the database for stored payloads

Code stored in the database is harmless on its own until something executes it, which is why database payloads usually pair with a small loader file. Clean both. Search for the usual encodings:

wp db search "eval\(|base64_decode|gzinflate|str_rot13|fromCharCode|atob\(" --regex --all-tables --stats

Type it as shown. --regex treats the search as a regular expression (| means “or”), --all-tables includes plugin tables, and --stats summarizes matches per table. PHP payloads usually sit in an option with a bland or random name; JavaScript payloads in widgets, posts and theme settings. FG-23 covers reading and safely removing JavaScript injections. For a malicious option, delete it with wp option delete OPTION_NAME, replacing OPTION_NAME with the name shown, after finding and removing the code that reads it.

Step 7: Check every other site in the account

If several sites share one hosting account, they usually share one system user, and malware in any of them can rewrite the others. Cleaning only the site with symptoms is one of the most common reasons infections return. FG-25 includes a loop that runs checksum checks and an uploads scan across every install in an account; run it, and remove any old or test install you do not need.

Advanced: catching the reinfection in the act

If you cannot find the mechanism, make the next reinfection tell you where it came from. Match the reinfected file’s change time to your logs:

stat -c "%n | changed %z" FILE_PATH
grep "DD/MMM/YYYY:HH:MM" ACCESS_LOG

Replace FILE_PATH with the reinfected file. %z is the change time, which records when the file was actually written. Then replace DD/MMM/YYYY:HH:MM with that time in the access log’s format: two-digit day, three-letter month as in Nov, four-digit year, then hour and minute and ACCESS_LOG with your log path. Requests in that minute show what triggered the write. A POST to an unknown file is a backdoor; a request to wp-cron.php points to a cron event; no web request at all points to a server cron job, a background process, or an SFTP login (check your host’s FTP log for that time).

On a VPS you can watch writes live with the inotifywait tool from the inotify-tools package:

inotifywait -m -r -e create,modify,moved_to --timefmt "%F %T" --format "%T %w%f %e" wp-content

-m keeps watching instead of exiting after one event, -r watches every subfolder, -e picks the events (new files, changed files, files moved in), and --format prints the time, path and event. Leave it running in a terminal multiplexer such as screen. Normal output shows cache files and uploads; a PHP file appearing in uploads or a theme file changing at 3am is your culprit caught with a timestamp.

File integrity monitoring

Once clean, record a fingerprint of every code file so any future change is obvious:

find . -type f \( -name "*.php" -o -name "*.js" -o -name ".htaccess" -o -name ".user.ini" \) -not -path "./wp-content/cache/*" -exec sha256sum {} + > ~/baseline.sha256
sha256sum -c --quiet ~/baseline.sha256

Run the first line from the site folder once, right after cleanup and after every legitimate update. It hashes every PHP, JavaScript, .htaccess and .user.ini file and saves the list in your home folder, outside the website, where visitors cannot download it. The second line, run later from the same folder, rechecks every file: --quiet prints only files that changed or disappeared, so no output means nothing changed. It cannot spot new files; pair it with find . -name "*.php" -newer ~/baseline.sha256, which lists PHP files created or changed since the baseline was written. Add both to a daily cron job that emails you the output, or use a security plugin or host feature with file change alerts.

A step-by-step method to prove the site is clean

  1. Purge all caches and confirm the symptom is real with a live, logged-out, mobile test.
  2. Remove every unpatched, abandoned or nulled plugin and theme; update the rest.
  3. Replace core, plugins and themes with fresh copies from their official sources. wp core verify-checksums --include-root and wp plugin verify-checksums --all pass.
  4. No PHP files in uploads; no unexplained must-use plugins, drop-ins or plugin folders.
  5. The SQL administrator list matches the people you expect; no unknown application passwords; all sessions destroyed.
  6. No unknown WP-Cron hooks, crontab lines or long-running processes.
  7. No unexplained includes in wp-config.php or index.php, and no unknown auto_prepend_file.
  8. Database searches for encoded payloads return only matches you can explain.
  9. Every other site in the account passes the same checks, or has been removed.
  10. Every credential rotated from a clean device: hosting, SFTP, SSH keys, database, mailboxes, WordPress users, salts, API keys.
  11. Baseline hashes recorded and checked daily for at least a month, with access logs reviewed for POST requests to unfamiliar files.

If every box is checked and the malware still returns, the foothold is outside your reach: a server-level compromise, or shared credentials elsewhere.

When to contact your host

Open a ticket once you have ruled out everything you can see, or straight away if you find files owned by another user, processes you cannot stop, or loaders in parent folders above your site. Send:

  • The reinfection timestamps in UTC and the file paths or settings that changed each time.
  • The access log lines from the minute of each reinfection.
  • Anything suspicious from the crontab, process list, .user.ini files or SSH keys.
  • The checklist above, showing what you have verified.

Ask them to check FTP and panel login logs for those times, server-level cron and processes, and whether other accounts on the server are affected.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close