一、前言
在参与某个漏洞赏金计划时,我发现某个站点的功能非常有趣:该站点允许用户使用可控的表达式来过滤数据。我可以使用类似book.price > 100
的表达式来显示价格高于100美元的书籍,使用true
为过滤器则显示所有书籍,false
为过滤器不显示任何内容。因此,我可以知道所使用的表达式结果为true
还是false
。
该功能成功吸引了我的注意,因此我尝试传入更为复杂的表达式,如(1+1).toString()==="2"
(结果为真)以及(1+1).toString()===5
(结果为假)。这显然是JavaScript代码,因此我猜测我使用的表达式会传入NodeJS服务器上类似eval
的某个函数。此时貌似我找到了一个远程代码执行(RCE)漏洞。然而,当我使用更为复杂的表达式时,服务器返回错误,提示表达式无效。我猜测服务端并没有使用eval
函数来解析表达式,而是使用了JavaScript的某种沙箱。
在受限环境中使用沙箱来执行不可信代码通常并不完美。在大多数情况下,我们已经有一些方法能够绕过这种保护措施,以普通权限来执行代码。如果目标环境尝试限制使用像JavaScript之类复杂功能的语言,那么防护起来更难面面俱到。发现这个问题后,我决定花些时间尝试突破这个沙箱系统。我需要了解JavaScript内部工作原理,这样才能有助于查找并利用RCE。
我首先需要确定网站使用哪个库来实现沙箱,因为整个NodeJS生态中有数十个库可以实现该功能,并且在许多情况下这些实现方案并不完美。也有可能目标网站使用了自定义的沙箱,但这种可能性较小,因为开发者需要较多精力才能做到这一点。
最后,我通过分析应用的错误信息发现目标站点使用的是static-eval,这个库没有那么知名(但开发者是substack,是NodeJS社区的一个名人)。虽然这个库最初并不是针对沙箱场景而设计(其实我现在也不了解这个库最开始的使用场景),但文档中的确涉及相关内容。目前,我测试的这个站点的确将该库用于沙箱环境。
二、绕过static-eval
static-eval
的原理是使用esprima库来解析JS表达式,将其转化为AST(抽象语法树)。给定AST和我们输入的变量对象后,目标会尝试计算表达式。如果目标发现某一点存在异常,那么函数就会失败,不会执行我们输入的代码。因为这一点,最开始时我有点动力不足,因为我发现这个沙箱系统对能接收的数据非常严格。我甚至不能在表达式中使用for
或者while
语句,因此想执行需要迭代算法的操作几乎无法完成。无论如何,我一直在尝试寻找系统中是否存在任何bug。
粗略分析后我并没有找到任何bug,因此我查看了static-eval
GitHub项目的commits
和pull
请求。我发现其中有个pull请求修复了2个bug,这些bug可以规避沙箱环境,这正是我所寻找的答案。我也发现了pull请求作者发表过的一篇文章,其中深入分析了这些漏洞。因此,我第一时间在目标站点上测试了这些技术,但不幸的是,目标站点使用的是新版的static-eval
,已经修复了这些漏洞。然而,当发现有人曾成功绕过沙箱后,我对自己也更有信心,因此开始寻找能规避沙箱的新方法。
随后,我深入分析了这两个漏洞,希望这些漏洞能帮我找到思路,发现该库中的新漏洞。
三、分析第一个漏洞
第一个漏洞使用了constructor来构造恶意函数,攻击者经常使用这种技术来绕过沙箱。比如,在绕过angular.js沙箱以获得XSS攻击点的大多数方法中,最终都会使用能够调用constructor
的payload。攻击者也常使用这种方法来绕过与static-eval
类似的库,如vm2
。例如,我们可以通过如下表达式打印出系统环境变量,证实漏洞的确存在(因为沙箱的存在,该操作可能不会成功):
"".sub.constructor("console.log(process.env)")()
在如上代码中,"".sub
是获得函数的一个简单方法((function(){})
也能实现类似功能),随后再获取该函数的constructor
。当调用该函数后会返回一个新函数,该函数具体代码为传入的字符串参数。这类似于eval
函数,但并没有立即执行代码,而是返回可以执行代码的一个函数。这就可以解释payload结尾为什么会有()
,我们可以通过这种方式来调用该函数。
我们可以执行更多操作,而不单单是打印环境变量。比如,我们可以使用NodeJS child_process
模块的execSync
函数来执行操作系统命令并返回输出结果。如下payload会返回id
命令的输出结果:
"".sub.constructor("console.log(global.process.mainModule.constructor._load("child_process").execSync("id").toString())")()
上面的payload与之前的payload类似,不同点在于所创建函数的具体代码。在该代码中,global.process.mainModule.constructor._load
与NodeJS中require
函数的功能一样。由于我没注意到的某些原因,函数constructor
内部无法使用require
,因此我只能使用这种不优雅的表达方式。
开发者通过阻止对函数对象属性的访问(通过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;
}
这种修复方式非常简单,但也非常有效。由于constructor
只能在函数中使用,因此现在我已无法访问该接口。对象的typeof
属性无法修改,因此只要是函数,那么typeof
必定等于function
。我没有找到绕过这种防护的办法,因此我接着分析第二个漏洞。
四、分析第二个漏洞
与第一个漏洞相比,这个漏洞更加简单,也更加容易发现:问题在于沙箱允许我们创建匿名函数,但并没有检查函数内容,没有禁用恶意代码。实际上,我们可以将函数体直接传递给构造函数。如下代码能够实现与前面第一个payload同样的效果:
(function(){console.log(process.env)})()
我们可以修改匿名函数的函数体,使用execSync
来显示系统命令的执行结果,这部分工作留给大家来完成。
对于该漏洞,一种可能的修复方式是禁用static-eval
表达式内部的所有匿名函数声明语句。然而,这样可能会阻止匿名函数的正常使用(比如,正常情况下匿名函数可以用来映射数组)。因此,缓解措施必须允许正常匿名函数的使用,同时还要阻止恶意的使用方式。开发者可以分析已定义函数的函数体,检查该函数不会执行任何恶意操作,比如访问构造函数。
实际的修复措施比第一个漏洞的修复方式要更为复杂。与此同时,Matt Austin(提出缓解措施的开发者)表示自己并不确定这种方法是否能够完美解决问题。因此,我决定找到绕过这种修复措施的方法。
五、寻找新漏洞
我注意到一个细节,static-eval
会在函数定义时判断目标是否为恶意函数,而不在函数被调用时进行判断。因此static-eval
并不会去考虑函数参数的具体值,因此这样就需要在函数被调用时进行判断。
我常用的做法就是尝试访问构造函数,通过某种方式绕过第一种修复措施(因为我无法访问函数的属性)。然而,如果我尝试访问函数参数的constructor
时会出现什么情况?由于函数定义时并不知道这个值,因此我们有可能借此绕过系统的限制机制。为了测试这一点,我使用了如下表达式:
(function(something){return something.constructor})("".sub)
如果上面语句返回了cnostructor
,那么我们成功找到了绕过办法。不幸的是事实并非如此。如果某个函数在函数定义时访问某个未知类型的某个属性时,就会被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
),并非函数,因此我们可以访问其constructor
,成功创建函数。然而,当我调用该函数时,book
会将传入的值作为参数传递给另一个函数(即"".sub
,另一个函数)。然后访问并返回其constructor
,最终成功返回constructor
。
不幸的是,这种方法依然无法成功,因为开发者也考虑到了这种情况。在分析函数体时,所有参数的值会被设置为null
,覆盖变量的初始值。部分代码如下所示:
node.params.forEach(function(key) {
if(key.type == 'Identifier'){
vars[key.name] = null;
}
});
这段代码会处理定义该函数的AST节点,遍历类型为Identifier
的每个参数,提取其名称并将对应的vars
属性为null
。即便上述代码看起来非常正确,但也犯下了一个非常常见的错误:并没有覆盖所有可能的情况。如果某个参数比较奇怪,其类型不等于Identifier
会出现什么情况?修复代码并没有采用白名单机制,会忽略该参数,继续执行剩余代码(类似黑名单机制)。这意味着如果我构造的节点类型与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(我使用的是这款工具),就能看到一个非常令人满意的结果:
实际上,这种新的语法可以让函数参数的key.type
值不等于Identifier
,因此static-eval
在覆盖变量时不会处理该参数。通过这种方法,当执行如下表达式时:
(function({book}){return book.constructor})({book:"".sub})
static-eval
会使用book
的初始值(这是一个对象),然后我们也能创建函数。但当函数被调用时,book
就会变成一个函数,因此就能返回函数的constructor
。现在我的确找到了绕过方法!
之前的表达式会返回函数的constructor
,因此我只需要调用constructor
来创建恶意函数,然后再调用新创建的函数即可。
(function({book}){return book.constructor})({book:"".sub})("console.log(global.process.mainModule.constructor._load("child_process").execSync("id").toString())")()
我在本地环境安装了最新版的static-eval
,并测试了这个表达式,结果与我的预期相符:
任务完成!我找到了绕过static-eval
库的一种方法,能够在使用该库的目标主机上执行代码。唯一的条件是需要知道变量值不是函数的某个变量名,并且该变量包含constructor
属性。字符串(strings)、数字(numbers)、数组(arrays)以及对象(objects)都满足这些条件,因此这对我们来说应该不难。我只需要在我测试的网站上使用这种技术,证实目标存在RCE利用点即可。但事情真的那么简单吗?
六、无法在目标环境中生效
不幸的是,即使我找到了优雅并且可用的绕过方法后,我发现我测试的目标站点上并不能使用这种技术。唯一的条件是我们要掌握变量值不为函数的某个变量名,因此大家可能觉得我找不到这个切入点,导致我无法利用漏洞。然而事实上我的确满足这个条件,之所以无法成功利用,原因有点复杂。
这里介绍一下相关背景,该网站并没有直接使用static-eval
库,而是通过jsonpath npm库来使用目标库。JSONPath是与XPATH类似的一个查询语言,但处理的是JSON文档而非XML文档,该库最早于2007年问世(参考此处文章)。
阅读JSONPath文档后,我发现这个项目非常糟糕,没有明确的规范,所实现的大多数功能有点随性而为之的感觉,并不去考虑添加这些功能是否必要。可惜的是NodeJS生态系统中充斥着类似的库。
JSONPath具有一个过滤器表达式功能,可以过滤匹配给定表达式的文档。比如,我们可以使用$.store.book[?(@.price < 10)].title
来筛选价格低于$10
的书籍,然后获取书籍的标题。jsonpath npm库使用static-eval
来计算括号之间的表达式。我测试的站点可以让我指定JSONPath表达式,然后使用该库来解析这个表达式,因此RCE也就顺手拈来。
如果我们仔细观察上面那个JSONPath表达式,可以看到传递给static-eval
的表达式为@.price < 10
。根据文档描述,@
是包含待过滤文档的一个变量(通常是一个对象)。不幸的是,JSONPath开发者采用的是@
这个符号。根据ECMAScript规范,该符号并不是一个有效的变量名。因此为了让static-eval
能够正常工作,开发者需要修改esprima代码。使其能够将@
当成一个合法的变量名。
当我们在static-eval
中创建匿名函数时,匿名函数会嵌入另一个函数中,后者将其当成已定义的变量参数来使用。因此如果我在JSONPath过滤器表达式中创建一个匿名函数,那么它将创建包含该函数的一个函数,并且接受名为@
的一个参数。程序会直接调用constructor
来完成该操作,因此并没有使用之前的esprima补丁。然后,当定义函数时,它会抛出我无法规避的一个错误。这是这个库的一个bug,导致我们无法在过滤器表达式中定义函数(不管是不是恶意函数)。因此,本文介绍的这种绕过技术无法适用于这个库。
由于开发者在目标库中使用了@
来命名变量,而该符号又不是JS中的有效变量名,因此我无法在目标站点中利用RCE漏洞,也没法获得4位数的漏洞赏金。为什么开发者不使用_
(这是一个有效的变量名)、document
或者joseph
!经过这次学习,我找到了某个库中很大的一个漏洞,也学到了关于JavaScript的不少知识。
七、总结
即使我没有拿到预期的赏金,在与这个库搏斗的过程中我也乐此不疲。我也利用学到的知识绕过了另一个受限JS环境,这一次我并没有空手而归,回头我希望能够尽快公布相关研究报告。
这里还得再次感谢Matt Austin之前关于static-eval的研究成果,没有这些成果支撑,也许我不会发现这个新的漏洞。
在测试目标系统方面,我们可以考虑在本地环境中复现并控制系统的每个功能,这样我们测试起来可以更加自由。在本文中,我使用了部署static-eval
库的一个Docker实例,在此基础上尝试绕过沙箱。我的问题在于,我在整个研究过程中只使用过这个实例,没有去验证这种技术在实际网站中是否可用。如果我先验证了这一点,很可能早就发现这一点,可以早点腾出手来。这里我们可以吸取教训,不要过于抽象整个环境,我们需要不断测试在实际系统中的发现,而不是埋头钻到实验环境中。
最后提一下,如果我们正在审计部署类似系统的站点,该站点会将用户可控的表达式在沙箱中执行,我建议大家可以好好用心分析这个环境。沙箱系统很难尽善尽美,如果沙箱能够执行动态、全功能的编程语言(如JavaScript、Python或者Ruby)的话更难面面俱到。当我们发现沙箱绕过漏洞时,这种漏洞往往能够对包含该系统的应用造成重大影响。
八、时间线
- 01/02/19 – 将漏洞信息提交给NodeJS安全团队以及static-eval维护人员,大家可以参考原始报告
- 01/03/19 – NodeJS安全团队复现漏洞,告诉我他们将联系程序库维护人员,如果维护人员不响应报告,则会公布安全公告
- 02/14/19 – nmpjs网站上公布安全公告
- 02/15/19 – 漏洞已被修复 ,发布新版程序库
- 02/18/19 – 程序库的README文件已更新,提到该库不应该作为沙箱来使用
- 02/26/19 – 发布新的补丁,因为我最早的补丁有个bug,导致
static-eval
仍然存在漏洞