Executive Summary
The Paranoids Forensic and Incident Response Operations (FIRE) team recently dug into an Amatera stealer campaign delivered via a ClickFix social-engineering lure that mimicked the Anthropic Claude desktop download page. The lure was a near-perfect clone of claude[.]com/download, but it shipped something we don't see every day: the logic that handed victims their malicious "installation" command lived inside a compiled Rust WebAssembly (WASM) module instead of JavaScript.
While this infrastructure was part of a broader malware infection chain, the delivery mechanism was interesting enough to warrant a deeper dive, which is what we will focus on in this post. What looked like a single macOS lure turned out to be one face of a 15-campaign Malware-as-a-Service (MaaS) operation, with the C2 configuration tucked behind layers of obfuscation & AES-256-GCM encryption inside the WASM binary. We recovered all of it with nothing but free, open-source tooling.
This post is the short version of our investigation; the full technical analysis, extraction scripts, & IOCs are accessible on our GitHub.
Infection Chain
A malicious Google Search ad for "Claude Code" directed unsuspecting users to a threat-actor-controlled Squarespace page claude-cowork-desktop[.]squarespace[.]com. This page wiped its own DOM & overlaid a full-screen iframe of a Cloudflare Pages staging site — a SingleFile clone of the real Claude download page — pulling in three components alongside it:
modal.js: builds the fake "open Terminal & paste this command" pop-up
connector.js: the communication bridge to the WebAssembly module
connector_bg.wasm: the compiled Rust module holding the real logic
When a user clicked the "download" button, modal.js requested the command to display from the WASM module. There is a benign fallback baked into the HTML for appearance's sake, but the command that is actually served comes straight out of the WASM binary.
From there it's a textbook ClickFix flow: the page tells the victim to open Terminal & paste a "verification" command that's already been written to their clipboard. On the workstation we examined, that command was a single curl … | zsh one-liner that phoned a Cloudflare-fronted host & pulled down a gzip-compressed shell script (Stage 1). That script unpacked its embedded payload & fired off a second curl a moment later, dropping a Mach-O binary at /tmp/helper. Before running it, the dropper stripped the macOS Gatekeeper quarantine flag with xattr -c & marked the file executable with chmod +x — a quiet local-execution chain that never once prompts for a browser download.
To the victim, none of this is visible. The page looks exactly like Anthropic's and the instructions look exactly like a normal install step. To a defender armed with minimal DNS & process lineage telemetry, however, the infection chain is blatant.
Security Through Obscurity..?
Threat actors keep reaching for WebAssembly because it looks like a wall. As it is a binary format, it gets waved off as "too low-level" to bother with, & it barely shows up in web-focused triage in the first place — WASM only appears on a fraction of a percent of sites, so most analysts have never had a reason to read about it. The author of this sample leaned into this idea, hiding their C2 configuration behind an asynchronous WASM state machine, a pile of decoy data, a custom key schedule, & AES-256-GCM encryption.
In theory, the layering is clever:
- The entry point is an
async function, so control scatters into Rust/wasm-bindgen future machinery instead of running top-to-bottom.
- Dynamic dispatch through virtual tables hides the sequence of function calls behind runtime-computed indices rather than static calls.
- The encrypted payload is split into 30 Base64 fragments, but only 10 are valid, so a
strings dump of the binary would never produce the plaintext configuration.
- The encryption key is never stored anywhere; it is built at runtime.
None of this mattered. WebAssembly is a fully specified, openly documented format. All that obfuscation buys is time — it doesn't change the result. With nothing more than the wabt toolkit, a scratchpad for keeping track of ephemeral pointers, & a bit of patience, we peeled back each layer until we could compute the runtime decryption key ourselves.
Configuration Recovery
The module exports exactly one function worth caring about: get_commands(offer, platform). Getting from there to the payload took a few layers of deobfuscation. The full analysis can be accessed on our GitHub.
1. wabt Triage
wasm-objdump confirmed a Rust build & provided the export table, including that conveniently named get_commands function. The module's producers custom section even spells out its build tools: walrus & wasm-bindgen, at specific versions, which let us pin the compilation to a narrow window in time & anchor our reading to the matching WebAssembly spec revision. This is a small finding, but it meant every instruction we interpreted after could be compared to the exact spec the compiler was built against.
2. Decompile to Flat Text
wasm2wat turns the binary into the WebAssembly Text format (WAT) — a readable, greppable format of S-expressions. Two facts about WASM make it surprisingly readable once you're in there: it's a typed stack machine (every value's type is declared somewhere), & it has no pointer type — addresses are just integers, so whether an integer is a pointer comes down entirely to the context in which it is used.
3. Navigate the Asynchronous Calls
get_commands is asynchronous, which is where things get annoying. On the JavaScript side, the two input strings (offer & platform) are each passed as a pointer/length pair, so two strings show up as four integers crossing into WASM. From there, the entry point vanishes into wasm-bindgen future/Promise machinery: the inputs get boxed into a state machine, wrapped in a new Promise, & the function hands a pending promise back to JavaScript almost immediately.
The real work happens later, when that promise gets polled. Finding the decryption path on a successful poll meant chasing several vtable-driven indirect function calls. Each one reads a virtual-table pointer from the data section, loads a method index from it, & dispatches through the function table using call_indirect. This is the part that eats the most time, and it's exactly the part the obfuscation is banking on to lose you. Patiently following each instruction (plus a JavaScript microtask queue round trip) eventually drops us at the function that actually builds the config.
4. Deobfuscate
The payload is stashed as 30 Base64 fragments, of which 20 are decoys. The module determines which 10 to keep by XOR-ing two lookup tables to produce a set of indices, then concatenates those fragments into a single valid Base64 blob. Concatenating the 30 slices together in their stored order will only ever output garbage. That gap is the entire point of the decoy slices.
5. Reconstruct the Key
The Base64 blob doesn't decode to anything readable. It is AES-256-GCM ciphertext, & the 256-bit key is nowhere in the binary. Instead, it's rebuilt at runtime from a single static 8-byte seed.
Essentially, that logic looks like:
Python
perm = [(seed[j] * 5 + 3) & 7 for j in range(8)] # a permutation of 0..7
blocks = [key_material[p*16 : p*16+16] for p in range(8)] # 8 16-byte blocks
message = b"".join(
bytes(b ^ ((j * 55 - 98) & 0xFF) for b in blocks[perm[j]])
for j in range(8)
)
key = sha256(message).digest() # the 32-byte AES key
The seed produces a permutation that reorders the eight blocks of key material; each chosen block is XOR-masked with a per-block constant; the whole thing is then run through SHA-256. Since the seed is static, the key is completely deterministic, so we re-implemented the math in Python & regenerated it ourselves.
6. Decrypt All The Things!
The decoded blob is laid out as nonce (12 bytes) || ciphertext || tag (16 bytes). Feed it the derived key & AES-256-GCM decryption succeeds, with the authentication tag verifying — which is its own proof the key is right, since GCM throws on a wrong key instead of handing back plausible-looking junk. The result is plain, readable JSON.
The lesson from the crypto layer is worth saying twice: grepping the binary for a 32-byte key turns up nothing. The recipe (seed, tables, & algorithm) is all in the file; the finished key only ever exists in memory for the instant it's used. The only way through is to emulate the algorithm yourself, & because WASM is fully specified, that's entirely doable.
The decrypted config is a JSON list of C2 endpoints the loader rotates through, plus per-campaign installation commands. The hosts are short, throwaway-looking domains on cheap TLDs:
None
hxxps[://]hewh-dh[.]icu
hxxps[://]hnfsdhreh[.]top
hxxps[://]rhtwyu34[.]top
...
Pivoting on the shared structure showed this was no one-off. The same module backs 15 distinct campaign lures across AI tools, developer software, & crypto/prediction-market brands, on both macOS & Windows:
| Lure |
macOS |
Windows |
Target audience |
| claude | Yes | Yes | AI users (Anthropic Claude) |
| notebook | Yes | Yes | AI users (Google NotebookLM) |
| deepseek | Yes | Yes | AI users (DeepSeek) |
| manus | Yes | Yes | AI users (Manus AI agent) |
| pycharm | Yes | Yes | Developers (JetBrains PyCharm) |
| jetbrains | Yes | Yes | Developers (JetBrains suite) |
| cowork | Yes | Yes | AI users (Anthropic Claude) |
| polymarket | No | Yes | Crypto / prediction-market users |
| hidden | Yes | No | Unknown |
| sound | Yes | No | Unknown |
| storage | Yes | No | Unknown |
| usb | Yes | No | Unknown |
| dns | Yes | No | Unknown |
| frozen | Yes | No | Unknown |
| slow | Yes | No | Unknown |
Every campaign ships its own curl … | zsh (macOS) and/or mshta (Windows) command, all pointing back to the same infrastructure. The shared hosting, the identical key-derivation logic across every campaign, & the copy-paste build conventions all point squarely to a Malware-as-a-Service offering; the Claude "cowork" lure we observed is just one of many variants.
Indicators of Compromise (IOCs)
The network indicators are below; the full set — sample hashes and every campaign's ClickFix command — lives in the repo.
| Type |
Indicator |
| Staging (Squarespace) | claude-cowork-desktop[.]squarespace[.]com |
| Staging (Cloudflare Pages) | new-csopcx4p6l-cw[.]pages[.]dev |
| Delivery host | famiode[.]com |
| C2 endpoint | hrb-hrjd-dn[.]icu |
| C2 endpoint | gbmq-mag-1b3l[.]icu |
| C2 endpoint | nbt-sngq-ebn-5[.]icu |
| C2 endpoint | nga-dge[.]icu |
| C2 endpoint | hewh-dh[.]icu |
| C2 endpoint | hw-dsgqeh-f[.]icu |
| C2 endpoint | ngdjwg-09-113[.]icu |
| C2 endpoint | j26hrkl-268yuh[.]icu |
| C2 endpoint | ngdjk-628yuh[.]icu |
| C2 endpoint | bn-3nt-26t[.]icu |
| C2 endpoint | fregherwqewr5[.]top |
| C2 endpoint | hnfsdhreh[.]top |
| C2 endpoint | ngsfjaeru2[.]top |
| C2 endpoint | otyuyre3[.]top |
| C2 endpoint | rhtwyu34[.]top |
| C2 endpoint | sdfsdfsdfs[.]top |
Conclusion
This post is just the summary of our findings. The full write-up walks the WASM module function by function & includes everything you'd need to reproduce the work yourself:
- Most of the reverse-engineering workflow (glossing over a few repetitive function calls), with memory offsets & indirect calls explained.
- The full key-derivation math & the static AES-256-GCM key.
- Python scripts to deobfuscate the loader & decrypt the payload.
- All IOCs observed: every C2 domain, every campaign's ClickFix command, & hashes of the samples we examined.
In summary, our main takeaways to share are:
- WebAssembly is not a black box. It's a documented, tool-supported format, &
wabt (wasm-objdump, wasm2wat) is enough to read it. If a .wasm file is exporting something like get_commands, that's worth a look, not a shrug.
- "Compiled" is not synonymous with "encrypted." Decoys, custom key schedules, & async indirection drive up the cost of analysis, but they don't change the result — anything the program can compute at runtime, we can rebuild statically.
- Feed your curiosity. Tech stacks evolve daily, requiring defenders to at least know a little bit of everything, but it is not realistic to assume we can know everything. Sink your teeth into something unknown and/or interesting. Share the knowledge.
References