- What: Analysis of GlassWorm v2, a macOS infostealer
- Impact: macOS users may be affected if they use compromised extensions
GlassWorm: macOS infostealer with decentralized C2 and a broken(?) kill switch Static analysis and live infrastructure monitoring of a GlassWorm variant distributed through compromised Cursor extension on Open VSX. This writeup covers the infection chain, persistence mechanism, C2 architecture, an "interesting" kill switch, and ongoing operator activity observed over 57 hours of monitoring. C2 communication was designed to be particularly resilent to takedowns. Background On January 30, 2026, four VS Code extensions published under the account "oorzc" received malicious updates. The extensions (ssh-tools, i18n-tools-plus, mind-map, scss-to-css-compile) had 22,000+ combined downloads. Socket.dev researcher Kirill Boychenko reported the compromise the next day ( GitHub Issue #25 ). The malicious code belongs to the GlassWorm campaign, previously documented by Koi Security, Truesec, and others. This variant shares infrastructure patterns with earlier waves but uses a different Solana wallet and introduces a TCP tunnel module not previously reported. Infection chain The npm package hooks preinstall.js , which contains Unicode variation selector steganography. Invisible characters from U+E0100-U+E01EF encode the payload bytes after a visible pipe character. The decoded blob is AES-256-CBC encrypted (key and IV hardcoded in the stego decoder). Decrypted output is the stage 1 loader. Stage 1 ( stage1_loader.js , 163 lines): Waits 10 seconds, runs _isRussianSystem() to exclude CIS locales, then queries Solana for the current C2 URL. Fetches a base64 payload from the C2, receives AES-256-CBC key and IV as HTTP response headers ( secretkey , ivbase64 ), decrypts, and evals. Subject to a 2-day cooldown ( init.json date check). The decrypted output is stage 3. The CIS exclusion requires a Russian-language locale AND either a matching timezone or UTC offset: // stage1_loader.js:132-161 function _isRussianSystem ( ) { let isRussianLanguage = [ os . userInfo ( ) . username , process . env . LANG , process . env . LANGUAGE , process . env . LC_ALL , Intl . DateTimeFormat ( ) . resolvedOptions ( ) . locale ] . some ( ( info ) => info && / r u _ R U | r u - R U | R u s s i a n | r u s s i a n / i . test ( info ) ) , timezoneInfo = [ Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone , new Date ( ) . toString ( ) ] , russianTimezones = [ "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" ] , isRussianTimezone = timezoneInfo . some ( ( info ) => info && russianTimezones . some ( ( tz ) => info . toLowerCase ( ) . includes ( tz . toLowerCase ( ) ) ) ) , utcOffset = - new Date ( ) . getTimezoneOffset ( ) / 60 , isRussianOffset = utcOffset >= 2 && utcOffset <= 12 ; return isRussianLanguage && ( isRussianTimezone || isRussianOffset ) ; } Stage 1 handles 20-char payloads differently from the RAT (it evals them): // stage1_loader.js:84-87 if ( uezupbxi ?. length == 20 ) { eval ( atob ( uezupbxi ) ) ; // kill switch works here. See below for more return ; } Stage 3 ( stage3_darwin_decrypted.pretty.js , ~2800 lines): Two parallel execution paths. Path 1, AppleScript stealer (~660 lines of embedded AppleScript): Attempts silent Chrome keychain extraction via security find-generic-password ; falls back to a fake system dialog ("Application wants to install helper") if that fails Browser data from 10 Chromium variants and Firefox (cookies, login data, autofill, extension storage) 150 hardcoded crypto wallet browser extension IDs MetaMask in Firefox: parses prefs.js for extension UUID, copies IndexedDB 17 desktop wallet data directories (Electrum, Exodus, Ledger Live, Trezor Suite, Bitcoin Core, Binance, TonKeeper, etc.) SSH keys with validation (checks for BEGIN PRIVATE KEY before exfiltrating) AWS credentials ( ~/.aws/ ) Apple Notes (all three SQLite files) Safari cookies, login keychain Documents under 10MB matching common office/archive extensions FortiVPN configuration If Ledger or Trezor are installed: kills the running app, downloads a trojanized version from the C2, strips quarantine flags twice, replaces the original Path 2, Node.js credential theft: GitHub tokens: tries git credential fill , VS Code extension storage, ~/.git-credentials , GITHUB_TOKEN env var. Validates against GitHub API. NPM tokens: npm config get , ~/.npmrc , NPM_TOKEN env var. Validates against npm registry. Russian-language error strings in this module ( "Токен не найден" , "Невалидный токен" ). Native keychain extractor: downloads a compiled .node addon from the C2 ( /env/<id> ). If the phished password grants sudo, runs the addon as root. We couldn't retrieve this binary (0 bytes at time of probing). Exfiltration goes to two separate servers: AppleScript data → POST 208[.]76[.]223[.]59/p2p Node.js data → POST 208[.]85[.]20[.]124/wall Both on Vultr, geolocated to Spain. Ca...