写点什么

npm 前员工自曝生态内部存在严重 bug | 附避坑指南

作者 | Darcy Clarke

  • 2023-07-24
    北京
  • 本文字数:5984 字

    阅读完需:约 20 分钟

npm前员工自曝生态内部存在严重bug | 附避坑指南

最近,npm 前工程经理 Darcy Clarke 在一份报告中指出,npm 注册没有根据相应 tarball 包的内容验证清单信息。Clarke 说,这会导致双重事实来源,攻击者可以利用它来隐藏脚本或依赖项。

 

这一点影响很大。例如,npm 上有个包可能会显示它没有依赖项,而实际上它有。同样,它显示的包名或版本可能与 package.json 中的不同,而这可能会导致缓存中毒。更糟糕的是,它可以隐藏它将在安装期间运行脚本的事实。

 

在接受 InfoQ 采访时,Sonatype 安全研究员 Ax Sharma 强调,这种不一致不一定是恶意的,可能是源于合法的克隆或分叉,或者是由于开发人员在更新包时没有清理过时的元数据。他还提出了一点小小的异议:

 

相信 package.json 并不一定比相信包的 npmjs 页面更好——两者都不是完全可靠的。

 

根据 Sharma 的说法,要解决这个问题需要借助安全工具进行更深入的分析,例如,对恶意文件或受到攻击的文件进行基于散列的分析,即高级二进制指纹。

 

另一个有用的建议来自 J. M. Rossy 的推特,他建议默认关闭脚本。

 

如果你对这个清单之惑感兴趣,请阅读 Clarke 的原文,其中有许多其他的见解。

 

以下为原文翻译。

 

简单自我介绍,2019 年 7 月至 2022 年 12 月期间,我负责 npm CLI 团队的工程管理。2020 年我参与了 GitHub 收购 npm 项目.。2022 年 12 月,我因各种原因离开了 GitHub。

 

如今,各类新兴供应链攻击可谓层出不穷,而本文要向大家分享的则是其中一例——我个人称之为“manifest 混淆”(manifest confusion)。

 

故事背景

 

在 Node 生态系统发展到如今全球用户达数千万、创建超过 310 万个软件包、月下载量高达 2080 亿次的规模之前,当初该项目的贡献者数量曾非常有限。当然,社区越小,大家就越感觉安心,毕竟没有哪个黑客团队会找这么“瘦”的目标下手。但随着时间推移,npm 注册表被逐步开发出来,人们可以免费贡献并检查其中的开源代码,语料库的组织政策和实践也迎来同步发展。

 

从诞生之初,npm 项目就非常信任注册表的客户端与服务器端。现在回想起来,这种高度依赖客户端来处理数据验证的作法真的很有问题。但也正是凭借这项策略,是让 JavaScript 工具生态得以快速成长并在数据形态中有所体现。

 

发生甚么事了?

 

npm 公共注册表不会使用包 tarball 中的内容来验证 manifest 信息,反而是依赖 npm 兼容的客户端进行解释和强制验证/一致性。事实上,在研究这个问题时,我发现服务器似乎从未承担过验证任务。

 

如今,registry.npmjs.com 允许用户通过 PUT 请求将软件包发布至相应的包 URI,例如:

 

https://registry.npmjs.com/-/<package-name>。

 

该端点会接收一条请求 body,内容如下所示(请注意:在经历近 15 年的发展之前,如今的 npm 及其他注册表 API 仍然严重缺乏记录信息):

 

{  _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>'    }  }}
复制代码

 

目前的问题是,version 元数据(也就是「manifest」数据)是独立于存放有软件包 package.json 的 tarball 而独立提交的。这两部分信息之间从未进行过相互验证,而且我们往往搞不清依赖项、脚本、许可证等数据的“权威事实来源”究竟是谁。据我所知,tarball 才是唯一拥有签名,且有着可离线存储及验证的完整性值的工件。从这些角度看,它应该才是正确的来源;但令人意外的是,package.json 当中的 name & version 字段实际上很可能与 manifest 中的字段不同,因为二者间不会进行相互验证。

示例


  1. 在 npmjs.com 上生成身份验证令牌(例如: https://www.npmjs.com/settings/<your-username>/tokens/new - 选择 "Automation" 以方便测试)

  2. 启动一个新项目 (例如: mkdir test && cd test/ && npm init -y)

  3. 安装 helper 库(例如:npm install ssri libnpmpack npm-registry-fetch)

  4. 创建一个子目录,作为“真实”的软件包及内容(例如 mkdir pkg && cd pkg/ && npm init -y)

  5. 修改该包的内容……

  6. 在项目根目录中创建一个 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, })})()
复制代码

 

  1. 可随意修改其中 manifest 键(例如,我在这里去掉了 scripts & dependencies );

  2. 运行程序(例如: node publish.js);

  3. 导航至 https://registry.npmjs.com/<pkg>/ & https://www.npmjs.com/package/<pkg>/v/<version>?activeTab=explore 以查看差异。

 


以上示例中的软件包是用不同 manifest 发布的,其各有对应的 package.json,请参考:

 https://www.npmjs.com/darcyclarke-manifest-pkg 

 https://registry.npmjs.com/darcyclarke-manifest-pkg/

 Bug, bug, 到处是 bug

 

如果大家想用更简单的办法重现这种不一致性,现在也可以使用 npm CLI。一旦在项目中发现 binding.gyp 文件,它就会在 npm 发布期间改变 manifest 内容。这种行为似乎在我加入 npm 团队之前(即 6.x 或更早版本)就已经存在于客户端内,而且已经给众多用户惹出了不少麻烦。

 

  1. npm init -y

  2. touch binding.gyp

  3. npm publish

  4. 可以看到, "node-gyp rebuild" scripts.install 条目已被自动添加至 manifest 当中,但却未被添加至 tarball 的 package.json 当中。例如:

 

这种不一致现象在 node-canvas 中经常出现:

 

相关影响

 

这个 bug 可能会以多种方式影响消费者/最终用户:

  1. 缓存中毒(即保存的包可能与注册表/URI 中的名称+版本规格不匹配;

  2. 安装未知/未列出的依赖项(欺骗安全/审计工具);

  3. 安装未知/未列出的脚本(欺骗安全/审计工具);

  4. 引发潜在降级攻击(保存到项目中的版本规格,为不符合要求/易受攻击的包版本)。

 

已知受到影响的第三方组织/实体:

 

 

更新:前文提到,Socket Security 易受到 manifest 混淆问题的影响。自 2022 年 9 月 5 日起,Socket 方面已开始使用 tarball 内的 package.json 文件作为事实来源,且要求显示包的准确信息(例如依赖项、许可证、脚本等)。截至本文发布时,darcyclarke0-manifest-pkg 的软件包页面错误地引用了过时的数据,但 Socket 团队很快解决了这个问题。这里要称赞一声,Socket 可能是首个正确处理此问题的项目团队。

 

此问题还会以下面介绍的几种方式,影响到所有已知的主要 JavaScript 包管理器。jFrog 的 Artifacory 等第三方注册表实现似乎也继承了该 API 的设计/问题,因此使用这些私有注册表实例的所有客户端也会出现相同的问题/不一致。

 

注意,各类包管理器和工具对应不同的应用场景。它们要么使用/引用软件包的注册表 manifest,要么使用/引用 tarball 的 package.json(主要是为了通过缓存机制提高安装性能)。

 

这里需要强调的是,生态系统目前仍普遍存在错误假设,即 manifest 的内容始终与 tarball 的 package.json 内容一致(这主要是因为注册表 API 说明文档过少,且 docs.npmjs.com 多次提到注册表会将 package.json 的内容存储为元数据——但却没有强调其实是由客户端负责确保一致性)。

 

npm@6

执行 manifest/tarball 中不存在的脚本


重现步骤:

  1. 安装一个格式经过篡改的依赖项: npx npm@6 install darcyclarke-manifest-pkg@2.1.13

  2. See that lifecycle scripts are being executed even though none are present in the manifest & the registry has not registered the package as having install script (ie.可以看到,虽然在 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 中不存在的依赖项

 

由于包 tarball 会被缓存在全局存储当中,所以如果--prefer-offline 配置与--no-package-lock 共同使用,则下一次在系统中对该包运行 install 时,隐藏在 tarball 中的依赖项也会被安装。

重现步骤:

  1. 安装 npx npm@6 install darcyclarke-manifest-pkg@2.1.13

  2. 再次运行安装… npx npm@6 install --prefer-offline --no-package-lock



npm@9

安装 manifest/tarball 中不存在的依赖项


与 npm@6,类似,npm@9 在使用--offline 配置时也会直接安装经过缓存的 tarball package.json 当中引用的依赖项。

 

注意:其中似乎存在争用条件,即--offline 可能会/可能不会被从缓存内提取,因此重现结果并不稳定。

 

重现步骤:

  1. 安装格式经过篡改的依赖项以将其缓存;

  2. 在安装时使用--offline 配置并/或关闭可用网络(例如: npm install --offline --no-package-lock)

  3. 可以看到,manifest 中并未引用的依赖项也会被安装。

 

yarn@1

 

执行 manifest/tarball 中不存在的安装脚本

 

与 npm@6 & npm@9 类似,yarn@1 会 tarball 中引用、但 manifest 并未引用的脚本,反之亦然。



使用 tarball 中的 version 字段——暴露潜在降级攻击向量


现在大家已经了解,tarball 的内容定义可以与 manifest 有所不同;在这种情况下,yarn@1 顺理成章地在升级/降级之后,再把错误版本保存回当前项目的 package.json 当中(可能令用户在后续安装中遭受降级攻击)。



pnpm@7

执行 manifest/tarball 中不存在的安装脚本


重现步骤:

与之前几个案例类似,pnpm 会运行 tarball 中存在、但 manifest 并未引用的脚本,反之亦然。



CWE 分类/细分

 

此漏洞可能涉及多种 CWE 分类。至少如果我们把此问题视为“特例”,则以上情况应该被归纳为“服务端安全的客户端实施”(即 CWE-602——但我严重怀疑这种判断并不适用。我在下文中会具体分析各种问题及其相应 CWE 分类,且分别提供参考代码)。


GitHub 为此做了哪些努力?

 

据我所知,GitHub 大概在 2022 年 11 月 4 日左右发现了这个问题;经过独立研究之后,我认为这个问题的潜在影响/风险要比最初的判断大得多,因此于 3 月 9 日提交了一份包含个人发现的 HackerOne 报告。3 月 21 日,GitHub 关闭了该工单,表示他们正在“内部”处理这个问题。据我了解,之后 GitHub 没有取得任何 重大进展,也没有公开发布这个问题。相反,他们在过去半年间逐渐放弃了 npm 的产品地位,且拒绝更新或提供关于补救措施的相关说明。

可行的解决方案

 

GitHub 正陷入不可逆转的困境。事实上,npmjs.com 就是在这样的状态下运行了十余年,意味着目前的安全状况已经被深深嵌入代码当中,再难实现广泛修复。如前所述,npm CLI 本身也依赖于这种设计,而且目前还可能存在其他非恶意用途。


  • 该做点什么……

  • 应开展进一步调查,确定注册表内受影响条目的具体范围,这将有助于确定滥用情况。

  • 如果差异量不太大(但考虑到当前 manifest 变体的可观规模,这种可能性恐怕很低),那么也许可以根据 tarball 的 package.json 重新生成包含差异的 manifest。

  • 从现在起,根据研究/发现对 manifest 中高权限/已知密钥强制执行/验证。

  • 尽快将 npm 公共注册表 API 及其相应的请求/响应对象记录下来。

用户能做点什么?

 

与认识的任何使用 npm 注册表 manifest 数据的已知工具作者/维护者联系,确保他们知情并想办法在适当时转而使用包内容作为元数据(即除了 name & version 之外的所有内容)。另外,请从现在起严格执行/验证注册表代理的一致性。

原文链接:

https://blog.vlt.sh/blog/the-massive-hole-in-the-npm-ecosystem

相关阅读:

前端开发:node.js 的 node 包管理器 npm 安装以及...

NPM 实用命令与快捷方式

前端包管理工具 npm yarn cnpm npx

Npm,Inc. 发布 Npm Pro,面向独立 JavaScript 开发人员


2023-07-24 11:162141

评论

发布
暂无评论
发现更多内容

Python中 *args 和 **kwargs的用法

BigYoung

Python *args **kwargs 缺省参数

配置类需要标注@Configuration却不知原因?那这次就不能给你涨薪喽

YourBatman

spring Spring Boot Spring Framework @Configuration Spring配置类

【总结】技术选型

小胖子

架构师训练营-第5周作业

坂田吴奇隆

极客大学架构师训练营

我终于弄懂了Python的装饰器(二)

BigYoung

Python 装饰器

Java中异常处理的9个最佳实践

码农神说

异常

架构师师傅在训练营第5周感想

tuuezzy

架构师

你到底在忙啥呢?

池建强

创业 写作

区块链或将成为整治形式官僚主义的“大杀器”

CECBC

智能合约 去中心 防篡改 服务高效性

Java 后端博客系统文章系统——No1

猿灯塔

CORS 和 CSRF 修炼宝典

Geek_z9ygea

大前端 Web CORS CSRF

第五章作业

小胖子

架构师训练营-第5周总结

坂田吴奇隆

极客大学架构师训练营

第五周作业

好名字

Go在容器运行时要注意的细节

博文视点Broadview

容器 云原生 Go 语言

一致性hash

石刻掌纹

我终于弄懂了Python的装饰器(一)

BigYoung

Python 装饰器

环信即时通讯云荣获《2020年度云通讯产品创新奖》

DT极客

我终于弄懂了Python的装饰器(三)

BigYoung

Python 装饰器

Week5总结

王志祥

极客大学架构师训练营

计算机操作系统基础(十六)---进程同步之共享内存

书旅

php laravel 操作系统 进程 线程’

Week5作业

王志祥

极客大学架构师训练营

rc-form源码解读

Lee Chen

大前端

官宣 | 千呼万唤,Apache Flink 1.11.0 正式发布啦!

Apache Flink

flink

我终于弄懂了Python的装饰器(四)

BigYoung

Python 装饰器

Python 中的元类到底是什么?这篇恐怕是最清楚的了

BigYoung

Python python元类

出成绩了!Avaddon勒索病毒劣迹昭著,6月勒索病毒占比TOP 10榜上有名

360安全卫士

勒索病毒

深入理解Java内存模型

itlemon

内存模型 Java内存模型

架构师训练营第五周学习总结

R20114

你不知道的 Blob

阿宝哥

Java 大前端 Web Blob

如何优雅地运用位运算实现产品需求?

梁桂钊

Java 架构

npm前员工自曝生态内部存在严重bug | 附避坑指南_工程化_InfoQ精选文章