I cleaned the SEO spam from the database. Verified it was gone. Refreshed the page. The spam was back.
Not reinfected from a file. Not pulled from a cache. The database itself had reversed my cleanup. The MySQL triggers I hadn't noticed were silently running BEFORE UPDATE on every edit, checking whether the spam links were still present, and blocking the query if they weren't.
This is the story of a WordPress site where cleanup failed six times before I discovered the real persistence mechanism — and it wasn't in any file on the filesystem.
The Initial Picture: Multi-Vector Chaos
A client's WordPress site had been flagged by their hosting provider's security scanner. The initial assessment revealed the kind of mess that makes you reach for more coffee:
- 3 active malware files — an obfuscated webshell (1.2 MB, math-based obfuscation using
pow(),M_PI, and hex constants), a ClickFix JS injector disguised as a caching plugin, and a standalone gambling SEO doorway page in the webroot - 25 rogue administrator accounts — each with REST API application passwords
- ~10,000 SEO spam posts — casino, gambling, pharmaceutical content published under the rogue accounts
- 51 rogue plugin and theme directories — the server's security product had quarantined the payloads, leaving empty shells everywhere
- SEO spam injected directly into legitimate page content — the homepage and a site-wide footer template had hidden links appended using
overflow:hidden;height:1pxCSS
The filesystem malware was standard — obfuscated but recognizable patterns. The server's security product had already quarantined most of it, leaving zero-byte stubs. I catalogued it, collected samples for false-negative reporting, and moved to the database.
That's where things got interesting.
The First Anomaly: Cleanup That Doesn't Stick
The homepage (a large Elementor page) had been flagged by the security scanner six previous times. Each time it was "cleaned." Each time the malware came back. The scanner's log showed the same detection ID being triggered over and over on the same page — a cleanup loop that never resolved.
When I examined the page content, I found two layers of injection:
post_content(the HTML fallback) — hidden<div>blocks withposition:absolute;left:-99999pxcontaining hundreds of spam anchor tags_elementor_data(the JSON source of truth, 7.3 MB) — entire Elementor widget blocks injected as HTML widgets, each containing spam links wrapped inoverflow:hidden;height:1pxdivs
I wrote a targeted SQL UPDATE to strip the spam from post_content. Ran it. Verified the content was clean. Checked the page — the spam was still there.
I ran the query again with explicit WHERE conditions. Checked the affected rows. Zero rows affected. The UPDATE was executing, but silently doing nothing.
Discovery: The MySQL Triggers
When a SQL statement executes but nothing changes, and you know the WHERE clause matches, there are a few possibilities: a locking issue, a broken transaction, or — and this is the one nobody checks first — a trigger.
SHOW TRIGGERS LIKE 'wp_posts';
Two triggers appeared that should not exist on any WordPress installation:
+-------------------------------------+--------+-----------+
| Trigger | Event | Timing |
+-------------------------------------+--------+-----------+
| wds_protect_XXXX_before_update | UPDATE | BEFORE |
| wds_protect_XXXX_after_update | UPDATE | AFTER |
+-------------------------------------+--------+-----------+
The trigger names followed a pattern: wds_protect_{POST_ID}_{timing}. Let me show you what was inside.
The BEFORE UPDATE Trigger
CREATE TRIGGER wds_protect_XXXX_before_update
BEFORE UPDATE ON wp_posts
FOR EACH ROW
BEGIN
IF NEW.ID = XXXX THEN
-- Check if protected links are still present in the new content
SET @links_ok = 1;
-- For each protected link, verify it exists in the NEW content
IF LOCATE('protected-link-1.example', NEW.post_content) = 0 THEN
SET @links_ok = 0;
END IF;
IF LOCATE('protected-link-2.example', NEW.post_content) = 0 THEN
SET @links_ok = 0;
END IF;
-- ... repeated for each spam link
IF @links_ok = 0 THEN
-- Block the update by raising an error
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Edit of this page or post is not possible.';
END IF;
END IF;
END;
Read that carefully. On every UPDATE to the posts table for this specific post ID, the trigger:
- Checks whether the new content still contains all the spam links
- If any link is missing from the updated content, it raises a MySQL error and blocks the entire UPDATE
- The error message is deliberately generic ("Edit of this page or post is not possible") — if this appeared in the WordPress admin, a site owner would assume it's a plugin conflict or a permission issue
The AFTER UPDATE trigger served as a secondary check — if somehow the BEFORE trigger was bypassed, the AFTER trigger would log the modification and flag it for re-injection.
The database was literally preventing the removal of malicious content. Not passively storing it — actively defending it.
Layer 2: The WP Options Configuration
Triggers don't create themselves. Something installed them. Searching the wp_options table revealed a configuration system:
SELECT option_name, LEFT(option_value, 100)
FROM wp_options
WHERE option_name LIKE 'wds_%';
+---------------------------+--------------------------------------------+
| option_name | option_value |
+---------------------------+--------------------------------------------+
| wds_protection_enabled | 1 |
| wds_protected_posts | a:1:{i:0;i:XXXX;} |
| wds_protected_links | a:15:{i:0;s:28:"protected-link-1.example" |
| wds_last_check | 1718XXXXXXXX |
+---------------------------+--------------------------------------------+
The wds_ prefix suggested a legitimate-sounding plugin name (something like "WordPress Data Shield" or "WP Database Security" — impossible to know the exact branding since the plugin directory had been quarantined to zero-byte files by the security product).
Four options controlling the system:
wds_protection_enabled— master switchwds_protected_posts— serialized array of post IDs to protectwds_protected_links— serialized array of spam URLs that must remain in the contentwds_last_check— timestamp of the last trigger integrity verification
The options stored the configuration. But who was reading them?
Layer 3: The WP-Cron Job
Searching the WordPress cron schedule revealed a registered hook:
SELECT option_value FROM wp_options WHERE option_name = 'cron';
-- Within the serialized cron data:
-- "wds_check_protected_links" scheduled hourly
A WordPress cron job named wds_check_protected_links was running every hour. Without the plugin code to inspect (it had been quarantined), I had to infer its behavior from the evidence:
- Read the list of protected posts and links from
wds_protected_postsandwds_protected_links - Verify that the MySQL triggers still exist on the
wp_poststable - If triggers are missing — recreate them
- Verify the spam links are still present in the protected posts
- If links were removed despite the triggers — re-inject them
- Update
wds_last_checktimestamp
This is what made the infection survive. Even if you:
- Dropped the triggers manually → the cron would recreate them within the hour
- Cleaned the spam content during the window after dropping triggers → the cron would re-inject it
- Deleted the
wds_*options → the plugin code would recreate them on the next page load
Layer 4: Plugin Auto-Activation
When I deactivated all plugins to stop the cron job from running, I discovered the final layer. The active_plugins option in the database was being automatically modified to re-include the malicious plugin.
The attacker had either:
- Placed a
mu-pluginsdropper (must-use plugins auto-load, can't be deactivated from the admin panel) that re-activates the main plugin on every request, or - Hooked into WordPress's
shutdownaction to check and restore the plugin's active state
In this case, the mu-plugins directory had already been cleaned by the security product. But the active_plugins array still showed the rogue plugin. Setting it to an empty array broke the loop — with no code to execute, the self-healing cycle couldn't restart.
The Full Kill Chain
Here's the complete persistence architecture, visualized as a dependency graph:
+-------------------+
| Rogue Plugin | ← Layer 4: Auto-activates via mu-plugin/shutdown hook
| (wds_*.php) |
+--------+----------+
|
| registers
v
+-------------------+
| WP-Cron Job | ← Layer 3: Runs hourly
| wds_check_ |
| protected_links |
+--------+----------+
|
+--------+--------+
| |
v v
+-----------------+ +------------------+
| wp_options | | MySQL Triggers | ← Layer 1 & 2
| wds_* config | | BEFORE/AFTER |
| (link list, | | UPDATE on |
| post IDs) | | wp_posts |
+-----------------+ +------------------+
|
| blocks UPDATE if
| spam links removed
v
+------------------+
| wp_posts content |
| (protected spam) |
+------------------+
Each layer protects the one below it. Kill the triggers? The cron rebuilds them. Kill the cron? The plugin re-registers it. Deactivate the plugin? The auto-activation mechanism restores it. Delete the options? The plugin recreates them.
You have to kill all four layers simultaneously.
The Remediation: Simultaneous Takedown
Once I understood the architecture, the cleanup had to be executed as a single atomic operation — or as close to it as possible. Here's the sequence:
-- Step 1: Kill the executor (prevents recreation of anything)
UPDATE wp_options
SET option_value = 'a:0:{}'
WHERE option_name = 'active_plugins';
-- Step 2: Remove the scheduler (prevents trigger recreation)
-- Delete wds cron hook from serialized cron data
UPDATE wp_options
SET option_value = REPLACE(option_value, 's:26:"wds_check_protected_links"', '')
WHERE option_name = 'cron';
-- Step 3: Drop the guardians (allows content modification)
DROP TRIGGER IF EXISTS wds_protect_XXXX_before_update;
DROP TRIGGER IF EXISTS wds_protect_XXXX_after_update;
-- Step 4: Delete the configuration (prevents reconfiguration)
DELETE FROM wp_options
WHERE option_name LIKE 'wds_%';
-- Step 5: NOW clean the content (triggers can't block this anymore)
-- Truncate post_content at safe boundary
UPDATE wp_posts
SET post_content = LEFT(post_content, 23266)
WHERE ID = XXXX;
-- Clean Elementor JSON (required PHP script with prepared statements)
-- Recursive widget removal: 7.3MB -> 88KB, 33 spam widgets removed
The order matters. Step 1 must come first — if the plugin code runs between any other steps, it will detect the modification and start rebuilding. With the plugin deactivated and no mu-plugins dropper, there's nothing to trigger the recovery.
Why This Is Harder Than It Looks
The Trigger Error Is Deliberately Misleading
When the BEFORE UPDATE trigger blocks a modification, the MySQL error message is:
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Edit of this page or post is not possible.'
In the WordPress admin, this surfaces as a vague error: "Could not update the post in the database." A site administrator would assume a plugin conflict, a database corruption issue, or a permission problem. They would never suspect that the database is deliberately preventing the edit.
This is social engineering at the infrastructure level. The error message is designed to send the victim down the wrong troubleshooting path.
Standard Cleanup Tools Can't See Triggers
WordPress malware scanners focus on three things: filesystem malware (PHP files with suspicious patterns), database injections (malicious content in posts/options), and rogue users. None of them check for MySQL triggers.
SHOW TRIGGERS is not part of any standard WordPress security scan I've encountered. The triggers operate at a layer below the application — WordPress itself doesn't know they exist. It just sees UPDATE queries failing with a generic error.
The Elementor Complication
The site used Elementor, which stores page content in two parallel formats:
post_content— static HTML (fallback for non-Elementor rendering)_elementor_data— JSON blob containing the widget tree (source of truth for Elementor)
Both must be cleaned independently. Cleaning post_content without cleaning _elementor_data means Elementor will re-render the spam on the next page edit. Cleaning the JSON requires parsing the widget tree recursively and removing entire widget blocks — not just string replacement. The 7.3 MB JSON structure was too large for simple regex, requiring a PHP script with proper JSON parsing and prepared statements to avoid escaping issues.
Active Reinfection During Remediation
While I was cleaning the database, the 25 rogue admin accounts were still active. During remediation, a new spam post appeared — created by one of the rogue users through the REST API using an application password. The rogue accounts couldn't be touched (database user management was outside remediation scope), but their activity demonstrated that until the client rotates credentials, the site remains at risk of reinfection.
The Broader Implications
This Technique Is Transferable
MySQL triggers work on any table. The same approach could protect:
- Rogue admin accounts — a trigger on the users table that blocks
DELETEorUPDATEon specific user IDs - Backdoor options — a trigger on
wp_optionsthat prevents deletion of malicious option rows - Malicious cron entries — a trigger that blocks modifications to the cron option
- Plugin activation state — a trigger that reverses
active_pluginschanges
Any row in any table can be made "immortal" with a well-crafted trigger. The only limitation is that creating triggers requires the TRIGGER privilege, which most WordPress database users have by default on shared hosting.
Detection Recommendations
Add these checks to your WordPress security audit workflow:
# Check for ANY triggers on WordPress tables
SHOW TRIGGERS;
# If you find triggers, inspect them:
SHOW CREATE TRIGGER trigger_name;
# Check for wds_* or similar persistence options
SELECT option_name, LEFT(option_value, 100)
FROM wp_options
WHERE option_name NOT IN (
-- list of known WordPress core options
'siteurl', 'blogname', 'blogdescription', ...
)
AND option_name LIKE '%protect%'
OR option_name LIKE '%wds%'
OR option_name LIKE '%shield%'
OR option_name LIKE '%guard%';
# Audit the cron schedule for unknown hooks
# (requires PHP or WP-CLI)
wp cron event list
The critical command is SHOW TRIGGERS. If you're not running it during WordPress malware investigations, you have a blind spot. A clean WordPress installation has zero triggers. Any trigger is an anomaly worth investigating.
The Full Infection at a Glance
Here's what the complete remediation covered beyond the trigger system:
- Filesystem: 3 active malware samples (obfuscated webshell, ClickFix JS injector disguised as caching plugin, SEO doorway page), 16 rogue plugin directories, 35 rogue theme directories, 6 rogue directories in WordPress core paths (
wp-admin/,wp-includes/), dozens of zero-byte stubs - Database: ~10,000 spam posts deleted, homepage cleaned (100K → 23K chars in HTML, 7.3 MB → 88 KB in Elementor JSON), footer template cleaned, 25 rogue admin accounts documented
- Persistence: 2 MySQL triggers dropped, 4 WDS configuration options deleted, 1 malicious cron hook removed, all plugins deactivated
The filesystem malware was the visible layer. The database spam was the payload. The trigger system was the armor. Each layer had a different job, and together they created an infection that survived six automated cleanup attempts.
Key Takeaways
- Run
SHOW TRIGGERSon every WordPress database you investigate. Zero is the expected count. Anything else is suspicious. This single command would have caught this entire persistence mechanism immediately. - When UPDATE queries silently fail, suspect triggers before plugin conflicts. If you can
SELECTthe row but can'tUPDATEit, and the error message is vague, checkSHOW TRIGGERS LIKE 'table_name'. - Self-healing malware requires simultaneous multi-layer takedown. Killing one component at a time gives the others time to rebuild. Map the dependency chain first, then execute the cleanup in dependency order.
- The
TRIGGERprivilege is a security risk on shared hosting. Most WordPress database users are grantedALL PRIVILEGESby default, which includesTRIGGER. If your hosting environment allows it, consider revoking this privilege for WordPress database users — WordPress never uses triggers in normal operation. - Elementor sites need dual-format cleanup.
post_contentand_elementor_dataare independent storage layers. Clean both, or the spam regenerates on the next page edit.
The most dangerous part of this infection wasn't the malware files or the spam posts — those are standard. It was the principle: the database becoming an active participant in defending the infection. Once you accept that MySQL triggers can be weaponized, you start checking for them. And that's the entire point of documenting cases like this — expanding the mental model of what "database-level persistence" actually means.
It's not just data sitting in a row. Sometimes, it's the database fighting back.