latest event 55m ago · pipeline live
← Blog
Malware Analysis

Following the Wallets: How ErrTraffic Rotates Its Infrastructure

Shakil Hossain · 13 Aug 2026

The operators rotate delivery domains by writing one transaction to a smart contract. That transaction is public, permanent and attacker-authored — and following it back to the deploying wallets turned a two-contract incident into a 123-contract inventory with 36 live delivery domains. Part 3 of 3.

The first two posts covered a fake CAPTCHA on a hacked Bangladeshi website and the two malware bundles it delivered. This one is about the part that will still be useful in three months, when every domain in the first two posts is dead.

The operators rotate delivery infrastructure by writing a single transaction to a smart contract. That is the bad news. The good news is that the transaction is public, permanent, timestamped, and written by the attacker — which makes it the best source of provenance in the entire incident. It also turned out to be the thread that unravelled the whole thing.

How the rotation actually works

There is no DGA, no fast flux, no fallback list baked into the malware. When the operators want a new delivery domain, they call updateDomain(string) on their contract. The next visitor to any compromised site carrying that contract gets the new domain. The website is never touched.

We decoded every one of those calls from raw calldata, on both contracts, for their entire lifetimes.

Timeline of all 42 domain-rotation writes across both contracts, June to August 2026

Forty-two rotations. Chain A ran eight over sixteen days and has been silent since 28 July. Chain B ran thirty-four over sixty-six days and was still rotating while we were writing this — the domain it published on 11 August appears in no public reporting anywhere.

The cadence is almost identical: a median gap of 41.3 hours for chain A and 43.7 hours for chain B. Roughly every other day, for both. That similarity is more interesting than it looks, because in every other respect these two campaigns behave differently — which points at a shared playbook or a tooling default rather than two operators independently landing on the same habit.

The rotation is cosmetic

Here is the thing that changes what you should actually block.

verif-key-code[.]info was written on-chain at 02:01 UTC on 11 August. By the time we resolved it, it pointed at 178[.]16[.]52[.]101 — the same server as the domain it replaced. Chain A does the same: cloudeccentric[.]cc and errakgrwenmjibedi[.]cc, sixteen days and four rotations apart, both resolve to 87[.]58[.]206[.]124.

Two chain A delivery domains, four rotations apart, co-hosted on the same server

Every rotated-away domain we tested has had its A record removed. So the operators are burning names, not servers.

The contract is the best provenance source. The IP is the best control. Blocking 178[.]16[.]52[.]101 would have covered at least eight consecutive chain B delivery domains.

One caveat on that, and it matters: 178[.]16[.]52[.]101 is a repurposed shared-hosting box carrying unrelated tenants — an FTP server, mail services, a database, an old RDP host. Alert on it, check what else you serve from it, then block. 87[.]58[.]206[.]124 shows none of that; it has seven open ports, all first seen in July 2026, and looks purpose-built.

Certificate transparency gets you there first

One more source is worth wiring up, because it fires before the domain is used rather than after.

The Let's Encrypt certificate for the chain A delivery domain, issued nine days before the lure was captured

The chain A delivery host's certificate was issued at 18:12 UTC on 28 July — nine days before we captured the lure, and on the same day the contract published that domain. Single SAN, no wildcard, standard 90-day Let's Encrypt lifetime.

That issuance is a hard, publicly searchable timestamp in the certificate transparency logs. Given the naming pattern below is combinatorial and fairly distinctive, CT monitoring is a cheap early-warning source for this cluster: you get the domain days before the campaign uses it, which is the only point in this whole chain where a defender is ahead rather than behind.

Every domain, both contracts

Complete lists, straight from the write history. Nothing sampled, nothing truncated.

Chain A — 0xfD75a8F503be4851E11a52b645De1f017492A6b0, all .cc:

  • errakgrwenmjibedi[.]cc
  • errjothcxmklspp[.]cc
  • clouderrtufjcb[.]cc
  • clouderfaster[.]cc
  • auth-clo-id[.]cc
  • cloudeccentric[.]cc

Chain B — 0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2, in publication order:

  • ethercdnns[.]beer
  • framework-css-styles-js[.]beer
  • verification-js-cdn[.]boats
  • svs-verificationdate[.]beer
  • verification-code-js[.]beer
  • code-verification-js[.]beer
  • codecerification[.]beer
  • idverification-code[.]beer
  • claudverification-id[.]beer
  • code[.]verification-claude-cdn[.]beer
  • verificationscodes[.]beer
  • idverification-cdn[.]info
  • codeverificatrorcl[.]info
  • authorization-code[.]info
  • authorization-code[.]beer
  • authorization-id-code[.]info
  • authorization-cdn-press-enter[.]info
  • authorization-press-enter[.]info
  • auth-code-check[.]info
  • auth-code-verif[.]beer
  • auth-id-browser[.]info
  • authorization-id-browser[.]info
  • authorization-code-cdn[.]info
  • enter-code-cdn[.]info
  • enter-press-cdn[.]info
  • enter-pverif-code[.]info
  • id-verif-code[.]info
  • verif-key-code[.]info

Plus xdavnode[.]pro, which the contract returned from deployment until the first rotation. That one is not an affiliate choice — it is the template's default seed value, and a sibling contract deployed five minutes later still returns it.

Two things fall out of that list.

The naming is combinatorial — roughly {auth, authorization, enter, id} × {code, verif, press, browser} × {cdn, check, js}. Sorting candidate domains by name similarity will pull in unrelated squatters and miss renamed siblings. Sort by registrar, nameserver and creation date instead.

And two of them are worth a second look: claudverification-id[.]beer and code[.]verification-claude-cdn[.]beer, registered in late June. Claude-branded lure domains, consistent with the AI-platform impersonation reported for other campaigns on this framework.

Following the wallets

Both contracts had a creator address. Expanding those is nearly free — contract addresses are deterministic, a keyless block explorer serves full transaction lists, and public RPC nodes answer eth_getCode. So we did, expecting to find two affiliates.

We found something else.

The two-tier structure: a vendor wallet deploying and operating 123 contracts, versus a single self-hosted customer wallet

Chain B's "creator" has deployed 183 contracts, 123 of them on the identical dead-drop template, continuously since February 2025 — fifty-one of them in October 2025 alone. It is not an affiliate. It is the framework's own deployer and operator.

Four independent lines support that, and none of them depends on the others:

  • Scale and continuity. 123 contracts on one template over sixteen months.
  • Its developer test deployments are still sitting on-chain. Twelve contracts return https://example.com/initial. Others return example.com/gate.php, 123, 0, 888, a bare backtick, and one hex-encoded all:ping:url:http://localhost:3001/. Affiliates do not leave localhost debug values in production dead drops. The party building the kit does.
  • It operates what it deploys. Across a twelve-contract sample spanning the full deployment window, 117 of 117 rotation writes came from this one wallet. Not one sampled contract was ever written to by anyone else.
  • Its live domains match the published cluster profile. Of the 36 contracts currently returning a real delivery domain:
    .beer ×14
    .click ×5
    .cfd ×4
    .sbs ×4
    .world ×3

Chain A's creator is the opposite in every respect: a wallet created for the purpose, deploying at nonce 0, ten transactions in its entire life, funded with 303 POL seven minutes before it deployed, rotating its own domains.

So these two campaigns are not peers. Chain B is one of roughly 123 campaigns run directly from the vendor's wallet. Chain A is a customer running the same kit themselves.

That also explains a detail we initially misread. The two contracts were compiled with different Solidity versions and have different runtime sizes, which looked like evidence of separate authorship — until the vendor wallet's own 123 contracts turned out to span four compiler versions, and chain A's runtime bytecode turned out to be byte-identical to 74 of them. It is a build-generation artefact and carries no attribution weight. The general lesson: check the base rate before treating a difference as distinctive.

What that single pivot bought

A two-contract incident became a 123-contract inventory with 36 live delivery domains, recovered by calling getDomain() on each one. It cost nothing — no API key, no paid platform.

It also surfaced something that should worry anyone writing detections for this: a newer dead-drop format is already deployed. Eleven of the vendor's contracts return hex-encoded KEY:base64 blobs and ten return OpenSSL Salted__ AES ciphertext instead of a cleartext hostname. Any detection that greps an eth_call response for a plaintext domain will not see those at all.

The generalisation is the obvious next step and we have not done it yet: every contract on this template carries the same four function selectors, and Solidity emits each as a literal PUSH4 in the runtime bytecode. Sweeping Polygon for the quartet b68d1809 + b249cd2d + f851a440 + e096a091 should return every affiliate contract in the cluster, including ones deployed from wallets nobody has seen. Each hit yields a creator wallet and a complete domain timeline.

One of those four selectors is still unidentified. 0xe096a091 is present in both contracts and was called by nobody across 42 transactions. In a dead-drop resolver, a function nobody invokes is exactly where a second domain slot, a fallback, or a kill switch would live. If it returns a second string, detection keyed on getDomain() alone has a blind spot. It did not brute-force against the obvious candidate names.

Against what has already been published

This framework is not new. Sekoia documented it in June 2026 as ErrTraffic, a ClickFix distribution service sold on a Russian-language forum, with a cluster that uses Polygon contracts as dead-drop resolvers. LevelBlue followed. The attribution is theirs and we have not independently verified it.

What we can compare is indicators, and this is where it gets uncomfortable.

Six published attributes compared against what was observed in this incident: three match, three diverge

Three of six attributes still hold. The hosting ASN matches. The payload family matches. The one-contract-per-campaign model holds, though our wallet pivot refines it substantially — the contracts are per-campaign, but the wallet behind 123 of them is shared, and affiliates on the managed tier appear never to touch the blockchain at all. That is exactly what a subscription sold to non-technical buyers looks like, and it explains how a customer operates an EtherHiding dead drop without ever holding cryptocurrency.

The other three diverge, and they are the three that detection rules are built from:

  • The contract selector. Published reporting documents getURL() = 0x38bcdc1c. This activity uses getDomain() = 0xb68d1809, and 0x38bcdc1c is in neither contract's byte-code. Different template generation.
  • The panel URI. Published: /cf.js and /api/index.php?a=ctx…. Observed: /api.php?s=<48-hex>&_v=<minute>. Neither published pattern appears anywhere in this traffic.
  • The TLD set. The cluster is characterized by .beer, .click and .monster. Chain A is entirely .cc and never used .beer at all; chain B had moved to .info. Triage that gates on .beer misses half of this incident.

If you have ClickFix detection content built on the June 2026 indicators, it is giving you false assurance rather than coverage. That is the single most actionable finding in this series.

Worth noting how the drift happened: chain B migrated off .beer on 29 June, weeks after .beer was publicly named as the cluster's primary TLD. That reads like a response to being reported — but it is an inference, not something we can establish, and a routine rotation would look the same.

There is one closer match. A public teardown from 13 July 2026 describes the identical URI grammar, token length for token length, using the same contract and the same selector, and the same downloader shape — hidden PowerShell window, a sleep, fetch a 7-Zip binary and a password-protected archive, extract, launch, confirm. The differences are exactly what you would expect from an affiliate rotation: a different archive password, and a 20-second sleep against our 18.

But the payload bundle in that July chain is nothing like ours. It delivered a signed engineering-software updater with side-loaded DLLs and a different persistence mechanism entirely. Ours delivered an IExpress package wrapping a signed AutoIt interpreter.

That cuts two ways, and both are worth stating. Do not hunt the July host-side indicators on the strength of this incident — they are not applicable and would generate noise and false confidence. But it strengthens the framework attribution rather than weakening it: two campaigns sharing the delivery grammar, the contract mechanism and the archive handling while sharing nothing whatsoever in the payload is precisely the signature of rented infrastructure. It is the same thing we saw inside this single incident, where two contracts on one page delivered two unrelated bundles.

Anchor on the delivery layer, not the payload. The grammar was stable across affiliates and across a month. The payload was not stable across two contracts on the same page on the same day.

The whole campaign, mapped

Delivery layer and both payload chains in one view, colored by which part of the campaign each technique belongs to.

MITRE ATT&CK mapping across the delivery layer and both payload chains

A note on two entries. Bring-your-own-interpreter — a legitimately signed AutoIt interpreter running an attacker's script — has no ATT&CK technique that fits cleanly, so it is recorded as an uncatalogued observation rather than forced into an approximate ID. And chain A's persistence entry is a scoped negative: a full read of the dropper and the de-obfuscated loader found no mechanism. That is a finding. It is not the same as not having looked, and it is not evidence the host was clean.

What to block, and for how long

Most indicator lists are flat. This one should not be, because the difference between the top and the bottom of it is roughly two months of useful life.

Campaign indicators ranked by how much work it would take the operator to invalidate each one

The ordering is by cost-to-the-operator, not by confidence. The deployer wallet sits at the top because everything else is downstream of it: it enumerates 123 contracts, and those enumerate 36 live delivery domains, and it keeps producing more. A single delivery domain sits at the bottom because its median life is about 42 hours and DNS for the retired name is torn down within hours of rotation.

Indicators — infrastructure and blockchain

Defanged. Complete, nothing truncated.

Wallets

  • 0xCaf2C54E400437da717cF215181B170F65187aBf — framework deployer and operator. 183 contracts, 123 on the template, 851 transactions since 2024-10-17. The highest-value indicator in this series.
  • 0x2a51c1eD5C96882beE51f06Ec08c1504B9921Ec2 — chain A creator, nonce-0 throwaway
  • 0x6976d8025229Db5F54Bc899DD3Dbc9285423896B — funded chain A's creator with 303.27 POL seven minutes before deployment. ~1,015,115 POL across 13,090 transactions and no contracts deployed — assessed as an exchange or payment-service hot wallet, not an operator

Contracts

  • 0xfD75a8F503be4851E11a52b645De1f017492A6b0 — chain A
  • 0xB6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2 — chain B
  • 0x58001f06484E347DF0FE0CA7B2BeC92cb015857D — template default-seed contract, never written to, still returns xdavnode[.]pro

Selectors

  • 0xb68d1809 = getDomain() — read by the malware on every page load
  • 0xb249cd2d = updateDomain(string) — the setter, on 42 of 42 writes
  • 0xf851a440 = admin() — present in both, never called
  • 0xe096a091 — unresolved, present in both, never called
  • b68d1809 + b249cd2d + f851a440 + e096a091 — the template fingerprint, and the basis for cluster-wide discovery

Hosts

  • 87[.]58[.]206[.]124 — chain A delivery. AS207043 Dedik Services, NL
  • 178[.]16[.]52[.]101 — chain B delivery. AS202412 Omegatech LTD, DE. Shared tenancy — verify before blocking
  • 89[.]34[.]90[.]45 — chain B StealC C2. AS207043 Dedik Services, DE
  • 151[.]243[.]113[.]94 — chain B Overlord RAT C2, port 5173. AS207043 Dedik Services, DE
  • 160[.]20[.]109[.]90 — chain A StealC C2. AS402492 HostCram LLC, TR

Three of the five sit on AS207043, spanning both campaigns.

RPC endpoints abused as the lookup channel — these are legitimate public infrastructure and blocking them breaks unrelated things. The behaviour is what is detectable, not the destination:

  • 1rpc[.]io/matic
  • polygon-bor-rpc[.]publicnode[.]com
  • polygon[.]gateway[.]tenderly[.]co
  • polygon-mainnet[.]public[.]blastapi[.]io
  • rpc-mainnet[.]matic[.]quiknode[.]pro
  • rpc[.]ankr[.]com/polygon
  • polygon[.]drpc[.]org
  • polygon-public[.]nodies[.]app

Plus, in the newer loader build:

  • polygon[.]therpc[.]io
  • polygon[.]rpc[.]subquery[.]network/public
  • polygon[.]rpc[.]hypersync[.]xyz
  • polygon[.]lava[.]build
  • polygon-mainnet[.]gateway[.]tatum[.]io
  • gateway[.]tenderly[.]co/public/polygon

A scoped negative worth recording: none of the five campaign IPs, and none of the ~34 campaign domains, appear anywhere in a commercial C2 feed snapshot of 79,421 records taken on 11 August. No record in that snapshot carried any of the three ASNs. This is live infrastructure that the feed does not track — a coverage gap, not evidence of absence.

Detection at the resolver

The most durable network rule available keys on the contract address in the eth_call request body, not on the selector. The selector is shared by every affiliate on this framework and dies the moment the kit ships a new template — which, given the newer encrypted dead-drop format above, it already has. The contract address is immutable and tells you which campaign, not merely that the framework is present.

alert http $HOME_NET any -> $EXTERNAL_NET any ( \
  msg:"MALWARE ErrTraffic EtherHiding - eth_call to affiliate contract 0xB6bC9e1D (campaign B)"; \
  flow:established,to_server; \
  http.method; content:"POST"; \
  http.request_body; content:"eth_call"; nocase; \
  content:"B6bC9e1D0b2fB96Ab7C47E04Cb0BE477410bC1f2"; nocase; distance:0; \
  classtype:trojan-activity; \
  sid:1000023; rev:1; )

The address is matched without its 0x prefix so the rule holds whether the client sends checksummed or lowercase hex. This form generalises directly: any contract carrying the four-selector quartet is the same template and is a one-line addition.

The caveat is real — these need visibility into the JSON-RPC request body, so they fire only on cleartext or TLS-inspected traffic. Where inspection is not available, the delivery-host and URI-grammar rules from the first post are the fallback. And as with everything in this series: untested. Validate before deploying.

What we would do next

In priority order, and the first one is free:

  1. Sweep Polygon for the selector quartet. It enumerates the cluster, including affiliates nobody has seen.
  2. Enumerate the victim sites. The injected loader carries the contract address in cleartext, so a web-content index queried for b6bc9e1d0b2fb96ab7c47e04cb0be477410bc1f2 should recover the rest of the victim set directly.
  3. Resolve 0xe096a091. If it returns a second domain, everyone's detection has a blind spot.
  4. Triage the co-hosted domains on the chain B delivery IP. Seven of seven named samples turned out to be consecutive contract-published delivery domains. Domains co-hosted there but not yet in the write history are candidate for pre-registered future rotations.
  5. Notify the site owners. They are victims. The injection may still be live, and they are the only party who can pull the server-side artefacts that would establish how it got there.

Closing note on method

Public RPC nodes disagree with each other. One pass reported 50 of 123 contracts as having no code; a second reported 73. All 123 have code. If you are querying chain state through public infrastructure, require agreement from two independent nodes before you believe an empty result — otherwise you will write a scoped negative that is simply wrong.

Chain and infrastructure findings verified against public chain state and commercial enrichment during the engagement; framework attribution to ErrTraffic is third-party reporting and is not independently verified here.

Following the Wallets: How ErrTraffic Rotates Its Infrastructure — Threat Intel BD