TechEarl

WordPress File Integrity Monitoring That Can't Be Disabled from Inside the Server

Wordfence, Sucuri, and every in-WordPress security plugin can be disabled by malware running with the same privileges. The fix is monitoring at a layer the attacker can't touch: AIDE, maldet, or a custom cron-driven script running as root. With working configurations for each.

Ishan KarunaratneIshan Karunaratne⏱️ 17 min readUpdated
Why in-WordPress security plugins can be disabled by malware, and the AIDE / maldet / OSSEC server-side monitoring stack that survives a compromise.

Server-side file integrity monitoring is the layer of WordPress defense that doesn't depend on WordPress being trustworthy. A scheduled job running as root reads files independently, computes hashes, compares against a stored baseline, and alerts you through email (the OS mail utility, not wp_mail) when anything in the watched paths changes unexpectedly. Nothing inside WordPress can touch it: malware that compromises the WordPress user has no path to the root cron, the baseline file, or the outbound mail relay.

This is the only monitoring layer that survives the security plugins being silently disabled attack pattern, because the attacker who can disable Wordfence has no comparable ability to disable a system cron job running as a different user. This article covers three approaches at different sophistication levels: a 30-line custom cron script (sufficient for one or two sites), AIDE (the canonical Linux FIM, sufficient for one server), and OSSEC or Wazuh (full host intrusion detection, sufficient for multi-server deployments).

I started recommending server-side FIM seriously after a string of cleanups in 2019-2020 where attackers were targeting Wordfence's active_plugins row before deploying any visible payload. The site owners would check Wordfence ("looks fine"), the dashboard would say "Last scan: clean", and the actual compromise had been running for weeks. The fix isn't more in-WordPress monitoring; it's putting at least one monitoring layer outside the trust boundary the attacker has access to.

Why in-WordPress FIM isn't enough

Every WordPress security plugin that does file integrity monitoring (Wordfence's "Scan", Sucuri Security's integrity check, MalCare's site monitoring, WP Activity Log's file change detection) runs inside WordPress. The plugin computes hashes, stores a baseline, and reports differences. That works fine against opportunistic malware that doesn't know WordPress is monitored.

It fails against malware that knows. The same access that lets an attacker drop a backdoor in wp-content/uploads/ lets them:

  1. Remove the security plugin from wp_options.active_plugins (the plugin stops running, no scans happen, no alerts).
  2. Replace the plugin's baseline-storage file with a baseline that includes the backdoor (the scan runs but reports clean).
  3. Patch the plugin's check function via a mu-plugin that unregisters its hooks (the plugin appears active but does nothing).
  4. Block the plugin's outbound notification request via a pre_http_request filter (scans run, find the issue, but the alert never leaves the server).

Each of those is documented in Why Wordfence Got Silently Disabled. The pattern is real and reasonably common in 2020+ campaigns.

The structural fix: at least one monitoring layer has to live above the WordPress user's privilege, with state stored where WordPress can't reach it, and alerts delivered through a channel WordPress can't intercept. Three options in increasing sophistication:

Approach 1: A minimal custom cron script

The smallest possible useful FIM. A bash script run as root from a system crontab, hashing a fixed set of paths, comparing against a stored baseline, mailing on diff. ~50 lines total. Works on any Linux server with sha256sum, find, and mail.

Save as /usr/local/sbin/wp-fim.sh:

wp-fim.shServer-side file integrity monitor. Runs as root from cron, alerts via OS mail on diff.Download
bash
#!/usr/bin/env bash
# wp-fim.sh, server-side file integrity monitor for a single WordPress install.
# Source: https://techearl.com/wordpress-file-integrity-monitoring-server-side
# Site:   https://techearl.com/
# Runs as root from cron. Stores baseline in /var/lib/wp-fim/ (out of WordPress's reach).
# Alerts via OS mail when anything in WATCH_PATHS changes from the baseline.

set -e
WP_ROOT="/var/www/wordpress"
ALERT_EMAIL="alerts@your-off-server-address.com"
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
BASELINE_DIR="/var/lib/wp-fim"
mkdir -p "$BASELINE_DIR"

# Paths to monitor. wp-content/uploads/ is excluded because legitimate uploads
# change frequently; we monitor whether PHP appears there separately.
WATCH_PATHS=(
  "$WP_ROOT/wp-config.php"
  "$WP_ROOT/.htaccess"
  "$WP_ROOT/index.php"
  "$WP_ROOT/wp-admin"
  "$WP_ROOT/wp-includes"
  "$WP_ROOT/wp-content/plugins"
  "$WP_ROOT/wp-content/themes"
  "$WP_ROOT/wp-content/mu-plugins"
)

# 1. Compute the current state
CURRENT="$BASELINE_DIR/current.txt"
> "$CURRENT"
for p in "${WATCH_PATHS[@]}"; do
  [ -e "$p" ] || continue
  if [ -d "$p" ]; then
    find "$p" -type f \( -name '*.php' -o -name '*.htaccess' -o -name '*.js' \) \
      -exec sha256sum {} \; >> "$CURRENT" 2>/dev/null
  else
    sha256sum "$p" >> "$CURRENT" 2>/dev/null
  fi
done

# 2. Also catch the "PHP appeared in uploads" case (always suspicious)
SUSPICIOUS_UPLOADS=$(find "$WP_ROOT/wp-content/uploads" -type f -name '*.php' 2>/dev/null)

# 3. Compare to baseline (first run creates the baseline)
BASELINE="$BASELINE_DIR/baseline.txt"
if [ ! -f "$BASELINE" ]; then
  cp "$CURRENT" "$BASELINE"
  echo "Initial baseline created at $BASELINE." | mail -s "wp-fim baseline created on $HOSTNAME" "$ALERT_EMAIL"
  exit 0
fi

DIFF=$(diff "$BASELINE" "$CURRENT" || true)

if [ -n "$DIFF" ] || [ -n "$SUSPICIOUS_UPLOADS" ]; then
  {
    echo "WordPress file integrity anomaly on $HOSTNAME at $(date)."
    echo
    if [ -n "$SUSPICIOUS_UPLOADS" ]; then
      echo "PHP files found in wp-content/uploads (always suspicious):"
      echo "$SUSPICIOUS_UPLOADS"
      echo
    fi
    if [ -n "$DIFF" ]; then
      echo "Hash differences from baseline:"
      echo "$DIFF" | head -200
    fi
  } | mail -s "ALERT: wp-fim integrity anomaly on $HOSTNAME" "$ALERT_EMAIL"
fi

Make it executable and add to root's crontab:

bash
sudo chmod 700 /usr/local/sbin/wp-fim.sh
sudo crontab -e
# Add:
*/15 * * * * /usr/local/sbin/wp-fim.sh > /var/log/wp-fim.log 2>&1

That's it. Every 15 minutes the script runs as root, computes the current state, compares to the baseline stored in /var/lib/wp-fim/ (which the WordPress user cannot read or write because /var/lib/ is owned by root with mode 755), and emails on differences.

Updating the baseline after a legitimate change: deliberate. When you legitimately update a plugin, theme, or core, you run:

bash
sudo /usr/local/sbin/wp-fim.sh    # generates alert (the change you just made)
sudo cp /var/lib/wp-fim/current.txt /var/lib/wp-fim/baseline.txt

The discipline of having to consciously update the baseline is a feature. If you didn't make a change but the baseline differs, that's a real anomaly.

Why this beats in-WordPress FIM:

  • Stored state is in /var/lib/wp-fim/, not in wp-content/ or wp_options. WordPress malware running as www-data cannot read or write it.
  • Cron runs as root, scheduled by /etc/crontab (which the WordPress user cannot read).
  • Alerts go through OS mail, which reads /etc/aliases, /etc/postfix/, or similar. WordPress has no path to intercept.
  • The script itself is in /usr/local/sbin/ (root-owned, 700). WordPress can't modify it.

The only attack path is compromising the OS user (root) or finding an OS-level privilege escalation. Those are real but a vastly different (and rarer) threat model than "plugin CVE → admin user".

Approach 2: AIDE (canonical Linux FIM)

AIDE (Advanced Intrusion Detection Environment) is what real Linux deployments use. It's been the standard since 2001, ships in every major distro's package manager, and handles the things the custom script doesn't (proper baseline rotation, configuration as data, performance on large file trees, reasonable handling of legitimate noise).

Installation:

bash
# Debian / Ubuntu
sudo apt install aide

# RHEL / Rocky / Alma
sudo dnf install aide

# Alpine
sudo apk add aide

The configuration is in /etc/aide/aide.conf (Debian-style) or /etc/aide.conf (RHEL-style). A WordPress-focused configuration adds these blocks:

code
# /etc/aide/aide.conf.d/50_aide_wordpress
# (Debian/Ubuntu drops files from this dir into the main aide.conf)

# Watch WordPress core deeply
/var/www/wordpress/wp-admin       NORMAL
/var/www/wordpress/wp-includes    NORMAL
/var/www/wordpress/wp-content/plugins  NORMAL
/var/www/wordpress/wp-content/themes   NORMAL
/var/www/wordpress/wp-content/mu-plugins  NORMAL

# Watch the front-door files closely
/var/www/wordpress/wp-config.php  R+sha256+sha512
/var/www/wordpress/.htaccess      R+sha256+sha512
/var/www/wordpress/index.php      R+sha256+sha512

# Watch for PHP appearing where it shouldn't
=/var/www/wordpress/wp-content/uploads/ Norm
!/var/www/wordpress/wp-content/uploads/.*\.(jpg|jpeg|png|gif|webp|svg|pdf|doc|docx|xls|xlsx|zip|mp4|mp3|m4a|webm|mov)$

# Ignore the cache directories that change constantly
!/var/www/wordpress/wp-content/cache
!/var/www/wordpress/wp-content/uploads/[0-9]{4}/[0-9]{2}/.*$  WLOG

Initialize the baseline:

bash
sudo aideinit
# This takes a few minutes on a typical WordPress install.
# Output: /var/lib/aide/aide.db.new

# Move the new database into place as the active baseline
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

Run the check from cron:

bash
sudo crontab -e
# Add:
0 4 * * * /usr/sbin/aide --check 2>&1 | mail -s "AIDE report $(hostname)" alerts@your-address.com

AIDE outputs a structured report:

code
AIDE 0.18.6 found differences between database and filesystem!!

Summary:
  Total number of entries:    12847
  Added entries:                2
  Removed entries:              0
  Changed entries:              1

---------------------------------------------------
Added entries:
---------------------------------------------------

f++++++++++++++++: /var/www/wordpress/wp-content/uploads/2026/05/cache.php
f++++++++++++++++: /var/www/wordpress/wp-content/themes/active/test-cross-site.php

---------------------------------------------------
Changed entries:
---------------------------------------------------

f >.... mc.. : /var/www/wordpress/wp-config.php

f++++++++++++++++ marks new files (the two backdoors that appeared). f >.... mc.. marks a changed file (wp-config.php modification, with m = mtime changed, c = content/hash changed).

After a legitimate update, refresh the baseline:

bash
sudo aide --update
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

Why AIDE beats the custom script:

  • Tuned hashing per file class (cheap SHA1 for many files, SHA256/SHA512 for security-critical ones)
  • Built-in suppression for legitimate noise (cache directories, log rotation, atime changes)
  • Configurable monitoring granularity (you don't need to hash an image file's content; size and mtime are enough)
  • Reports are structured (machine-parseable) instead of free-form diffs
  • Database integrity (AIDE signs its baseline, detecting tampering with the baseline itself)

For one or two WordPress sites on a single server, AIDE is the right tool.

Approach 3: Linux Malware Detect (maldet)

AIDE detects integrity changes. It doesn't know whether a changed file is malicious or just modified. maldet (Linux Malware Detect) is the complement: it actively scans files against signature databases for known web malware patterns, and flags hits regardless of whether they're new.

Maldet is particularly strong against PHP webshells (c99, r57, WSO, b374k, FilesMan) and the patterns the ClickFix family injects. It bundles its own signature feed plus a ClamAV integration for AV-style scanning.

Installation (it's not in distro repos; install from the maintainer's site):

bash
cd /tmp
wget https://www.rfxn.com/downloads/maldetect-current.tar.gz
tar xzf maldetect-current.tar.gz
cd maldetect-*
sudo ./install.sh

Configuration is in /usr/local/maldetect/conf.maldet. The key settings:

bash
# Email alerts on detection
email_alert="1"
email_addr="alerts@your-off-server-address.com"
email_subj="maldet alert from %h"

# Run a daily scan from the included cron (already set up by install.sh)
# Set quarantine to ON so detections are auto-isolated
quarantine_hits="1"
quarantine_clean="0"  # don't auto-clean; review before cleaning

# Watch this WordPress install
scan_max_depth="20"

Manual scan:

bash
sudo maldet -a /var/www/wordpress

The scan takes a few minutes for a typical WordPress install. Output:

code
Linux Malware Detect v1.6.5
            (C) 2002-2023, R-fx Networks

(*) {SCAN ID: 240519-0431.123456}
(*) Scan time:    Thu May 19 04:31:00 UTC 2026
(*) Scan results: 2 hits

  {HEX}base64.inject.unclassed.6  : /var/www/wordpress/wp-content/uploads/2024/10/cache.php => quarantine
  {MD5}backdoor.b374k.x86         : /var/www/wordpress/wp-content/themes/active/.cache.php  => quarantine

The => quarantine action moves the file to /usr/local/maldetect/quarantine/ so it can't execute, then emails the alert. You review the quarantined files, confirm they're malicious, and delete them; or restore if it was a false positive.

maldet runs daily via the included cron. Combine it with AIDE: AIDE catches anything that changed, maldet catches anything malicious that's there whether it changed or not.

Approach 4: OSSEC or Wazuh (full HIDS, multi-host)

For deployments with multiple servers or where you want central alert management, OSSEC (the original) or Wazuh (the actively-developed fork) provide host-based intrusion detection that includes FIM, log analysis, rootkit detection, and active response.

The architecture: an agent on each monitored host reports to a central manager, which correlates events and sends consolidated alerts. The agent runs as a non-root user with carefully scoped permissions; the manager runs on a separate host.

This is enterprise-grade and over-engineered for one or two WordPress sites. It's the right answer when:

  • You operate 5+ sites and want central visibility
  • You have separate dev/staging/prod environments and want correlated alerts
  • You need compliance-grade reporting (PCI DSS, HIPAA, SOC 2)
  • You want to combine FIM with log analysis and active blocking in one platform

Installation and configuration is its own topic, well-covered in Wazuh's official documentation. For typical small-to-medium WordPress operations, this is more infrastructure than the value justifies. Approaches 1-3 are usually sufficient.

Picking the right approach for your site

A rough decision tree:

SituationRight approach
One WordPress site, shared hosting (no shell access)You can't run this. Use Wordfence + manual periodic checks + plan to migrate to hosting with SSH
One WordPress site, VPS or managed hosting with SSHThe 30-line custom script (Approach 1)
Two to four sites on one VPSAIDE (Approach 2) covering all sites
Five-plus sites on a VPS, or any site with paid plugins worth protectingAIDE + maldet (Approaches 2 + 3)
Multiple servers, multiple environments, compliance requirementsOSSEC or Wazuh (Approach 4)

The progression is additive: maldet doesn't replace AIDE; they complement each other. AIDE detects change, maldet detects malice. A baseline-matching file containing a c99 shell is malicious despite passing AIDE; a baseline-not-matching wp-config.php is suspicious despite passing maldet.

What to monitor (the list nobody else publishes completely)

The paths I always include in a WordPress server-side FIM configuration:

code
# WordPress core (any change is suspicious; updates should be the only source of changes)
/var/www/wordpress/wp-admin/
/var/www/wordpress/wp-includes/
/var/www/wordpress/index.php
/var/www/wordpress/wp-load.php
/var/www/wordpress/wp-settings.php
/var/www/wordpress/wp-login.php
/var/www/wordpress/wp-cron.php

# Configuration (change only when you legitimately edit config)
/var/www/wordpress/wp-config.php
/var/www/wordpress/.htaccess

# Plugins and themes (change only when you update or install)
/var/www/wordpress/wp-content/plugins/
/var/www/wordpress/wp-content/themes/
/var/www/wordpress/wp-content/mu-plugins/

# Drop-ins (should not exist unless you have specific caching/object-cache plugins)
/var/www/wordpress/wp-content/advanced-cache.php
/var/www/wordpress/wp-content/object-cache.php
/var/www/wordpress/wp-content/db.php
/var/www/wordpress/wp-content/sunrise.php

# Web server config (the broader attack surface)
/etc/apache2/   or   /etc/nginx/
/etc/php/

# OS-level cron (where attackers schedule re-infection)
/etc/crontab
/etc/cron.d/
/etc/cron.daily/
/etc/cron.hourly/
/etc/cron.weekly/
/var/spool/cron/

# SSH access
/etc/ssh/sshd_config
/root/.ssh/authorized_keys
/home/*/.ssh/authorized_keys

# User-account shell hooks (the gsyndication-family persistence layer)
# Attackers append a base64 launcher to these files so a rogue process
# respawns the moment any shell session for the hosting user starts.
# See section 11 of /wordpress-malware-persistence-mechanisms.
/home/*/.bashrc
/home/*/.bash_profile
/home/*/.profile
/home/*/.zshrc

# User-home stash locations attackers use to hide resident binaries
/home/*/.config/htop/

The user-account shell hooks (.bashrc and friends) and the ~/.config/htop/ stash location deserve special note. They are the persistence layer behind the gsyndication.com wp-config.php reinjection pattern I see most often in 2026, and they fall completely outside the document root that WordPress security plugins ever scan. The malware itself lives above the WordPress directory, in the hosting user's home, where nothing scans by default. You can clean WordPress repeatedly while the malware is untouched one directory up.

This is the attack class known as above-doc-root persistence, and every popular WordPress security plugin is blind to it by design. Wordfence, Sucuri Security, iThemes Security Pro (Solid Security), Patchstack, MalCare, Jetpack Scan, and every hosting-provider one-click cleanup run as PHP inside the WordPress install and cannot enumerate processes, read user dotfiles, or look in /etc/. A server-side FIM with these paths in the watch list is the only monitoring layer that surfaces the reinfection source. Without it, the only signal you have that the attacker is still resident is wp-config.php repeatedly reinjecting itself with nothing in the WordPress filesystem looking suspicious, while every dashboard you log into tells you the site is clean. Section 11 of the persistence article covers the full attack class, the affected products list, and the modern campaigns in this category (Balada Injector, Sign1, gsyndication, Sysrv-hello, WP-VCD).

What NOT to monitor closely (too noisy):

code
/var/www/wordpress/wp-content/uploads/    # legitimate media uploads constantly
/var/www/wordpress/wp-content/cache/      # cache plugins write here on every request
/var/log/                                  # log rotation generates constant change
/tmp/                                      # by design

But monitor the wp-content/uploads/ directory for a specific case: any .php file appearing in it. PHP under uploads is always suspicious; image uploads are not. AIDE's = and ! syntax handles this distinction (see the example config above).

Alert delivery: the part most setups get wrong

The script or AIDE detected something. The alert has to reach you. Three layers to get right:

  1. Local mail transport: a working MTA on the server (postfix, exim, or msmtp) that can hand mail to a remote SMTP relay. Test with echo "test" | mail -s "test" you@example.com.
  2. Outbound delivery: most hosting providers block port 25 outbound. Configure your MTA to use a transactional email provider (Postmark, SendGrid, Mailgun, Amazon SES) over port 587 with authentication.
  3. Off-server inbox: the alert email goes to an address you read. Not the same domain as the WordPress site. If the WordPress site is compromised, an attacker who can read mail at alerts@thesite.com can suppress alerts. Use a separate domain or a different mail provider entirely.

A working postfix-with-Sendgrid setup as relay (/etc/postfix/main.cf):

code
relayhost = [smtp.sendgrid.net]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt

/etc/postfix/sasl_passwd:

code
[smtp.sendgrid.net]:587 apikey:YOUR-SENDGRID-API-KEY

After sudo postmap /etc/postfix/sasl_passwd and sudo systemctl restart postfix, mail flows.

Common mistakes

The patterns that make server-side FIM not actually work:

Storing the baseline inside the WordPress directory. Defeats the entire purpose. The baseline has to be at /var/lib/wp-fim/, /var/lib/aide/, or similar root-owned path that the WordPress user cannot read or write.

Running the FIM script as the WordPress user. Same problem. The script has to run as root (or at least as a user the WordPress user cannot become).

Letting the alert email arrive at an inbox on the same domain. If the WordPress site is compromised, the attacker controls the mail for the domain. Use an off-server inbox.

Not testing the alert delivery before relying on it. Send a deliberate test alert immediately after setup. If it doesn't arrive within a minute, the alert is broken and you'll discover it the next time you needed it.

Forgetting to update the baseline after legitimate changes. Then every legitimate plugin update generates a flood of "anomaly" alerts and you stop reading them. The discipline is: after every conscious legitimate change, refresh the baseline.

Monitoring wp-content/uploads/ for any change. Far too noisy; visitors and admins upload media constantly. Monitor it only for the specific case of PHP files appearing.

Skipping the OS-level paths. Attackers schedule re-infection via root cron, modify SSH config to add backdoor keys, and tweak web-server config to add auto_prepend_file directives. None of these are in the WordPress directory but all of them affect WordPress. Monitor them too.

Frequently asked questions

See also

External references: the AIDE official documentation covers the configuration language in full. Linux Malware Detect's project page maintains the current signature feeds. Wazuh's documentation is the canonical reference for the OSSEC fork.

TagsWordPressSecurityFile Integrity MonitoringFIMAIDEmaldetOSSECWazuhTripwireServer HardeningWordfenceSucuriAbove Doc RootReinfection
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