npm 源投毒研究系列(一):从包结构理解攻击面
内网渗透那块又搁置了,实在是事情有点多,等后面学校里的 ddl 都赶完再接上。
最近在负责这块的内容,以此篇记录一下最近的一些总结学习。
近期看来,npm 生态接连遭遇多轮供应链投毒事件,例如 chalk/debug、Shai-Hulud V1/V2、Mini Shai-Hulud 等案例,都暴露出 npm 依赖体系中的典型风险。与传统漏洞利用不同,这类攻击很多时候并不是依赖某个复杂的 0day,而是滥用了 npm 包本身就具备的正常机制,例如生命周期脚本、依赖解析、版本发布、包入口加载等。攻击者也可能通过伪装成正常包、接管维护者账号、发布恶意版本等方式,将恶意逻辑植入依赖链中,进一步实现安装阶段凭证窃取、依赖链扩散,甚至自传播蠕虫式感染。
要分析这些攻击,得先理解 npm 包本身是怎么组织的、安装时到底会发生什么。所以这一篇先把基础结构梳理一遍,关键节点会顺手点一下对应的攻击面在哪——具体手法、案例和研判思路留到下一篇展开。
1. npm 生态的基本组成
npm 生态里最核心的几个概念包括:
| 概念 | 简要说明 |
|---|---|
| npm CLI | 本地使用的 npm 命令行工具,例如 npm install、npm publish |
| npm registry | npm 包的集中存储和分发平台,默认是 registry.npmjs.org,有一些公司也会用自己内部的 |
| package | 一个可发布、可安装的 npm 包 |
| tarball | npm 包实际发布和下载的压缩包,通常是 .tgz 文件 |
package.json |
npm 包的核心描述文件 |
node_modules |
本地安装依赖后生成的目录 |
| lockfile | 依赖锁定文件,例如 package-lock.json、pnpm-lock.yaml、yarn.lock |
日常开发中,我们执行的通常只是 npm install <package-name> 这样的命令,他的流程通常为:从 registry 获取包元数据、版本解析、依赖树计算、tarball 下载、完整性校验、解压安装、执行生命周期脚本等。
后面要讲的攻击面,基本都嵌在这条流程的某一个环节里。
2. npm 包本质上是什么
但从这个包本身来看,其实就只是一个 .tgz 格式的压缩包,通过命令 npm pack lodash,会下载得到一个类似 lodash-4.17.21.tgz 这样的一个包,这个 tarball 包就是用户在执行 install 的时候真正会下载并处理的内容。
因此后面在想要通过静态分析识别恶意源的时候,就可以通过对这个包进行静态代码分析的方式。
一个 npm 包有的特点是:
- 最少只需要一个
package.json就可以构成合法的 npm 包 - 包的入口、依赖、脚本、发布内容基本都由
package.json控制 - 用户在安装下载 npm 的时候,是从对应的 npm registry 上拉取的 tarball,因此 tarball 的内容可能与 GitHub 仓库的内容不完全一致。因此就是仓库源码不一定等于最终发布包,在分析样本是否被投毒的时候需要关注这一点,还是得从 registry 上获取包
攻击面提示:仓库源码 ≠ 发布产物是整个供应链分析的基础假设。攻击者可能让 GitHub 仓库保持干净,但 tarball 里夹带恶意文件,所以分析时必须以 registry 拉到的 tarball 为准。
3. 一个 npm 包的常见目录结构
典型 npm 包可能长这样:
1 | package-name/ |
就像前面说的,一个 package.json 都能够构成一个合法的 npm 包,以上这些文件不是每个包都必须存在,比如:
- 工具库可能主要包含
index.js、lib/ - CLI 工具通常会有
bin/ - 前端组件库可能会有
src/、dist/ - 原生扩展包可能会包含
binding.gyp、.node文件 - TypeScript 项目可能会包含
tsconfig.json、src/、types/
但无论结构如何变化,package.json 基本都是分析 npm 包时最重要的入口。
4. package.json(npm 包的核心)
package.json 可以理解为 npm 包的说明书和控制中心。它不仅描述包的名称、版本、作者,也决定这个包如何被加载、如何安装、依赖哪些包、发布时包含哪些文件。
比如一个包长这样:
1 | { |
5. 基本元信息字段
常见基础字段包括:
| 字段 | 作用 |
|---|---|
name |
包名 |
version |
包版本 |
description |
包描述 |
author |
作者信息 |
license |
开源许可证 |
repository |
源码仓库地址 |
homepage |
项目主页 |
keywords |
npm 搜索关键词 |
其中最重要的是 name 和 version,因为在 npm 中,一个包的具体版本通常就由 name + version 一同唯一确定,比如:[email protected]、[email protected]。
5.1 包名与 scope
name 可以是两种形式:
- 普通包名:
lodash、axios、react - 带 scope 的包名:
@types/node、@babel/core、@anthropic-ai/sdk
scope 是以 @ 开头的命名空间,通常代表某个组织或个人。带 scope 的包默认是公开的(除非显式声明为 private),scope 本身也可以由企业注册并绑定到内部 registry。
5.2 版本信息
从近段时间的投毒事件来看,通常就不是一个包的全部有问题,而是几个版本出现异常。
例如分析的时候可以重点关注:
- 某个包是否突然发布新版本
- 新版本距离上一个版本间隔是否异常
- 版本发布者是否发生变化
- 新版本中是否新增了大量文件或依赖
- lockfile 中实际锁定的是哪个版本
攻击面提示:投毒包很少新建一个全新包名(因为没人装),更常见的路径是接管已有热门包发恶意新版本。所以”新版本时间戳 + 发布者变化 + 文件增量”这几个信号一起出现时,基本就是有问题的信号。
6. 入口字段
npm 包被项目引用时,需要知道从哪个文件开始加载。这个入口通常由 main 或 exports 字段控制。
这一块更详细的可以参考:https://juejin.cn/post/7621142437353226274
6.1 main:传统默认入口
main 是传统使用的入口字段,例如通常为:
1 | { |
在这种情况下,如果用户在项目中写到:
1 | const pkg = require("example-package") |
或者在部分场景下导入该包时,Node.js 会根据 main 字段找到对应文件。
如果没有声明 main,Node.js 通常会尝试加载包根目录下的 index.js。
6.2 module:面向打包工具的 ESM 入口
module 并不是 Node.js 的官方标准字段,而是社区和打包工具形成的约定,常用于指定 ES Module 格式的入口文件。例如:
1 | { |
其中 main 提供 CommonJS 的版本,module 提供 ES Module 版本。Webpack、Rollup、Vite 等打包工具默认优先读取 module 字段。
6.3 browser:浏览器环境入口
有的 npm 包既可以运行在 node.js 环境中,也可以运行在浏览器中,但 Node.js 和浏览器提供的 API 不一样,例如:
- Node.js 有
fs、path、http等模块 - 浏览器中没有这些 Node 内置模块,通常使用
fetch、XMLHttpRequest等 API
因此一些包会通过 browser 字段指定浏览器环境下的入口文件:
1 | { |
6.4 types/typings:TypeScript 类型入口
types 用来告诉 TypeScript 这个包的类型声明文件在哪:
1 | { |
如果一个 npm 包是用 JavaScript 写的,但想给 TypeScript 用户提供类型提示,就通常会提供 .d.ts 文件。types 和 typings 作用基本相同,但常见的是 types。
6.5 type:决定 .js 文件的模块类型
type 字段不是入口文件路径,而是模块系统开关。它决定 Node.js 如何理解当前包里的 .js 文件。
1 | { |
简单总结:
.cjs永远是 CommonJS,不管type怎么设.mjs永远是 ESModule,不管type怎么设.js的身份取决于type字段
6.6 exports:现在最常用的入口控制方法
exports 是现在更推荐使用的入口控制字段。它可以更精确地声明包对外暴露哪些入口,也可以区分不同模块系统的加载方式。例如:
1 | { |
以上表示,使用 import 时加载 ./dist/index.mjs,使用 require 时加载 ./dist/index.cjs。
exports 还可以控制子路径导出:
1 | { |
这样用户只能访问包显式暴露的入口,例如:
1 | import pkg from "your-package" |
而不能随意访问包内部其他文件。
攻击面提示:入口字段是恶意代码的直接落点——只要用户
require/import这个包,入口文件的顶层代码就会被执行。同一个包可能同时有main、module、browser、exports,不同加载方式进入的是不同文件,攻击者可能把恶意逻辑塞在不常被审计的入口里。
7. 命令行入口:bin
有的 npm 包不是普通的代码库,而是命令行工具,例如 eslint、webpack、ts-node 这类工具,安装以后可以直接在命令行中执行。这类能力就由 bin 字段控制,比如:
1 | { |
安装后,npm 会把这个命令链接到 node_modules/.bin/ 目录中。这样项目脚本就可以调用:
1 | npx example-cli |
或者在 package.json 的 scripts 中调用:
1 | { |
攻击面提示:除了
main,bin指向的脚本也是分析样本时要看的位置。恶意包可以通过bin注册与常见 CLI 同名的命令,覆盖node_modules/.bin/下的执行路径。
8. scripts 与生命周期脚本
这块可以参考更全面的:https://juejin.cn/post/7121605730309767175
scripts 是 package.json 中非常重要的字段,用来定义项目中的脚本命令。它既可以定义开发者手动执行的命令,也可以定义 npm 在特定阶段自动触发的生命周期脚本。比如一个常见的写法如下:
1 | { |
这些脚本可以通过 npm run 执行:
1 | npm run dev |
其中 test、start 这类命令属于 npm 内置简写,可以直接执行,其他自定义命令通常需要通过 npm run <script> 调用。
8.1 普通 scripts:开发者主动执行
普通 scripts 更像是项目里的快捷命令,开发者可以通过其来封装一些常见操作。
例如:
1 | { |
这样的话,比如执行 npm run build,就等同于执行了 tsc && vite build。
8.2 pre / post 前后置脚本
在英语的词缀里,pre- 就表示在 xxx 之前,post- 就表示在 xxx 之后。
比如:
1 | { |
当执行 npm run build 时,实际的执行顺序是:
1 | prebuild → build → postbuild |
8.3 生命周期脚本:npm 自动触发
生命周期脚本就不一定需要开发者主动运行,它们会在安装、打包、发布等特定 npm 操作中自动触发。
常见的一些生命周期脚本:
| 脚本 | 常见触发时机 | 正常用途 |
|---|---|---|
preinstall |
包安装前 | 安装前检查环境 |
install |
包安装过程中 | 编译原生模块、下载平台相关资源 |
postinstall |
包安装后 | 初始化、构建、生成文件 |
prepare |
本地安装、打包、发布前,以及安装 Git 依赖时 | 构建发布产物 |
prepack |
npm pack 或 npm publish 打包前 |
打包前准备文件 |
postpack |
tarball 生成后 | 打包后清理或记录 |
prepublishOnly |
npm publish 前 |
发布前检查、测试或构建 |
publish / postpublish |
发布完成后 | 发布后通知或清理 |
攻击面提示:生命周期脚本是供应链投毒里曝光率最高的攻击面。只要用户执行
npm install,攻击者就拿到一次以当前用户权限运行任意代码的机会,过程完全无感。近期 Shai-Hulud 系列的核心载荷就钩在postinstall上。pre/post install、prepare、prepublishOnly是分析样本时必看的几项,下一篇会专门拆这条路径。
9. 依赖字段
npm 的核心能力之一就是依赖管理。开发者不需要把所有代码都写在项目里,而是可以在 package.json 中声明依赖。
常见依赖字段包括:
| 字段 | 说明 |
|---|---|
dependencies |
生产环境依赖,项目正常运行需要 |
devDependencies |
开发环境依赖,例如测试、构建、格式化工具 |
peerDependencies |
同伴依赖,常用于插件类包 |
optionalDependencies |
可选依赖,安装失败通常不会导致整体安装失败 |
bundledDependencies |
发布包时一并打包进去的依赖 |
9.1 dependencies
这是最常见的依赖字段:
1 | { |
当用户安装当前项目时,npm 会自动安装此处声明的依赖包。
9.2 devDependencies
开发依赖主要用于开发、测试和构建:
1 | { |
通常不会直接进入生产运行环境,但会影响开发者本机和 CI/CD 构建环境。
9.3 peerDependencies
peerDependencies 常用于插件和框架生态。例如一个 React 插件可能要求宿主项目安装 React。
1 | { |
9.4 optionalDependencies
optionalDependencies 表示可选依赖。即使安装失败,npm 通常也不会让整个安装流程失败。
它常用于平台相关依赖,例如某些包只在 macOS、Linux 或 Windows 上需要特定组件。
9.5 bundledDependencies
bundledDependencies 会把指定依赖一起打包进最终发布的 tarball 中。
1 | { |
也就是说分析 npm 包时,不能只看 package.json 中列出的依赖关系,要看实际 tarball 里是否包含被打包进去的依赖代码。
攻击面提示:依赖字段是另一类常见的下毒位置——版本号小幅升级但新增了陌生依赖,几乎是有问题的信号。另外别因为名字里有 dev 就觉得
devDependencies安全,它一样会执行生命周期脚本,而且常跑在拥有源代码和 CI 凭证的环境里。
10. npm 的依赖链是什么
npm 的依赖链指的是一个项目从直接依赖到间接依赖逐层展开后形成的依赖关系网络。
比如:
1 | 你的项目 |
开发者在 package.json 中直接声明的依赖,只是第一层依赖。每个依赖包自己还可以继续声明依赖,最终形成多级依赖树。
这也是 npm 生态的特点之一:一个项目表面上可能只安装了几十个直接依赖,但实际落到 node_modules 中,可能会出现几百甚至上千个包。
因此,在做 npm 包分析或供应链排查时,需要区分:
| 类型 | 说明 |
|---|---|
| 直接依赖 | 当前项目在 package.json 中直接声明的依赖 |
| 间接依赖 | 由直接依赖继续引入的依赖 |
| 传递依赖 | 经过多层依赖关系传递进来的包 |
| 开发依赖 | 主要影响开发和构建过程 |
| 生产依赖 | 项目运行时需要的依赖 |
理解依赖链之后,再看 npm 供应链风险会更清楚。一个包的影响范围不只取决于它本身有多少用户,也取决于它是否被大量项目间接依赖。chalk、debug、color-name 这类底层工具包之所以被反复盯上,就是因为它们处在大量项目的依赖树深处,单个包被投毒就能扩散到几百万下游项目。
11. files 与 .npmignore:控制发布内容
npm 包发布时,并不一定会把项目目录里的所有文件都发布出去。发布内容主要受 files 字段和 .npmignore 文件影响。
11.1 files
files 是一个白名单,用来声明哪些文件或目录会被包含进最终发布包。
例如:
1 | { |
11.2 .npmignore
.npmignore 类似 .gitignore,用于排除不需要发布到 npm 的文件。
攻击面提示:
files和.npmignore的存在让 GitHub 仓库内容和 npm tarball 内容可以是两个不同的集合——前者用白名单加文件,后者用黑名单减文件,两个口子都可以让 tarball 偏离仓库视图。这也是为什么前面说”分析必须以 registry 拉到的 tarball 为准”。
12. lockfile:锁定依赖树
npm 项目通常会有 package-lock.json。它的作用是锁定依赖版本和依赖树结构。
一个简化示例如下:
1 | { |
其中几个字段:
| 字段 | 说明 |
|---|---|
version |
实际安装的版本 |
resolved |
tarball 下载地址 |
integrity |
完整性校验值 |
lockfile 的意义在于:
- 保证不同环境安装出来的依赖尽量一致
- 避免每次安装都重新解析出不同版本
- 记录实际下载的 tarball 地址
- 方便后续排查依赖版本变化
攻击面提示:
integrity只能证明下载内容与 lockfile 记录一致,并不等于证明这个包本身安全——一旦恶意版本被首次写入 lockfile,integrity 反而会保护它在后续每次安装精确还原。resolved字段指向的 registry 才是判断包真实来源的依据,是后面 dependency confusion 排查的关键证据。
13. node_modules 与缓存
执行 npm install 后,依赖会被安装到 node_modules 目录中。
node_modules 里可能包含:
- 当前项目的直接依赖
- 依赖继续引入的间接依赖
- 命令行工具链接(
node_modules/.bin/) - 包的源码或构建产物
- 生命周期脚本生成的文件
npm 还会在用户家目录维护一个全局缓存(通常在 ~/.npm/_cacache/),保存下载过的 tarball。这个缓存在事后排查时有时是关键来源——即使某个版本已被 npm 官方下架,本地缓存里可能还留着原始 tarball。
14. registry 与镜像源
npm 默认从官方 registry 下载包:https://registry.npmjs.org/
但很多开发者或企业会使用其他来源,常见的有几类:
- 公共镜像源:例如国内的淘宝镜像、cnpm 等,对官方 registry 做同步
- 企业内部 registry:用 Verdaccio、Nexus、Artifactory 等搭建,托管公司内部私有包
- 代理缓存型 registry:既做内部包托管,也代理上游公共 registry
需要理解的是,镜像源不是官方 registry 本身,而是对上游的同步或缓存,因此它可能存在同步延迟。这意味着:
- 某个版本在上游已经发布,但镜像源暂时还没有同步
- 某个版本在上游已经删除(npm unpublish),但镜像源或缓存中可能仍然存在
- 企业内部缓存可能保存了历史安装过的 tarball
因此,在分析 npm 包安装来源时,需要关注 .npmrc、lockfile 中的 resolved 字段,以及实际使用的 registry 配置。
攻击面提示:当一个项目同时使用了多个 registry(公共 + 私有),如果 scope-to-registry 的映射没有显式配置,攻击者就有机会在公共 npm 上抢注一个与公司内部包同名的恶意包——这就是 dependency confusion。下一篇会展开这个攻击的完整机制和排查方法。
15. dist-tags:版本标签
version 决定一个包的具体版本号,而 dist-tags 决定不同标签指向哪个版本。最常见的标签是 latest,也就是用户执行 npm install <pkg> 不指定版本时默认拉的那个版本。
可以通过命令查看:
1 | npm dist-tag ls lodash |
典型输出:
1 | latest: 4.17.21 |
维护者可以发布带不同 tag 的版本,例如:
1 | npm publish --tag beta |
也可以单独调整 tag 指向:
1 | npm dist-tag add [email protected] latest |
攻击面提示:dist-tag 在投毒事件里给攻击者额外的操控空间——可以发恶意版本但暂时不动
latest做小范围验证,也可以把latest短暂指过去拉一批安装后再悄悄改回来。所以排查时不能只看当前的latest,要把目标包的版本和 tag 变更历史都拉出来对照。
16. 维护者账号与 provenance
前面讲的所有攻击面,几乎都依赖一个前置条件:攻击者拥有这个包的发布权限。所以维护者账号本身才是 npm 供应链最根本的入口——历史上的大量事件,技术细节再花哨,源头通常都是某个账号被攻破、某个 token 被窃取,或者维护者本机被入侵。
npm 在 2023 年推出了 provenance(发布出处证明)机制,可以让通过受信 CI(目前主要是 GitHub Actions)发布的包附带一份签名的元数据,证明 tarball 是从某个仓库的某次 commit 通过某个 workflow 构建出来的。它没法防止仓库或 CI 凭证本身被攻破,但能让”账号接管后随手 npm publish 一个恶意版本”这类最常见的路径明显变难。
17. 小结
这一篇主要把 npm 包结构相关的概念梳理了一遍,作为后续分析的前置。串一下关键节点和对应的攻击面:
package.json是分析的入口,包名、版本、入口字段、bin、scripts、依赖字段都是要看的位置- 生命周期脚本(特别是
pre/post install、prepare)是曝光率最高的攻击面 - tarball ≠ GitHub 仓库,分析必须以 registry 拉到的 tarball 为准
- lockfile 的
resolved和integrity在事件取证里是关键证据,但integrity不等于安全 - registry 配置决定包从哪来,没显式做 scope-to-registry 映射就有 dependency confusion 风险
- dist-tag 变更历史和当前
latest同样重要 - 维护者账号是所有攻击面的最终前提
下一篇
下一篇会从这些攻击面切入,结合 chalk/debug、Shai-Hulud 这类近期事件的实际样本,把常见投毒手法(typosquatting、dependency confusion、生命周期脚本下毒、dist-tag 操纵、维护者账号接管等)和对应的研判思路一起拆一下——包括怎么拉 tarball、看哪些字段、用什么工具、什么样的特征组合可以下判断。
