Back to Articles

When the Database Fights Back: MySQL Trigger Malware That Protects Itself

Share:
Database Security - MySQL Trigger Malware

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:1px CSS

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:

  1. post_content (the HTML fallback) — hidden <div> blocks with position:absolute;left:-99999px containing hundreds of spam anchor tags
  2. _elementor_data (the JSON source of truth, 7.3 MB) — entire Elementor widget blocks injected as HTML widgets, each containing spam links wrapped in overflow:hidden;height:1px divs

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:

  1. Checks whether the new content still contains all the spam links
  2. If any link is missing from the updated content, it raises a MySQL error and blocks the entire UPDATE
  3. 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 switch
  • wds_protected_posts — serialized array of post IDs to protect
  • wds_protected_links — serialized array of spam URLs that must remain in the content
  • wds_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:

  1. Read the list of protected posts and links from wds_protected_posts and wds_protected_links
  2. Verify that the MySQL triggers still exist on the wp_posts table
  3. If triggers are missing — recreate them
  4. Verify the spam links are still present in the protected posts
  5. If links were removed despite the triggers — re-inject them
  6. Update wds_last_check timestamp

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-plugins dropper (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 shutdown action 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 DELETE or UPDATE on specific user IDs
  • Backdoor options — a trigger on wp_options that 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_plugins changes

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

  1. Run SHOW TRIGGERS on 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.
  2. When UPDATE queries silently fail, suspect triggers before plugin conflicts. If you can SELECT the row but can't UPDATE it, and the error message is vague, check SHOW TRIGGERS LIKE 'table_name'.
  3. 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.
  4. The TRIGGER privilege is a security risk on shared hosting. Most WordPress database users are granted ALL PRIVILEGES by default, which includes TRIGGER. If your hosting environment allows it, consider revoking this privilege for WordPress database users — WordPress never uses triggers in normal operation.
  5. Elementor sites need dual-format cleanup. post_content and _elementor_data are 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.