温故知新:寻找新漏洞,绕过JS沙箱的限制

41yf1sh Web安全 2019年3月26日发布
Favorite收藏

导语:在参与漏洞赏金计划的过程中,我发现了一个网站,其中包含非常有趣的功能——该网站允许我基于用户控制的表达式过滤掉一些数据。

概述

在参与漏洞赏金计划的过程中,我发现了一个网站,其中包含非常有趣的功能——该网站允许我基于用户控制的表达式过滤掉一些数据。例如,我可以写出类似于book.price > 100这样的条件,并让该网站只显示出售价高于100美元的书。如果使用true作为过滤器,网站将会显示出所有的书籍,如果使用false,则不会显示出任何内容。所以,我能够知道我使用的表达式是真还是假。

这一功能引起了我的注意,所以我尝试传递更加复杂的表达式,例如:(1+1).toString()==="2"(结果为true)和(1+1).toString()===5(结果为false)。因此,我猜测,该表达式将会被用作NodeJS服务器内类似于eval的函数的参数。由此,我们好像就找到了一个远程代码执行漏洞。但是,当我们进一步尝试更加复杂的表达式时,将会产生错误,表明这些表达式是无效的。由此我猜测,它不是解析表达式的eval函数,而是JavaScript的一种沙箱系统。

用于在受限环境中执行不受信任代码的沙箱系统,通常难以正确使用。在大多数情况下,存在着绕过此类保护以便能以正常权限执行代码的方法。特别是在他们希望限制一个像JavaScript这样复杂、功能繁多的语言使用,就尤为如此。这一难题已经引起了我的注意,因此我决定花时间来攻破这个沙箱系统。我将学习JavaScript内部原理,试图发现漏洞,并实现远程代码执行的漏洞利用。

我做的第一件事,就是确定网站用来实现沙箱功能的库。在NodeJS的生态系统中,有数十个库都能实现这样的功能,并且其中的大多数都存在着一定的问题。但也有可能这是一个仅适用于目标站点的自定义沙箱库,但我放弃了这种可能性,因为开发人员不太可能花时间来做这类事情。

最后,通过分析应用程序的错误消息,我得出结论,他们使用的是static-eval,这是一个不太知名的库,由NodeJS社区中的substack编写。通过阅读文档,我们发现这个库的最初目的并不是用作沙箱。但是,不管怎样,在我们进行测试的网站上,它确实被用作了沙箱。

攻破static-eval

实际上,static-eval的思路是使用esprima库来解析JS表达式,并将其转换为AST(抽象语法树)。鉴于它使用了AST,以及具有一个包含我希望在沙箱中可用的变量的对象,所以它会尝试计算表达式。如果发现该表达式中含有非法的内容,那么该函数将执行失败,并且我们自定义的代码将不会被执行。起初,因为这一点,我有一些沮丧,因为我意识到沙箱系统对它接受的内容会进行非常严格的检查。我甚至无法在表达式中使用for或者while语句,因此想要实现一些循环算法,几乎是不可能的。但无论如何,我仍然在尝试从中找到一个漏洞。

经过初步分析,我没有发现任何漏洞因此我查看了GitHub项目的Commit,以及所有的Pull Request。我发现,在Pull Request #18中,修复了两个允许沙箱在库中逃逸的漏洞,而这正是我所寻找的。除此之外,我还发现了一个Pull Request作者的博客文章,其中深入揭示了这个漏洞。我立即在我所测试的网站上尝试使用这种技术,但不幸的是,他们使用的是更新后版本的static-eval,已经修复了这个漏洞。但是,目前我知道有人能攻破这个库,这使我变得更加自信,我开始积极地寻找绕过它的新方法。

在此之前,我深入分析了这两个漏洞,希望这两个漏洞的思路可以激发出我的灵感,让我在库中找到新的漏洞。

对第一个漏洞的分析

第一个漏洞使用函数构造函数来制造恶意函数。这种技术经常用于绕过沙箱。例如,绕过angular.js沙箱以获得XSS漏洞的大多数方法,都会用到调用函数构造函数的Payload。因此,这种方法也被用来绕过类似于static-eval的库,例如vm2。下面的表达式通过打印系统环境变量,来证明漏洞的存在。理论上,这样的表达式是无法成功执行的,会被沙箱阻止:

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

在这段代码中,"".sub是获取函数的一个简短方法,当然,(function(){})也可以使用。然后,它将访问该函数的构造函数。具体来说,这是一个函数,当它被调用时将会返回一个新的函数,其代码是作为参数传递的字符串。实际上,它与eval函数类似,但它并不是立即执行代码,而是返回一个在调用时执行代码的函数。这也就解释了Payload末尾为什么会有一个(),它叫做创建的函数。

1.png

除了显示环境变量之外,我们还可以做更多有意思的事情。例如,我们可以使用child_process NodeJS模块的execSync函数来执行操作系统的命令并返回其输出。下面的Payload将会返回运行id命令的输出结果:

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

除了其中包含创建的函数的主体之外,这里的Payload与前一个非常类似。在这种情况下,global.process.mainModule.constructor._load与NodeJS的require函数相同。由于某些原因,我忽略了这个函数在函数构造函数中不能使用require这个名称,所以我们只能使用那个比较长的名称。

2.png

要修复此漏洞,需要阻止针对作为函数的对象属性的访问,这是通过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;
    }

这是一个非常简单的漏洞修复方式,但它的修复效果却令人惊讶。当然,函数构造函数仅在函数中可以使用,因此我无法访问它。由于对象的typeof无法修改,因此任何函数都会将其typeof设置为函数。我没有找到绕过这种防护方案的方法,因此我继续研究了第二个漏洞。

对第二个漏洞的分析

这个漏洞与第一个漏洞相比,更加简单易于利用。其问题在于,沙箱允许创建匿名函数,但并没有检查其主体是否禁止恶意代码。相反,函数主体会被直接传递给函数构造函数。以下代码与博客文章中的第一个Payload具有相同的效果:

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

我们还可以更改匿名函数的主体,以便它使用execSync来证明能够显示执行系统命令后的输出结果,我将它作为一个练习留给各位读者。

该漏洞的一个可能修复方式是,禁止在static-eval表达式中使用所有匿名函数声明。但是,这会阻止匿名函数的合法用法(例如:使用它来映射数组)。因此,修复程序必须允许使用合法的匿名函数,但需要阻止恶意的函数。要实现这一点,需要在定义函数时分析函数主体,从而判断出它是否会执行任何恶意操作,例如:访问函数构造函数。

事实证明,要修复这个漏洞比修复第一个漏洞更加复杂。此外,修复方法的提出者Matt Austin也不能确定该方案的绝对有效。因此,我决定绕过这个修复方案。

寻找新的漏洞

有一件事引起了我的注意,static-eval会在定义时判断一个函数是否为恶意,而不是在被调用时。因此,它并没有考虑函数参数的值,因为参数是要在调用函数之前进行检查的。

之前,由于我无法访问函数的属性,所以思路总是想要尝试访问函数构造函数,来绕过第一个修复方法。但是,如果我尝试访问函数参数的构造函数,会发生什么呢?由于它的值在定义时还是未知的,所以这可能会让系统混淆,从而允许其执行。为了测试我的理论,我使用了这个表达式:

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

如果它返回了函数构造函数,那么我就拥有了一个可行的绕过方案。但是很遗憾,事实并非如此。如果static-eval在函数定义时访问具有未知类型的属性(在本例中,为something参数),就会阻止该函数。

static-eval有一个有用的特性,几乎在所有情况下都会被用到,就是允许在static-eval表达式中指定一些我们希望可以使用的变量。例如,在本文的一开始,我使用了表达式book.price > 100。在这种情况下,调用static-eval的代码会将book变量的值传递给它,以便可以在表达式中使用。

这就给了我一个思路:如果我使用一个与已定义变量相同名称的参数,创建一个匿名函数,那么会发生什么?由于它无法在定义时知道参数的值,因此它可能会使用变量的初始值。这对我们来说,是非常有用的。假如我有一个变量book,它的初始值是一个对象。然后,我们使用以下表达式:

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

最终,会有一个非常令人满意的结果。在定义函数时,static-eval会检查book.constructor是否为一个有效的表达式。由于book最初是一个对象(其typeof是object),而不是一个函数,因此允许访问其构造函数并会创建函数。但是,当我调用此函数时,book将把作为参数传递的值作为函数传递(在这里是"".sub,另一个函数)。然后,它将访问并返回其构造函数,从而有效地返回函数构造函数。

但又一次令我们失望的是,这并没有用,因为修复方案的作者考虑到了这样的情况。在分析函数的主体时,其所有参数的值都会被置为null,覆盖变量的初始值。其代码片段如下:

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

这段代码采用定义函数的AST节点,迭代其类型为Identifier的每一个参数,获取其名称,并将具有该名称的vars属性设置为null。即使代码看上去正确,但它也有一个非常常见的问题:没有涵盖所有可能的情况。如果参数非常奇怪,并且其类型不是标识符,那么会发生什么?实际上,它并没有因为不清楚这是什么而阻止整个函数(即:使用白名单),而是会忽略这一点并继续其余部分(即:使用黑名单)。这意味着,如果我们让函数参数的节点具有与Identifier不同的类型,那么将不会覆盖具有该名称的变量的值,因此它将会使用初始值。在这时,我们非常自信已经找到了一个关键点。接下来,我们只需要找出如何将key.type设置为与Identifier不同的类型。

正如我之前所说的那样,static-eval使用esprima库来解析我们提供的代码。根据其文档,esprima是一个完全支持ECMAScript标准的解析器。ECMAScript就像是一种支持更多功能的JavaScript方言,使得它的语法对用户来说更加舒适。

ECMAScript中添加的一个功能是函数参数解构。利用这一功能,下面的JS代码将会是有效的:

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(我是通过使用这一工具来实现的),那么无疑是一个非常满意的结果:

3.png

实际上,这个新语法使得函数参数的key.type与Identifier不同,因此static-eval在覆盖变量时不会对它进行操作。这样一来,在评估过程中:

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

static-eval将会使用book的初始值,也就是一个对象。然后,它允许创建该函数。但是当其被调用时,book会作为一个函数,因此现在会返回函数构造函数。我们终于找到了绕过的方案!

前面的表达式将返回函数构造函数,所以我只需要调用它来创建恶意函数,然后再调用这个创建的函数:

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

我尝试使用最新版本的static-eval在本地进行尝试,使用的就是这一表达式,最终得到了我想要的结果:

4.png

任务完成!我现在找到了允许绕过static-eval库从而实现代码执行的漏洞。要使这种方法能正常工作,唯一必要的条件就是要知道一个变量的名称,同时这个变量不能是函数,并且具有构造函数属性。字符串、数字、数组和对象都满足上述要求,因此这样的条件应该很容易实现。我只需要在我测试的网站上利用上述技术,就可以轻松得到远程代码执行漏洞,在编写PoC之后,就可以静静等待我的奖金了。对于有些人来说,这个过程可能看起来非常简单,但也有人会觉得非常困难。

实战失败

在完成上述所有工作之后,我们已经找到了一个完美的绕过漏洞,但我居然发现,该漏洞无法在我测试的网站中利用。如上文所述,唯一的条件是我们要知道一个变量的名称,并且该变量的值不能是函数,所以大家可能会认为我没有满足这个条件。但是,事实并非如此,它不起作用的原因要比这复杂得多。

为了给出上下文,该站点没有直接使用static-eval,而是通过jsonpath npm库来使用。JSONPate是一种与XPATH具有相同目的的查询语言,但它是用于JSON文档,而并非XML文档,该语言最初在2007年发布

在阅读了JSONPath文档后,我意识到,这是一个非常糟糕的项目。在这个项目中,提供的规范非常模糊。并且它所实现的大多数功能,可能都是后续不断添加的,并没有考虑添加它们是否有必要。令人遗憾的是,在NodeJS的生态系统中,还有很多这样的库。

JSONPath有一个被称为过滤器表达式的功能,它允许过滤器文档匹配一个指定的表达式。例如,$.store.book[?(@.price < 10)].title将得到比10美元更便宜的书籍,然后获得这些书籍的书名。在JSONPath npm库中,使用static-eval来评估括号之间的表达式。我测试的网站允许我指定一个JSONPath表达式,并使哟功能该库来对其进行解析,所以显然会产生远程代码执行漏洞。

如果我们详细看看前面的JSONPath表达式,我们可以发现,传递给static-eval的表达式是@.price < 10。根据文档记录,@是包含被过滤文档的变量(通常它是一个对象)。但不幸的事,JSONPath的作者打算命名这个变量@。根据ECMAScript规范,这并不是一个有效的变量名称。因此,为了使static-eval能正常工作,他们必须做一件可怕的事情,即修补esprima代码,因此现在它会将@视为有效的变量名。

在static-eval中创建匿名函数时,它会嵌入到另一个函数中,该函数将已经定义的变量作为参数。因此,如果我在JSONPath过滤器表达式中创建一个匿名函数,它将会创建一个包含它的函数,该函数接受一个名为@的参数。这是通过直接调用函数构造函数来完成的,因此不会涉及到之前的esprima补丁。然后,在定义函数时,将会抛出一个我无法避免的漏洞。这只是库中的一个漏洞功能,它使得在过滤器表达式中定义函数(包括合法函数和非法函数)时失败。因此,我的绕过技术并不适用于这个库。

正因为其作者在主要用于JS的库中命名变量@的愚蠢决定,特别是其中@还不是JS中的有效变量名,我无法在测试站点上成功利用远程代码执行漏洞,当然也与4位数的奖金说再见了。为什么作者不将其命名为_、document或者joseph呢!这一次,我在这个库中发现了一个严重漏洞,并且学习到了许多关于JavaScript的知识。

总结

即使我无法得到我所期待的奖金,但我也在库的漏洞挖掘中获得了足够的乐趣。我使用之前所学到的概念,绕过了另一种受限制的JS环境。

在这里,我希望再次感谢Matt Austin先前关于static-eval进行的卓越研究。如果没有这些资料,我也许不会发现这个新的漏洞。

在测试系统的过程中,在我们控制的本地环境对其进行复制并隔离是一个良好的思路,这样一来我们就能更加自由的对复制环境进行研究。在我的案例中,我使用static-eval库创建了一个Docker实例,来尝试绕过沙箱。但我也存在问题,就是在整个研究过程中只使用了这个实例,并没有证实我所做的事情在实际网站中也是有效的。如果我在过程中进行了尝试,可能就会很快发现这样的方案不起作用,可能很早就会转向其他思路了。我们要从中吸取一些教训,应该避免对整个系统进行过高程度的抽象,并且应该不断测试在模拟环境中发现的问题,而不是在研究结束后才转战到真实环境中。

最后,如果大家正在对类似的站点进行研究,也就是站点会评估沙箱中用户控制的表达式,我强烈建议大家要多花一些时间。很少会存在没有漏洞的沙箱系统,特别是如果它使用的是动态的、功能完备的编程语言,例如JavaScript、Python或Ruby。当我们发现这种沙箱绕过漏洞时,这些漏洞往往会对应用产生重大影响。

我希望大家能够喜欢这篇文章。谢谢!

时间节点

· 2019年1月2日 向NodeJS安全团队和static-eval维护者提交漏洞报告。

· 2019年1月3日 NodeJS安全团队复现了这一漏洞,并告知他们将与库的维护者取得联系。

· 2019年2月14日 漏洞情况在nmpjs网站上正式发布。

· 2019年2月15日 库已经实现修复,并发布了新版本。

· 2019年2月18日 库的README文件已跟新,并添加免责声明,表示该库不应该作为沙箱使用。

· 2019年2月26日 由于我的原始修复程序中有一个问题,导致static-eval仍然容易受到攻击,因此发布了一个新的修复程序。

本文翻译自:https://licenciaparahackear.github.io/en/posts/bypassing-a-restrictive-js-sandbox/如若转载,请注明原文地址: https://www.4hou.com/web/16574.html
点赞 0
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论