npm 生态系统存在巨大的安全隐患

大家好,我是 Winty。

最近,曾经在 2019 - 2022 年担任 npm 研发经理的 Darcy Clarke 公开吐槽了 npm 生态系统的安全性,称其一直具有巨大的安全隐患。

简单来讲:一个 npm 包的 manifest 是独立于其 tarball 发布的,manifest 不会完全根据 tarball 的内容进行验证,生态系统普遍会默认认为 manifesttarball 的内容是一致的。任何使用公共注册表的工具都很容易受到劫持。恶意攻击者可以隐藏恶意软件和脚本,把自己隐藏在在直接或间接依赖项中。在新型的供应链攻击方面,这是一个非常重大问题,大家可以将其称为 “清单混淆”。

历史

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 中的字段不同,因为它们是从未经过验证的。

示例

  1. npmjs.com 上生成一个授权令牌(例如 https://www.npmjs.com/settings/<your-username>/tokens/new - 为了方便起见,选择 “Automation”)

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

  3. 安装帮助库(例如 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>/ 查看有什么差异

cfba611287d8fcaeba729bf0845fd7e9.png

在这个示例中,包和 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-bindinghttps://unpkg.com/darcyclarke-binding@1.0.0/package.json

在现实中对于这种受害者的例子也有很多,比如 node-canvas

  • https://www.npmjs.com/package/node-canvas/v/2.9.0?activeTab=explore

  • https://registry.npmjs.com/node-canvas/2.9.0

  • https://github.com/npm/cli/issues/5234

影响

这种安全隐患实际上可能会通过多种方式影响消费者或最终用户:

  • 缓存中毒(即保存的包可能与注册表/URI 中的名称+版本规范不匹配)

  • 安装未知/未列出的依赖项(欺骗安全/审核工具)

  • 执行未知/未列出的脚本(欺骗安全/审核工具)

  • 潜在的降级攻击(其中保存到项目中的版本规范是针对未指定的、易受攻击的包版本)

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

  • Snyk: https://security.snyk.io/package/npm/darcyclarke-manifest-pkg

  • CNPMJS/Chinese Mirror: https://npmmirror.com/package/darcyclarke-manifest-pkg

  • Cloudflare Mirror: https://registry.npmjs.cf/darcyclarke-manifest-pkg/2.1.15

  • Skypack: https://cdn.skypack.dev/-/darcyclarke-manifest-pkg@v2.1.15

  • UNPKG: https://unpkg.com/darcyclarke-manifest-pkg@2.1.15/package.json

  • JSPM: https://ga.jspm.io/npm:darcyclarke-manifest-pkg@2.1.15/package.json

  • Yarn: https://yarnpkg.com/package/darcyclarke-manifest-pkg

这个问题也会以各种方式影响所有已知的主要 JavaScript 包管理器,详细情况如下所述。像 jFrogArtifacory 这样的第三方注册表实现似乎也复制了这个 API 设计问题,这意味着那些私有注册表实例的所有客户端都将会有相同的不一致性问题。

值得注意的是,各种包管理器和工具在使用/引用软件包的注册表 manifesttarballpackage.json 方面有不同的情况(一般都是用来作为缓存和提高安装性能的机制)。

在这里要强调的关键点是,目前生态系统错误地认为 manifest 总是包含 tarballpackage.json 的内容(这在很大程度上是因为缺乏注册表 API 文档以及 docs.npmjs.com 中各种引用的缘故,指出 manifestpackage.json 的内容存储为元数据,但没有任何地方提到客户端会负责确保它们的一致性)。

npm@6

执行 manifest 中不存在的安装脚本,反之亦然

  • 安装一个格式错误的依赖项:npx npm@6 install darcyclarke-manifest-pkg@2.1.13

  • 请注意,即使 manifest 中不存在生命周期脚本,并且注册表尚未将程序包注册为具有安装脚本(即 hasInstallScript 未定义为 undefinedfalse)(参见 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 条目

5cc4346ed46afd403753db9951c5a44b.png

安装 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

e85844b99bc853116f30ad8b35c5f593.png

npm@9

安装 manifest 中不存在的依赖项,反之亦然

npm@6 类似,当使用 --offline 配置时,npm@9 会愉快地安装包的缓存 tarball package.json 中引用的依赖项。

注意:有可能会存在竞态条件,--offline 可能会或可能不会从缓存中拉取,从而导致间歇性结果。

重现步骤:

  • 安装格式错误的依赖项以使其缓存

  • 使用 --offline 配置运行安装和/或关闭网络可用性(例如,npm install --offline --no-package-lock

  • 查看将安装未在 manifest 中引用的依赖项

yarn@1

执行 manifest 中不存在的安装脚本,反之亦然

npm@6& npm@9 一样,yarn@1 将运行 tarball 内但未在 manifest 中引用的脚本,反之亦然。

3f06caecb35a301643c2e7568359cfb9.png

使用 version tarball 中发现的内容 - 暴露潜在的降级攻击向量

众所周知,tarball 可以有 versionmanifest 不同的定义;在这种情况下,yarn@1 将愉快地升级/降级并保存回使用项目的 package.json 错误版本(可能使消费者在后续安装中遭受降级攻击)

ec6479ea059ce5ff89148a1777506219.png

pnpm@7

执行清单中不存在的安装脚本,反之亦然

与所有其他脚本一样,pnpm 将运行 tarball 内但未在 manifest 中引用的脚本,反之亦然。

558898ae9fb19865ea22456a4b622069.png

GitHub 对此做了什么?

据我所知,GitHub 首次意识到这个问题是在 2022114 日左右;经过独立研究后,我相信这个问题的潜在影响/风险实际上比最初理解的要大得多,我于 39 日提交了一份包含我的发现的 HackerOne 报告。GitHub 关闭了该 Issue 并表示他们正在“内部”处理这个问题。据我所知,他们没有取得任何重大进展,也没有公开这个问题 - 相反,他们实际上在过去 6 个月里放弃了 npm 作为产品的地位,并拒绝跟进或提供任何补救措施的见解工作。

解决方案会是什么样子?

GitHub 正陷入不可逆转的困境。事实上,npmjs.com 这种方式已经运行了十多年了,这意味着当前的状态几乎已经无法打破。如前所述,npmCLI 本身依赖于这种行为,而且目前这种行为还可能存在其他非恶意用途。那理论上应该做什么呢?

  • 应该进行进一步调查以确定注册表中受影响的范围,这将有助于确定滥用情况

  • 如果差异的数量很小,那可以根据 tarballpackage.json 差异重新生成 manifest 是有意义的

  • 开始强制/验证 manifest 中的特权/已知密钥

  • npm 公共注册表 API 及其各自的请求/响应对象需要尽快记录下来

你能做什么?

联系你知道依赖于 npm 注册表 manifest 数据的任何已知工具作者/维护者,并确保他们在适当的时候开始使用包的内容作为元数据(除了 name&version 之外的所有内容)。开始使用严格执行/验证一致性的注册表代理。

最后

大家怎么看?欢迎在评论区留言!

参考:

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

7a19fdb37b057a166eaaa696cc8aa089.png

往期推荐

前端性能优化,我会从这几个角度去回答你!超级详细!

055cb68607bd4d9c427c4d6239592a4c.png

如何完成一个完全不依赖客户端时间的倒计时

ccc3ec43cb7f39939f6aabdddd19a296.png

前端获取电池信息

78581bf58b6fe09a687e6c53f8cbb6c6.png


最后

  • 欢迎加我微信,拉你进技术群,长期交流学习...

  • 欢迎关注「前端Q」,认真学前端,做个专业的技术人...

96d8534b0cccfd3b8ec838e36a0cd09f.png

前端Q

本公众号主要分享一些技术圈(前端圈为主)相关的技术文章、工具资源、学习资料、招聘信息及其他有趣的东西...

公众号

f9c4d4928661d3212253a13423a1936e.jpeg

7eccdcd53c103bce1de075e1a8162993.png

点个在看支持我吧

9fb7680aaa9dde52017bef15db21f087.gif

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值