A WordPress security plugin runs inside WordPress. Anything else that runs inside WordPress can disable it, including the malware the plugin is supposed to catch. The cleanups I get called in on consistently follow the same script: visitors notice strange behavior, the site owner opens Wordfence, finds nothing, and a few hours later realizes Wordfence stopped logging anything four days ago, exactly when the compromise started. The plugin wasn't blind. It was off.
This article covers the four ways I've seen attackers silently disable Wordfence, Sucuri Security, Jetpack, WP Activity Log, and similar plugins, the detection signals that flag each one, and the only layer of monitoring that actually survives this class of attack: server-side, completely outside WordPress.
I first wrote this in 2019 after a string of cleanups where the Balada Injector campaign and related malware families were explicitly targeting Wordfence's active_plugins row in wp_options. The mechanisms have stayed largely the same since; what's changed is that more attackers now use them as standard tradecraft rather than as a clever trick. The detection logic works on WordPress 4.x through the current 6.9 line.
The four mechanisms
Each of these gets the same end result, your security plugin stops doing its job, but they leave different fingerprints. Knowing which one was used tells you what the attacker had access to and what else they might have touched.
Mechanism 1: Modifying the active_plugins option
WordPress decides which plugins to load on every request by reading a single wp_options row called active_plugins. The value is a serialized PHP array of plugin paths. Remove an entry from the array and the plugin doesn't load. The plugin's files stay on disk untouched; nothing in the admin Plugins page shows red; the plugin just stops running.
-- See exactly which plugins WordPress is loading right now
SELECT option_value FROM wp_options WHERE option_name = 'active_plugins';A clean output for a site with Wordfence and a contact form plugin looks like:
a:2:{i:0;s:24:"wordfence/wordfence.php";i:1;s:33:"contact-form-7/wp-contact-form-7.php";}
If Wordfence is installed (its folder is in wp-content/plugins/) but missing from the serialized array, somebody removed it. WordPress shows the plugin as "Inactive" in the admin UI; alerts stop because the hooks were never registered for this page load.
Detection at the WP-CLI level:
# What WordPress thinks is active
wp plugin list --status=active --path=/path/to/wordpress --allow-root
# What's actually installed on disk
ls -d /path/to/wordpress/wp-content/plugins/*/If a plugin's directory exists but it's not in the active list, either you deactivated it manually or someone else did. For a security plugin specifically, "someone else did" is the malicious case.
Mechanism 2: Deleting (or renaming) the plugin folder
The variant for attackers who don't want to bother with PHP serialization: just delete the plugin's directory from wp-content/plugins/. WordPress detects the missing files on the next request, removes the entry from active_plugins, and silently continues without the plugin. The error log may have one line ("Plugin file does not exist") but unless you're tailing the log in real time, you'll never see it.
The rename variant is even cleaner: mv wp-content/plugins/wordfence wp-content/plugins/.wordfence-bak. The folder is hidden, the plugin can't load, and a naive ls doesn't show it.
Detection:
# Compare what should be there to what is there
EXPECTED="wordfence sucuri-scanner jetpack wp-security-audit-log wp-2fa limit-login-attempts-reloaded"
for p in $EXPECTED; do
if [ -d "/path/to/wordpress/wp-content/plugins/$p" ]; then
echo " PRESENT: $p"
else
echo " MISSING: $p"
fi
done
# Also check for hidden / renamed copies (dot-prefix or unusual suffix)
ls -la /path/to/wordpress/wp-content/plugins/ | grep -E "^d.*\.|bak|old|tmp|_disabled"If a security plugin you installed is missing entirely from the plugins directory, an attacker deleted it. If a hidden directory exists that looks like a renamed copy, an attacker renamed it.
Mechanism 3: Permissions or ownership swap
The subtle variant: the plugin files are still there, the plugin is still in active_plugins, but the file permissions or ownership were changed so the web server can't read them anymore. WordPress tries to include the plugin's main file, gets a permission-denied response, logs it once, and moves on. The plugin appears active everywhere except in actual behavior.
# Check permissions and ownership on each plugin's main file
for p in /path/to/wordpress/wp-content/plugins/*/; do
name=$(basename "$p")
main_file="$p$name.php"
if [ -f "$main_file" ]; then
perms=$(stat -c '%a %U:%G' "$main_file" 2>/dev/null || stat -f '%Lp %Su:%Sg' "$main_file")
echo " $perms $main_file"
fi
doneThe expected pattern: every file is 644 www-data:www-data (or whatever your web user is), every directory 755. Anything that's 600, owned by root:root, or owned by a different user than the rest of the install, is suspicious.
Mechanism 4: A drop-in or mu-plugin that disables hooks at runtime
The most sophisticated variant: the plugin loads normally, registers all its hooks, but a mu-plugin or drop-in loaded earlier removes those hooks before they can fire. From WordPress's perspective, Wordfence is active, has registered everything, and looks healthy. In practice, every hook it cares about has been silently unregistered.
// What an attacker's mu-plugin contains (simplified)
add_action('plugins_loaded', function() {
remove_action('init', 'wordfence::initProtection');
remove_action('wp_loaded', 'wordfence::initLogger');
// ... etc, every hook the security plugin depends on
}, 1);This is hardest to detect because nothing about Wordfence itself looks wrong. The detection signal is at a higher level: alerts and scans stop happening despite the plugin appearing active.
Find suspicious mu-plugins and drop-ins:
ls -la /path/to/wordpress/wp-content/mu-plugins/ 2>/dev/null
ls -la /path/to/wordpress/wp-content/{advanced-cache,object-cache,db,sunrise}.php 2>/dev/null
# And grep for explicit hook-removal targeting security plugins
grep -rnE "remove_action.*wordfence|remove_filter.*wordfence|remove_action.*sucuri|remove_filter.*sucuri" \
/path/to/wordpress/wp-content/If a file in mu-plugins/ exists at all and you didn't put it there, read it. The mu-plugins/ feature is rarely used legitimately; on most sites, the directory should be empty or absent.
A complete detection script
Save as wp-security-plugin-check.sh, chmod +x, run from inside the WordPress directory:
#!/usr/bin/env bash
# wp-security-plugin-check.sh, detect whether a WordPress security plugin has been
# silently disabled. Reports the four known mechanisms.
# Source: https://techearl.com/wordpress-security-plugin-silently-disabled
# Site: https://techearl.com/
#
# Usage: ./wp-security-plugin-check.sh /path/to/wordpress
set -e
WP_ROOT="${1:-$PWD}"
echo "========================================="
echo " Security plugin disablement check"
echo " WP root: $WP_ROOT"
echo "========================================="
# 1. What WordPress thinks is active vs. what's installed on disk
echo
echo "--- 1. Active plugins (from wp_options.active_plugins) ---"
wp plugin list --status=active --format=table --fields=name,version,status \
--path="$WP_ROOT" --allow-root 2>/dev/null
echo
echo "--- 2. Inactive plugins (installed on disk but not loading) ---"
wp plugin list --status=inactive --format=table --fields=name,version,status \
--path="$WP_ROOT" --allow-root 2>/dev/null
echo
echo "--- 3. Known security plugins, present on disk? ---"
PLUGINS_DIR="$WP_ROOT/wp-content/plugins"
KNOWN="wordfence sucuri-scanner jetpack wp-security-audit-log wp-2fa limit-login-attempts-reloaded wps-hide-login better-wp-security"
for p in $KNOWN; do
if [ -d "$PLUGINS_DIR/$p" ]; then
echo " PRESENT: $p"
fi
done
echo
echo "--- 4. Hidden / renamed plugin directories ---"
ls -la "$PLUGINS_DIR" | grep -E "^d.*\.|-bak|-old|-tmp|_disabled|\.disabled$" \
|| echo " (no hidden / renamed plugin directories)"
echo
echo "--- 5. Permission / ownership anomalies on plugin main files ---"
WEB_USER=$(stat -c '%U' "$WP_ROOT/wp-config.php" 2>/dev/null || stat -f '%Su' "$WP_ROOT/wp-config.php")
for d in "$PLUGINS_DIR"/*/; do
name=$(basename "$d")
main="$d$name.php"
if [ -f "$main" ]; then
owner=$(stat -c '%U' "$main" 2>/dev/null || stat -f '%Su' "$main")
perms=$(stat -c '%a' "$main" 2>/dev/null || stat -f '%Lp' "$main")
if [ "$owner" != "$WEB_USER" ] || ! { [ "$perms" = "644" ] || [ "$perms" = "664" ]; }; then
echo " ANOMALY: $main perms=$perms owner=$owner (expected 644 $WEB_USER)"
fi
fi
done
echo
echo "--- 6. mu-plugins (anything here is suspicious if you didn't put it there) ---"
ls -la "$WP_ROOT/wp-content/mu-plugins/" 2>/dev/null || echo " (mu-plugins directory absent, normal)"
echo
echo "--- 7. Drop-in files ---"
for f in advanced-cache.php object-cache.php db.php sunrise.php; do
if [ -f "$WP_ROOT/wp-content/$f" ]; then
echo " PRESENT: wp-content/$f"
fi
done
echo
echo "--- 8. Hook-removal patterns targeting security plugins ---"
grep -rnlE "remove_action.*['\"]?(wordfence|sucuri|jetpack|wp_security_audit_log)|remove_filter.*['\"]?(wordfence|sucuri|jetpack)" \
"$WP_ROOT/wp-content/" --include="*.php" 2>/dev/null \
| grep -v "/plugins/wordfence/\|/plugins/sucuri-scanner/\|/plugins/jetpack/" \
| head -20
echo
echo "========================================="
echo " If any check turned up an anomaly,"
echo " treat the site as compromised and run"
echo " the full malware-removal playbook."
echo "========================================="Why the fix has to be outside WordPress
Every fix that lives inside WordPress can be disabled by anything that runs inside WordPress. A second security plugin to monitor the first one has the same problem. A scheduled wp cron event to alert you when Wordfence goes silent has the same problem. Any layer above the operating system can be reached and turned off by malware that has the access required to plant itself there.
There is a related and even more fundamental version of this problem worth naming directly. The pattern is called above-doc-root persistence, and in many of the cleanups I work on, the malware itself isn't even inside WordPress. It is sitting one directory up, in the hosting user's home, running as a process the WordPress user owns.
Every popular WordPress security plugin has the same blind spot here: Wordfence, Sucuri Security, iThemes Security Pro (now Solid Security), Patchstack, MalCare, Jetpack Scan, WP Activity Log, and every hosting-provider one-click cleanup. They all operate as PHP running inside the WordPress install, scanning files inside /wp-content/ and rows inside the WordPress database. They cannot enumerate system processes, read ~/.bashrc, inspect ~/.config/, or reach /etc/cron.d/. The plugin reports the site as clean because every file the plugin can see is clean; the malware is in the part of the disk the plugin is not allowed to read. You can keep telling Wordfence to scan and Wordfence will keep saying everything is fine, while the actual malware is somewhere Wordfence was never designed to look.
The cleanup case this turns into a real practical problem is when the person doing the cleanup only has WordPress admin and SFTP access, which is the standard arrangement for freelance cleanups, agency cleanups, and most paid services. SFTP is typically chrooted to public_html/ or to the WordPress install, so the cleaner literally cannot see the file system above the doc root, let alone the process list. The cleanup is bounded by the access level the host gave the cleaner. The deep dive on this attack class, the affected products list, the cleaner-access problem, and the named modern campaigns (Balada Injector, Sign1, gsyndication, Sysrv-hello, WP-VCD) is in section 11 of the persistence article and the gsyndication walkthrough.
The fix is monitoring at a layer the attacker can't disable from inside WordPress: the operating system.
Server-side file integrity monitoring
Set up a periodic check that runs as a system service (cron + a script, or AIDE, or Tripwire, or maldet), reads files as root, and emails an alert when something it tracks changes unexpectedly. The check runs outside the web user's privileges, the alert delivery doesn't depend on wp_mail, and nothing about WordPress can touch it.
The minimum-viable version is a daily cron that:
- Computes SHA256 hashes of every file in
wp-content/plugins/wordfence/(or your security plugin of choice). - Compares them against a stored baseline of expected hashes.
- Compares the contents of
wp_options.active_pluginsagainst a stored expected value. - Mails the result to an off-server address.
Skeleton script (/usr/local/sbin/wp-security-monitor.sh):
#!/usr/bin/env bash
# wp-security-plugin-monitor.sh, run as root from cron. Alerts if a tracked
# WordPress security plugin has been disabled, renamed, deleted, or modified.
# Source: https://techearl.com/wordpress-security-plugin-silently-disabled
# Site: https://techearl.com/
set -e
WP_ROOT="/var/www/wordpress"
WEB_USER="www-data"
ALERT_EMAIL="alerts@your-monitoring-account.com"
BASELINE_DIR="/var/lib/wp-security-monitor"
mkdir -p "$BASELINE_DIR"
# 1. Current state
sudo -u "$WEB_USER" wp plugin list --status=active --format=csv --fields=name \
--path="$WP_ROOT" --allow-root > /tmp/active-now.txt
cd "$WP_ROOT/wp-content/plugins/wordfence" 2>/dev/null && \
find . -type f -name '*.php' -exec sha256sum {} \; | sort > /tmp/hashes-now.txt
# 2. Compare to baseline (first run builds the baseline)
if [ ! -f "$BASELINE_DIR/active.txt" ]; then
cp /tmp/active-now.txt "$BASELINE_DIR/active.txt"
cp /tmp/hashes-now.txt "$BASELINE_DIR/hashes.txt"
echo "Initial baseline created." | mail -s "WP security monitor: baseline" "$ALERT_EMAIL"
exit 0
fi
ACTIVE_DIFF=$(diff "$BASELINE_DIR/active.txt" /tmp/active-now.txt || true)
HASH_DIFF=$(diff "$BASELINE_DIR/hashes.txt" /tmp/hashes-now.txt || true)
if [ -n "$ACTIVE_DIFF" ] || [ -n "$HASH_DIFF" ]; then
{
echo "WordPress security-plugin anomaly detected at $(date)."
echo
[ -n "$ACTIVE_DIFF" ] && { echo "--- active_plugins diff ---"; echo "$ACTIVE_DIFF"; }
[ -n "$HASH_DIFF" ] && { echo; echo "--- plugin file hash diff ---"; echo "$HASH_DIFF"; }
} | mail -s "ALERT: WP security plugin changed on $(hostname)" "$ALERT_EMAIL"
fiCrontab entry:
*/15 * * * * /usr/local/sbin/wp-security-monitor.sh > /var/log/wp-security-monitor.log 2>&1
The alert lands in an inbox the attacker can't disable from inside WordPress. If the entire WordPress site goes offline, the cron still runs, the next run sees the empty active-plugins list, sends the alert. The only way to suppress the alert is to compromise the OS, which is a different (and much harder) class of attack.
Higher-end alternatives
For multi-site or higher-criticality deployments:
- AIDE (Advanced Intrusion Detection Environment) is the canonical Linux FIM. Configure it to watch
wp-content/plugins/<security-plugin>/,wp-config.php,.htaccess, andwp-content/mu-plugins/. Daily integrity check, email on any change. - Linux Malware Detect (
maldet) bundles ClamAV signatures specifically tuned for web malware. Runs as a daemon, scans/var/www/on a schedule, alerts on detection. - OSSEC or Wazuh does both FIM and log analysis with central alerting. Heavier to set up but covers more attack surface.
- Tripwire is the commercial enterprise version of the same idea.
All four have one thing in common: they run as a different OS user (root, or a dedicated monitoring user) and store their state outside the WordPress directory. Whatever the attacker does to WordPress can't touch them.
Common mistakes
The patterns that produce false sense of security:
Setting up Wordfence and assuming it's running. Open the Wordfence dashboard once a week and check that the "Last scan" timestamp is recent. If it hasn't run in days, something disabled it. The same applies to Sucuri, WP Activity Log, and any other in-WordPress security tool.
Trusting plugin-driven email alerts. Wordfence and Sucuri can email you when something goes wrong, but the email is sent via wp_mail, which is wp-content/plugins/wordfence-dependent. If the plugin is disabled, the alert that would have told you it was disabled doesn't go out. Server-side cron + mail (the OS utility) is the only alert channel that survives a WordPress compromise.
Using a second WordPress plugin to monitor the first. Both plugins live in the same wp_options.active_plugins row. An attacker who can disable one can disable both with the same SQL UPDATE. The "two redundant security plugins" architecture doesn't actually add redundancy against this specific class of attack.
Only doing file integrity monitoring on the security plugin folder. The attacker who can modify Wordfence's files can also modify wp-config.php, your theme, your active plugins, .htaccess. Monitor the broader set: wp-config.php, .htaccess, wp-content/plugins/* directory listings, wp-content/themes/* directory listings, and wp-content/mu-plugins/.
Reinstalling the disabled plugin without finding the persistence. If Wordfence got disabled and you just reinstall it, the same mechanism disables it again within hours or days. Find and remove the persistence (covered in WordPress malware persistence mechanisms) BEFORE reinstalling. Otherwise you're playing whack-a-mole.
Frequently asked questions
See also
- How to Remove WordPress Malware: The Practitioner's Playbook: the broader cleanup methodology. If your security plugin was disabled, the site has been compromised for longer than the alerts suggest, start from the playbook.
- Why WordPress Malware Keeps Coming Back: Persistence Mechanisms: the file-system and database places attackers hide the code that disables Wordfence. Required reading if reinfection keeps happening.
- How to Find the Original Entry Point in a WordPress Compromise: the access-log analysis that identifies which plugin CVE or credential vector got the attacker in. Without this, even a perfect cleanup gets undone.
- How to Detect and Remove Fake WordPress Admin Users: the user-side persistence that usually pairs with security-plugin disabling. If Wordfence went quiet AND new admin accounts appeared, you're seeing both halves of the same intrusion.
- The Fake Cloudflare Verification Attack on WordPress (ClickFix): a common payload deployed after the security plugin has been disabled. Disabled plugin first, social-engineering injection second.
- Cross-Site Contamination on Shared WordPress Hosting: when security plugins on multiple sites get disabled simultaneously, the cause is usually at the hosting layer, not on each individual site.





