一个JS沙箱逃逸漏洞

翻译文章:https://licenciaparahackear.github.io/en/posts/bypassing-a-restrictive-js-sandbox/

在做一个Bug bounty的项目时,我发现了一个网站,它有一个很有意思的功能:它能让我使用一些用户控制的表达式过滤数据。比如说,我可以输入 book.proce > 100 表达式,使它只展示一些价格高于 $100 的书。直接输入 true 可以列出所有的书,输入 false 就会一本书都不显示。所以我可以知道我使用的表达式是对是错。

这个功能引起了我的注意,因此我尝试着输入更复杂的表达式,比如 (1+1).toString()==="2" (值为 true)和 (1+1).toString()===5 (值为 false)。这显然是 JavaScript 代码,因此我猜测这个表达式在 NodeJS server 中被作为参数传给了一个类似 eval 的函数。到了这里,我感觉我快要发现一个远程执行漏洞了。然而,当我想要测试一个更加复杂的表达式时,它报错了,提示我输入的表达式非法。我猜测这应该不是 eval 函数,而应该是一个 JavaScript 的沙箱。

沙箱都是再一个受控的环境中执行非可信的代码,而这一般都是很难确保不出问题的。大多数情况下,我们都能找到一些方法来绕过沙箱的保护机制。特别是对于像 JavaScript 这样复杂,特性臃肿的语言,沙箱的漏洞可能会更多。这个问题吸引了我的注意,所以我决定花一些时间来打破这个沙箱的防护机制。我学习了 JavaScript 的一些内部机制,用来发现和利用沙箱的 RCE 漏洞。

我首先要确定这个网站使用了什么库来实现的沙箱,因为 NodeJS 中有几十个类似的库,在许多情况下,它们都存在一些漏洞。当然,也有可能这是一个开发人员自己写的库,但是我忽略了这种可能,因为单纯的网站开发人员不太可能花大把的时间来做这种语言底层的事情。

最后,通过分析网站的错误日志,我推断处它们应该使用的是 static-eval ,这是一个不太流行的库(由 substack写的,这个人再 NodeJS 社区中非常有名)。尽管它的文档中写了,它并不是被设计来作为沙箱的,但是我仍然十分确定他在这个网站中被用作了沙箱。

绕过 static-eval

static-eval的基本思想是使用 esprima 库解析 JS 表达式并将其转换为 AST(抽象语法树)。static-eval 通过分析这个AST 对我输入的表达式进行评估。如果发现一些奇怪的东西,函数就抛出异常,我的代码就不会执行。一开始,我有点灰心丧气,因为我意识到沙箱对它所接受的表达式有很大的限制。我甚至不能在表达式中使用 for 或 while 语句,所以做一些需要迭代算法的事情是几乎不可能的。无论如何,我坚持着继续寻找漏洞。

一开始,我并没有发现任何 bug,所以我查看了 static-eval 项目的 commits 和 pull requests 的所有记录。 我发现 pull requests #18 修复了两个沙箱逃逸的 bug,而这正是我所寻找的。 我还发现了这个 pr 作者的博客,在这篇文章里,他深入分析了这个漏洞。同时,我在立即在这个网站中测试这个漏洞,然而,他们使用了一个新版本的 static-eval,这个版本的 static-eval 早就把这个漏洞补上了。我立即尝试在我测试的网站中使用这种技术,但不幸的是,他们使用的是更新的静态评估版本,已经修补了这个漏洞。但是,知道有人发现过这种漏洞,这让我更加自信,所以我一直在寻找绕过它的新方法。

接下来,我深入分析了这两个漏洞,以期望能够为我找到新的漏洞寻找灵感。

第一个漏洞:

第一个漏洞使用了 function constructor 来生成恶意函数。
这种技术经常用于绕过沙箱。例如,大多数通过绕过 angular.js 沙箱来获得 XSS 的方法都使用一些有效载荷,它们最终都会访问和调用 function constructor。下面的表达式用来演示漏洞,能够打印系统环境变量(这应该是不被允许的,因为沙箱应该阻止它):

"".sub.constructor("console.log(process.env)")()

在这段代码中,"".sub 能够生成一个函数对象, 然后它会执行这个函数的构造函数 constructor,constructor 函数在被调用后会返回一个新函数,该函数的代码是 constructor 的参数。它类似于 eval 函数,但它不是立即执行代码,而是返回一个函数,在调用时执行代码。在这段代码的末尾正好有一个 (),这是为了调用这个新生成函数。

除了显示系统的环境变量,你还可以做一些其他的事情。比如说,你可以使用 child_process 模块的 execSync 函数来执行操作系统的命令,并且返回其结果。这段载荷将会返回执行 id 命令后的结果。

"".sub.constructor("console.log(global.process.mainModule.constructor._load(\"child_process\").execSync(\"id\").toString())")()

这段代码使用了跟前面一段代码相同的方法。在这段代码中,global.process.mainModule.constructor._load 的功能与 require 函数相同。正是由于在 constructor 中我不能直接使用 require,因此我不得不使用 global.process.mainModule.constructor._load 这个名字。

此漏洞的是修补方案是禁止对函数对象的属性的访问(通过 typeof obj == 'function' 来实现的):

else if (node.type === 'MemberExpression') {
    var obj = walk(node.object);
    // do not allow access to methods on Function 
    if((obj === FAIL) || (typeof obj == 'function')){
        return FAIL;
    }

这是一个非常简单的修复,但它的效果出奇的好。当然,function constructor 只在函数中可用。所以我无法访问它。对象的 typeof 是不能被修改的,因此任何函数的 typeof 都将被设置为 function。我没有找到绕过这个保护的方法,所以我又继续去分析了第二个漏洞。

第二个漏洞:

这个漏洞比第一个更简单、更容易检测:沙箱允许创建匿名函数,但它没有检查它们的主体以禁止恶意代码。相反,函数的主体被直接传递给 function constructor。以下代码与博客文章的上一个漏洞的第一个负载具有相同的效果:

(function(){console.log(process.env)})()

您还可以更改匿名函数的主体,使其使用 execSync 显示执行系统命令的输出。我把这个留给读者做练习。

译者注:
(function(){console.log(global.process.mainModule.constructor._load("child_process").execSync("id").toString())})()

第一个修复这个漏洞的方法是在 static-eval 表达式中禁用所有的匿名函数表达式。然而,这可能会禁用一些匿名函数的合法使用。因此,应该考虑一种允许合法匿名函数,禁止恶意匿名函数的方法。这需要通过分析函数体来确保其不会执行任意的恶意行为,比如说访问 function constructor。这个漏洞的修复会比第一个更加复杂,此外, Matt Austin (这个漏洞修复的作者)说它不确定当前开发的修复方案是完美的,所以我觉得可以考虑找到绕过这个漏洞修复的方法。

找到一个新漏洞

有一件事引起了我的注意,那就是 static-eval 只是在定义的时候判断一个函数是否是恶意的,而不是在调用它的时候。因此它不会分析函数的参数值,因为函数的参数值只能在函数调用时进行分析。

我的想法还是试图访问 function constructor, 在某种程度上,这需要绕过第一个漏洞的修复,因为我现在还不能直接访问一个函数对象的属性。但是,如果我尝试去访问一个参数的 constructor 属性呢?因为它的值在定义的时候时不确定是什么类型,因此可能会绕过系统的检测。为了验证我的猜想,我使用了以下表达式:

(function(something){return something.constructor})("".sub)

如果这返回一个函数的 constructor,那么我就成功的绕过了这个检测。遗憾的是,这并没有成功。static-eval 会禁止一个函数访问未知类型对象的属性。

static-eval的一个特性是,它允许指定您希望在static-eval表达式中可用的一些变量。例如,在博文的开头,我使用了表达式 book.price > 100。在这种情况下,调用静态 eval 的代码将传递 book 变量的值给它,这样就可以在表达式中使用它。

这给了我另一个想法:如果我创建一个匿名函数,其参数的名称与已经定义的变量相同,会发生什么? 因为它不能在定义时知道在运行时参数的值,所以它可能使用变量的初值。那对我很有用。假设我有一个变量book,它的初值是一个对象。那么,下面的表达式可能会达到我的目的。

(function(book){return book.constructor})("".sub)

当函数被定义时,static-eval会检查 book.constructor 是否是一个合法的表达式。由于 book 最初是一个对象(其类型为object)而不是一个函数,因此允许访问其构造函数,并创建该函数。然而,当我调用这个函数时,book会将传递给函数的值作为参数(这是 "".sub 的另外一个作用)。然后,就成成功的返回 function.constructor 了。

遗憾的是,这也没有起作用,因为作者在修复漏洞的时候已经考虑了这种情况。在分析函数体时,将所有参数的值设置为null,覆盖变量的初值。这段代码是这样做的:

node.params.forEach(function(key) {
    if(key.type == 'Identifier'){
      vars[key.name] = null;
    }
});

这段代码在定义函数的 AST 节点上,获取每个 Identifier 类型的参数的名字,并将所有该名称的变量的属性设置为 null。虽然这段代码看起来挺正常,但是它确有一个巨大的漏洞:他没有覆盖所有的分支。试想,如果一个参数的类型不是 Identifier 怎么办?它肯定不能特别智能的说“我不知道这是什么,所以我要禁用他”。相反,它一定会忽略这个参数并且继续检查下面的。这意味着,如果我能让一个函数的参数不再是 Identifier,那么与这个变量名字相同的变量值就不会被覆盖,所以它就可以使用初始值了。这下,我已经很确信我应该找到了一些不得了的东西。剩下的,我只需要找到怎么把 key.type 设置成不同于 Identifier 的类型。

前面说过, static-eval 使用 esprima 库来解析我们输入的代码。根据 esprima 的文档, esprima 根据自己的标准来解析代码。事实上,ECMAScript 更像是 JavaScript 的一种方言,它的一些特性会使用户更容易接受。

ECMAScript 中加入的一个特性是 函数参数的析构。根据这个特性,下面的代码是允许的:

function fullName({firstName, lastName}){
    return firstName + " " + lastName;
}
console.log(fullName({firstName: "John", lastName: "McCarthy"}))

在上面的表达式中,函数接受的并不是两个参数 (firstName, lastName)。相反,它只接受了一个参数,这个参数具有 firstName 和 lastName 两个属性。上一段代码也同样可以携程下一段代码。

function fullName(person){
    return person.firstName + " " + person.lastName;
}
console.log(fullName({firstName: "John", lastName: "McCarthy"}))

如果我们观察一下 esprima 生成的 AST,我们就会又意想不到的结果。

实际上,这种新语法使函数参数具有与 Identifier 不同的类型 ObjectPattern,因此 static-eval 不会重写这个变量。因此,当我们在执行下面代码时, static-eval 将继续使用 book 的初始值。

(function({book}){return book.constructor})({book:"".sub})

然后我们就能创建一个函数了。当它被调用时, book 将会是一个函数对象, 因此就能返回一个 function constructor 了,我找到了绕过的方法!前面的表达式返回函数构造函数,所以我只需要调用它来创建一个恶意的函数,然后调用这个创建的函数:

(function({book}){return book.constructor})({book:"".sub})("console.log(global.process.mainModule.constructor._load(\"child_process\").execSync(\"id\").toString())")()

在本地的的测试环境中,运行了最新版本的 static-eval,这段代码被成功的执行了。现在,我找到了一个 static-eval 绕过方法,能够在 static-eval 环境中执行恶意代码。使其工作的惟一必要条件是具有一个相同名称的已经赋值的变量,该变量具有 constructor 属性。字符串、数字、数组和对象都满足此属性,因此应该很容易实现此条件。

我的方法竟然在目标网站上不能执行

不幸的是,在完成所有这些工作并找到一个绕过的漏洞之后,我意识到它在我所测试的站点上无法工作。我的方法的惟一必要条件是具有一个相同名称的已经赋值的变量。我的确满足了这个条件,但是它却还是不能在目标网站上运行。

通过一番研究后, 我发现站点并没有直接使用 static-eval。它是通过 jsonpath 库来调用的 static-eval,JsonPath 与 XPath 的功能类似,之不妥它是用来处理 Json 文件的。在阅读 JsonPath 的文档后,我感觉这是一个比较烂的项目,对于它们应该要做的事情,它们自己都想不清楚。它实现的大多数特性可能是拍脑袋想到的,没有适当地考虑这些特性是否值得添加。遗憾的是, NodeJS 的生态里面到处都是这样的库。
译者注:这里是作者在发牢骚,大家不必当真

JsonPath 有一个特性叫做 表达式过滤器,它能根据指定的表达式来过滤一段文档。比如 $.store.book[?(@.price < 10)].title 将会返回比 $10 便宜的书,然后再得到它们的标题。对于jsonpath npm库,圆括号之间的表达式就是使用 static-eval 运行的。我测试的站点允许我指定一个JSONPath表达式,并使用 static-eval 来解析,所以这里的 RCE 理论上应该是可行的。

如果我们再仔细看看之前传给 Jsonpath 的表达式,我们可以发现传给 static-eval 的表达式其实是 @.price < 10。 根据说明文档,@ 是一个包含被过滤文档的变量(通常是一个对象)。然而,根据 ECMAScript 的规范,这并不是一个合法的变量名。 因此,为了使 static-eval 能够正常运行,他们不得不修改 esprima 的代码,使其判定 @ 为一个合法的变量名。

当你在 static-eval 中创建匿名函数时,它会被嵌入到另一个函数中,该函数接受已经定义的变量作为参数。因此,如果我在 JsonPath 的表达式过滤器中创建一个匿名函数,它将创建一个包装它的函数,该函数接受一个名为 @ 的参数。这是通过直接调用 function constructor 来完成的,而不是通过前面的 esprima 补丁。然后,在定义函数时,它会抛出一个我绕过的错误。这其实是库本身的一个bug,这使得它在表达式过滤器中定义函数(无论是良性的还是恶意的)时失败。正因为如此,我的旁路技术无法在这个库中工作。

总结

即使这次我没有拿到这个网站的漏洞奖励,我仍然深入的研究了 static-eval 这个库。最近,我利用这次学到的知识,成功的绕过了另一个 JS 环境,并且这一次得到了一定的经济上的回报,我将会在我接下来的一篇博客中介绍这一次的绕过技巧。在这里,我再次想要感谢一下 Matt Austin 关于 static-eval 的工作,没有他的研究,我是不可能发现这个新漏洞的。

时间线

  • 01/02/19 向NodeJS安全团队和static-eval mantainer提交的漏洞报告
  • 01/03/19 NodeJS安全团队回复了这个漏洞。他们告诉我,如果库的作者不回复这个漏洞,他们会联系他,并发布一份报告。
  • 02/14/19 漏洞正式发布在官方网站上。
  • 02/15/19 static-eval 库发布了一个新版本,修复了这个漏洞。
  • 02/18/19 static-eval 库更新了它的 README 文件,添加了一个免责声明,不建议用户用来作为沙箱。
  • 02/26/19 static-eval 发布了一个新的修补方案,因为我之前的修补方案有一个 bug。

点击收藏 | 2 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖