Skip to content
Back to Articles

Two mu-Plugins That Rebuilt Each Other, and the Application Passwords That Let the Attacker Back In

Share:

The site owner had cleaned this headless WordPress install more than once. Every time, the same things came back: a rogue "security" plugin reappeared in the plugins list, the theme's functions.php was re-injected, and two must-use plugins were rewritten on disk at dawn with nobody logged in. The server's security scanner flagged the files each time, the owner deleted them, and a few hours later they were back.

This was not one piece of malware. It was two families working together, each able to rebuild the other. One kept itself alive through a pair of mu-plugins, a recovery block hidden in wp-config.php and full copies of itself in the database. The other lived almost entirely in wp_options, and its way back in was a set of WordPress Application Passwords on the administrator accounts. This article walks through each layer, shows how to find it, and explains why the cleanup only works if every layer goes in one pass. Everything is sanitized: no site names, domains, IPs or keys.

🧭 The layers at a glance

  • Two mu-plugins with identical code, each checking the other on every request and rewriting it if it is missing or changed.
  • A recovery block in wp-config.php, placed before WordPress loads, that can rebuild both mu-plugins from the database.
  • Database copies: base64 copies of the whole runtime and of the second family's code, plus a zipped copy of its plugin, all in wp_options.
  • Application Passwords created on a dozen administrator accounts in the same second, giving a remote panel authenticated REST access.
  • Cron events that still pointed to the remote panel after the code behind them was gone.
  • Small helper files: a 192-byte loader stub in the site root, a fake "real" object-cache file and a lock file.

🔁 Layer 1: two mu-plugins that guard each other

Files in wp-content/mu-plugins/ load on every request and cannot be switched off from the dashboard. That alone makes them a favourite hiding place. This kit went one step further and installed two copies under harmless names, one presenting itself as an object-cache handler and one as a term-meta helper. Both carried the same class, and on every request the first thing that class did was check that its partner and its helpers still existed:

public function fast_check(){
    $mu1 = WP_CONTENT_DIR . "/mu-plugins/<name-1>.php";
    $mu2 = WP_CONTENT_DIR . "/mu-plugins/<name-2>.php";
    $oc  = WP_CONTENT_DIR . "/object-cache.php";
    $lc  = ABSPATH . "class-wp-locale-compat.php";
    if (file_exists($mu1) && file_exists($mu2) && file_exists($oc) && file_exists($lc)) {
        // everything present: full re-check at most once an hour
        if (!get_transient("_wpoc_rc")) { $this->repair(); set_transient("_wpoc_rc", 1, 3600); }
        return;
    }
    // something is missing: repair, at most once a minute
    if (!get_transient("_wpoc_rcm")) { set_transient("_wpoc_rcm", 1, 60); $this->repair(); }
}

repair() reads its own source with file_get_contents(__FILE__), checks a version marker, and writes that code back to both mu-plugin paths. It also restores a tiny loader in the site root and, if the object-cache drop-in is missing or is one of its own, replaces it with a "bridge" version. Delete one mu-plugin and the next page view puts it back. Delete both in two separate steps and the second one restores the first before you get to it.

Notice the transients. The rate limit means the repair does not hammer the disk, which keeps it quiet in logs. It also means an object cache such as Redis can keep the "already checked" flag alive after the files change. That is one reason the cache has to be flushed as part of the cleanup.

🧬 Layer 2: a recovery block inside wp-config.php

If both mu-plugins disappear at once, the runtime still has a way back. During installation it inserted a block into wp-config.php just above the line that loads wp-settings.php. The block defines a random 64-character key, the SHA-256 of the runtime and the name of an option that holds a copy of it. It only acts when a request carries that exact key:

/* BEGIN WPOC RECOVERY 3.5.15 */
define("WPOC_RECOVERY_KEY", "<64 hex chars>");
define("WPOC_RECOVERY_SHA256", "<sha256 of runtime>");
define("WPOC_RECOVERY_OPTION_NAME", "_wpoc_recovery_code_<sha256>");
if ($_SERVER["REQUEST_METHOD"] === "GET" && ($_GET["panel"] ?? "") === "1"
    && hash_equals(WPOC_RECOVERY_KEY, $_GET["cache_purge"] ?? "")) {
    // connect with mysqli using DB_HOST / DB_USER / DB_PASSWORD from this same file
    // SELECT option_value FROM <prefix>options WHERE option_name = WPOC_RECOVERY_OPTION_NAME
    // base64_decode, verify the SHA-256, write both mu-plugins, reply with JSON and exit
}
/* END WPOC RECOVERY */

A few details make this block hard to spot. The trigger parameter is called cache_purge, which looks normal in an access log. Without the key, the block does nothing at all, so the site behaves normally and a browser test shows no difference. It talks to MySQL with raw mysqli instead of WordPress functions, so it works even when WordPress itself is broken. Finally, it refuses to write anything unless the SHA-256 of the stored copy matches, so a defender cannot neutralise it by corrupting the option.

A file integrity check of WordPress core will not flag this, because wp-config.php is not part of core. You have to read the file.

🗄️ Layer 3: the database holds the spare copies

Both families kept their real payload in wp_options. The first stored two full base64 copies of its runtime (about 220 KB each), along with an API key, pairing secrets and an activation log, all under a _wpoc prefix. The second family used a wss_ prefix and stored much more:

  • the PHP code of a "bridge" it injects into the active theme's functions.php,
  • a "keeper" that restores that bridge and the plugin if they are deleted,
  • a site "console" file written to the web root,
  • a base64 ZIP of its plugin, ready to reinstall,
  • the remote panel URL, panel and network keys, and a 200-entry activity log,
  • a list of administrator logins with an Application Password key for each, kept for sending to the panel.

This is what made earlier cleanups fail. Deleting the plugin, cleaning functions.php and removing the mu-plugins left all of these rows in place, and any one of them was enough to rebuild the rest. A quick way to see this kind of store is to look for large options and for prefixes you do not recognise:

SELECT option_id, option_name, CHAR_LENGTH(option_value) AS len, autoload
FROM wp_options
WHERE option_name LIKE 'wss\_%' OR option_name LIKE '\_wpoc%'
   OR option_value LIKE '%<?php%' OR option_value LIKE 'UEsDB%'
ORDER BY len DESC;

UEsDB is the start of a base64-encoded ZIP file. It is a useful string to search for, because legitimate plugins rarely store whole archives in an option.

🔑 Layer 4: Application Passwords as a remote door

Application Passwords are a core WordPress feature for API clients. Each one lets an external tool call the REST API as that user, using HTTP Basic auth. They are separate from the user's normal password, and changing the login password does not remove them. Many login protections, such as CAPTCHA and login limits, are built around the login form and do not cover them.

The second family used this feature as its way back in. Twelve Application Passwords, all with the same name as the remote panel, were created on twelve administrator accounts within one second. With those, the panel could reinstall the plugin and rewrite the theme through the normal REST API, using valid credentials. Nothing about those requests looked like an exploit.

You can list them straight from the database. The value is a serialized array; the name, creation time and last-used time are all in it:

SELECT um.user_id, u.user_login, CHAR_LENGTH(um.meta_value) AS len, um.meta_value
FROM wp_usermeta um JOIN wp_users u ON u.ID = um.user_id
WHERE um.meta_key = '_application_passwords';

Or, as the site owner (not as root):

for id in $(wp user list --role=administrator --field=ID); do
  echo "== user $id"; wp user application-password list "$id" --fields=uuid,name,created,last_used
done

Look for many passwords with the same name, created at the same moment, on accounts whose owners never set up an API client. Removing them is a decision for the account owners, and it should go together with a review of every administrator account.

👻 What the runtime actually did

Once loaded, the runtime registered its own REST namespace with routes for a handshake, a ping, a sync and full create/read/update/delete of "blocks" and "data". Every route except the handshake checked an X-API-Key header against an option:

public function auth($r){
    $k = $r->get_header("X-API-Key") ?: $r->get_param("api_key");
    $s = get_option("_wpoc_ak", "");
    return $s !== "" && $k !== "" && hash_equals($s, $k);
}

Through these routes the operator could inject link blocks into page output with an output buffer, edit posts and options, and upload images. Visitors and crawlers saw the links; logged-in admins did not, because the buffer skips the dashboard, AJAX and REST requests. The runtime also added a meta query that hid posts flagged with _asm_hidden from archive listings, so planted pages stayed reachable by URL but never showed up in lists. On a headless site the front end is built by a separate app, so most of the visible spam never appeared there. The backdoor and the remote access stayed fully active.

⏰ Layer 5: cron events that outlive the code

After every file and every malicious option was gone, the WordPress cron schedule still held four events from the second family: three auto-update events and one daily sync. Their arguments contained the remote panel URL and key. With the code gone, nothing handled those hooks, so they did nothing. But if any copy of the plugin came back, it would find its schedule waiting and its panel address already set. A leftover like this is also a clear marker that the site was infected. Check for it:

wp cron event list --fields=hook,next_run_relative,recurrence | grep -iE 'wss_|wpoc'
wp option get cron --format=json | grep -oE '"wss_[a-z_]+"|https?://[^"]+' | sort | uniq -c

To remove such hooks safely, unschedule them by name with wp_unschedule_hook() or wp cron event delete, then list the events again to confirm they are gone. Do not edit the serialized cron option by hand.

🛠️ Cleaning it in the right order

Because every layer can rebuild every other layer, the order matters, and so does doing it all at once:

  1. Back up first. Take a full database dump and separate dumps of every row you are about to delete. Copy every file you will remove to a private folder outside the web root. Check that the backups are not empty.
  2. Remove the recovery block from wp-config.php. Remove only the lines between the BEGIN and END markers, then run php -l wp-config.php before moving on.
  3. In the same run, delete both mu-plugins, the root loader stub, the fake "real" object-cache file and the lock file. If the files are owned by the web server user and your shell user cannot delete them, the deletion has to run as that user.
  4. Delete the malicious options: both _wpoc* runtime copies and all their siblings, and every wss_* row. Remove the whole family, not only the rows that contain code.
  5. Unschedule the cron hooks by name and list the events again to confirm.
  6. Flush every cache layer: the object cache (Redis), any page cache and any reverse proxy such as Varnish or a CDN.
  7. Check that the theme and plugin list are clean: no bridge block in functions.php and no rogue plugin folder.
  8. Re-check after real traffic. Let the site take some requests, then confirm no file has reappeared, no option has come back and the REST namespaces return 404.

Application Passwords and administrator accounts are the site owner's decision. List them, show the owner which ones were created by the attacker, and let the owner remove them.

🔍 Detection checklist

# 1. the mu-plugin pair and its helpers
ls -la wp-content/mu-plugins/ wp-content/_object-cache-real.php class-wp-locale-compat.php 2>&1
grep -rlE "WPOC (RECOVERY|Runtime)|_wpoc_(agent_code|recovery_code)|wpoc/v1|WSS-(KEEPER|PANEL-BRIDGE|CONSOLE)" . --include="*.php"

# 2. wp-config.php: anything between the table prefix and wp-settings.php?
grep -nE "BEGIN WPOC|cache_purge|mysqli_real_connect|require_once.*wp-settings" wp-config.php

# 3. tiny PHP files in the web root that are not part of core
find . -maxdepth 1 -name "*.php" -size -1k -printf '%s %p\n' | sort -n

# 4. REST namespaces that should not exist
wp eval 'print_r(array_keys(rest_get_server()->get_namespaces()));' | grep -iE 'wpoc|wss'

Run them as the site's own user, and run the database queries from the sections above as well. Also run a WordPress core checksum check, but remember it does not cover wp-config.php, mu-plugins or the database.

💡 Takeaways

  • Two copies of the same file are not a mistake; they are a guard pair. Remove them together.
  • wp-config.php is the most trusted file on a WordPress site, and core checksum tools do not check it. Read it line by line.
  • When malware keeps coming back, the copy is usually in the database. Remove the whole option family, not only the rows that contain code.
  • Application Passwords are a real way back in. They survive password changes and pass through most login protections.
  • Cron events can outlive the code they belong to. Check the schedule as the last step of every cleanup.

❓ Frequently asked questions

Why did the mu-plugins come back after they were deleted?

Each of the two mu-plugins checked the other on every request and rewrote it if it was missing. If both were deleted, a recovery block in wp-config.php could rebuild them from a base64 copy stored in the options table.

Does changing the admin password remove the attacker's access?

No. WordPress Application Passwords are separate credentials. They keep working after the login password changes and must be reviewed and revoked on their own.

Why did core file checks say WordPress was clean?

Core checksum tools only compare the files that ship with WordPress. wp-config.php, mu-plugins and the database are not covered, and that is where this malware lived.

Why do leftover cron events matter if the code is gone?

They carried the remote panel address and key. If any copy of the plugin returned, it would find its schedule and settings waiting. They are also a clear sign the site was infected.