最近,曾经在 2019 - 2022
年担任 npm
研发经理的 Darcy Clarke
公开吐槽了 npm 生态系统的安全性,称其一直具有巨大的安全隐患。
简单来讲:一个 npm
包的 manifest
是独立于其 tarball
发布的,manifest
不会完全根据 tarball
的内容进行验证,生态系统普遍会默认认为 manifest
和 tarball
的内容是一致的。任何使用公共注册表的工具都很容易受到劫持。恶意攻击者可以隐藏恶意软件和脚本,把自己隐藏在在直接或间接依赖项中。在新型的供应链攻击方面,这是一个非常重大问题,大家可以将其称为 “清单混淆”。
在 Node.js
生态系统发展成现在这个模样之前,也就是全球数以千万计的开发者每月下载超过 3.1
亿个 npm
包的时期,能够对你所信任的软件库做出贡献的人非常少。比较小的社区能够带来更多信任,而在 npm
注册表的开发过程中,大多数方面都是开源的并且可以自由贡献和检查代码。但是,随着生态系统的发展,从这个库中消费的组织的政策和实践也在随之发展。
从一开始,npm
项目就非常信任注册表的客户端和服务器。现在回想起来,很明显,如此严重依赖客户端来处理数据验证的做法是充满问题的,但这个策略也使得 JavaScript
工具生态系统得到了飞速的增长。
npm
公共注册表不会通过包 tarball
的内容来验证 manifest
信息,而是依赖于 npm
兼容客户端来解释和执行一致性验证。实际上,当我研究这个问题时,服务器也从来没有进行过这种验证。
现在,用户可以通过向对应的包 URI(例如 https://registry.npmjs.com/-/<package-name>
)向 registry.npmjs.com
发送 PUT
请求来发布 npm
包。此端点会接受一个请求主体,结构类似于以下内容:
{
_id: <pkg>,
name: <pkg>,
'dist-tags': { ... },
versions: {
'<version>': {
_id: '<pkg>@<version>`,
name: '<pkg>',
version: '<version>',
dist: {
integrity: '<tarball-sha512-hash>',
shasum: '<tarball-sha1-hash>',
tarball: ''
}
...
}
},
_attachments: {
0: {
content_type: 'application/octet-stream',
data: '<tarball-base64-string>',
length: '<tarball-length>'
}
}
}
这样做的问题在于,包版本的元数据(即 manifest
数据)是独立于的 tarball
提交的,而后者包含了软件包的 package.json
。这两个信息不会相互验证,这引发了一个问题:我们不确定哪个是真实数据的规范来源,例如依赖关系、脚本、许可证等等。据我所知,tarball
是唯一被签名并具有可在离线环境中存储和验证的完整性值的文件(因此有可能成为正确的数据来源)。然而令人惊讶的是,package.json
中的名称和版本字段实际上可能与 manifest
中的字段不同,因为它们是从未经过验证的。
npmjs.com
上生成一个授权令牌(例如 https://www.npmjs.com/settings/<your-username>/tokens/new
- 为了方便起见,选择 “Automation
”)mkdir test && cd test/ && npm init -y
)npm install ssri libnpmpack npm-registry-fetch
)mkdir pkg && cd pkg/ && npm init -y
)publish.js
文件,内容类似于以下内容:;(async () => {
// libs
const ssri = require('ssri')
const pack = require('libnpmpack')
const fetch = require('npm-registry-fetch')
// pack tarball & generate ingetrity
const tarball = await pack('./pkg/')
const integrity = ssri.fromData(tarball, {
algorithms: [...new Set(['sha1', 'sha512'])],
})
// craft manifest
const name = '<pkg name>'
const version = '<pkg version>'
const manifest = {
_id: name,
name: name,
'dist-tags': {
latest: version,
},
versions: {
[version]: {
_id: `${name}@${version}`,
name,
version,
dist: {
integrity: integrity.sha512[0].toString(),
shasum: integrity.sha1[0].hexDigest(),
tarball: '',
},
scripts: {},
dependencies: {},
},
},
_attachments: {
0: {
content_type: 'application/octet-stream',
data: tarball.toString('base64'),
length: tarball.length,
},
},
}
// publish via PUT
fetch(name, {
'//registry.npmjs.org/:_authToken': '<auth token>',
method: 'PUT',
body: manifest,
})
})()
manifest
文件(例如,我在上面删除了 scripts & dependencies
)node publish.js
)https://registry.npmjs.com/<pkg>/
查看有什么差异在这个示例中,包和 package.json
的内容就是没办法对应上的。
如果你想要一种更简单的方法来复现这种不一致的问题,可以使用 CLI
,因为在 npm publish
的过程中,当你的项目中存在 binding.gyp
文件时,它会就会修改 manifest
。这种行为似乎已经存在于客户端里很久了(即 < 6.x
或更早的版本),并导致了很多消费者的错误和混淆。
npm init -y
touch binding.gyp
npm publish
"node-gyp rebuild
" scripts.install
已自动添加到 manifest
中了,但实际的 tarball package.json
却没有(例如 https://registry.npmjs.com/darcyclarke-binding
和 https://unpkg.com/darcyclarke-binding@1.0.0/package.json
)
在现实中对于这种受害者的例子也有很多,比如 node-canvas
:
这种安全隐患实际上可能会通过多种方式影响消费者或最终用户:
这个问题也会以各种方式影响所有已知的主要 JavaScript
包管理器,详细情况如下所述。像 jFrog
的 Artifacory
这样的第三方注册表实现似乎也复制了这个 API
设计问题,这意味着那些私有注册表实例的所有客户端都将会有相同的不一致性问题。
值得注意的是,各种包管理器和工具在使用/引用软件包的注册表 manifest
或 tarball
的 package.json
方面有不同的情况(一般都是用来作为缓存和提高安装性能的机制)。
在这里要强调的关键点是,目前生态系统错误地认为 manifest
总是包含 tarball
的 package.json
的内容(这在很大程度上是因为缺乏注册表 API
文档以及 docs.npmjs.com
中各种引用的缘故,指出 manifest
将 package.json
的内容存储为元数据,但没有任何地方提到客户端会负责确保它们的一致性)。
manifest
中不存在的安装脚本,反之亦然npx npm@6 install darcyclarke-manifest-pkg@2.1.13
manifest
中不存在生命周期脚本,并且注册表尚未将程序包注册为具有安装脚本(即 hasInstallScript
未定义为 undefined
或 false
)(参见 https://registry.npmjs.org/darcyclarke-manifest-pkg/2.1.13 ↗ - 代码/软件包参考 https://github.com/npm/minify-registry-metadata/blob/main/lib/index.js ↗)node_modules/darcyclarke-manifest-pkg
中的 package.json
反映了 tarball
条目manifest
中不存在的依赖项,反之亦然由于软件包 tarball
会被缓存在全局存储中,如果在 --no-package-lock
的情况下使用 --prefer-offline
配置,则在系统上下次运行该软件包的安装时,可能会安装其中隐藏的依赖项。
复现步骤:
npx npm@6 install darcyclarke-manifest-pkg@2.1.13
npx npm@6 install --prefer-offline --no-package-lock
与 npm@6
类似,当使用 --offline
配置时,npm@9
会愉快地安装包的缓存 tarball package.json
中引用的依赖项。
注意:有可能会存在竞态条件,--offline
可能会或可能不会从缓存中拉取,从而导致间歇性结果。
重现步骤:
--offline
配置运行安装和/或关闭网络可用性(例如,npm install --offline --no-package-lock
)manifest
中引用的依赖项与 npm@6
& npm@9
一样,yarn@1
将运行 tarball
内但未在 manifest
中引用的脚本,反之亦然。
众所周知,tarball
可以有 version
与 manifest
不同的定义;在这种情况下,yarn@1
将愉快地升级/降级并保存回使用项目的 package.json
错误版本(可能使消费者在后续安装中遭受降级攻击)
与所有其他脚本一样,pnpm
将运行 tarball
内但未在 manifest
中引用的脚本,反之亦然。
据我所知,GitHub
首次意识到这个问题是在 2022
年 11
月 4
日左右;经过独立研究后,我相信这个问题的潜在影响/风险实际上比最初理解的要大得多,我于 3
月 9
日提交了一份包含我的发现的 HackerOne
报告。GitHub
关闭了该 Issue
并表示他们正在“内部”处理这个问题。据我所知,他们没有取得任何重大进展,也没有公开这个问题 - 相反,他们实际上在过去 6 个月里放弃了 npm
作为产品的地位,并拒绝跟进或提供任何补救措施的见解工作。
GitHub
正陷入不可逆转的困境。事实上,npmjs.com
这种方式已经运行了十多年了,这意味着当前的状态几乎已经无法打破。如前所述,npmCLI
本身依赖于这种行为,而且目前这种行为还可能存在其他非恶意用途。那理论上应该做什么呢?
tarball
的 package.json
差异重新生成 manifest
是有意义的manifest
中的特权/已知密钥npm
公共注册表 API
及其各自的请求/响应对象需要尽快记录下来联系你知道依赖于 npm
注册表 manifest
数据的任何已知工具作者/维护者,并确保他们在适当的时候开始使用包的内容作为元数据(除了 name&version
之外的所有内容)。开始使用严格执行/验证一致性的注册表代理。
参考: