关于 js 原型链污染的学习
理论知识
参考文章
https://blog.csdn.net/u012468376/article/details/53121081
https://juejin.cn/post/7201668416170901563
https://blog.csdn.net/qq_51586883/article/details/119867720
关于原型的知识
原型是 JavaScript 中继承的基础,JavaScript 的继承即基于原型的继承
听起来很模糊,那么我们可以举个例子。
首先,创建一个函数 Test
function Test(){}
那么在函数创建的同时,内存中也会创建一个对象。在函数 Test 中默认存在一个属性 prototype
指向这个对象。这个对象也就是函数 A 的原型对象,简称函数 Test 的原型。
如何获取到这个对象呢,可以通过 Test.prototype
获取。如图
而这个通过原型对象也可以找到这个函数。即Test.prototype.constructor
那么现在,如果我们用这个函数作为构造函数来创建一个对象。var test = new Test()
那么这个创建出来的对象存在一个不可见的属性指向构造函数的原型对象。这个属性也就是__proto__
即 test.__proto__
与Test.prototype
是一样的,都指向 Test 的原型对象。
而在 JavaScript 里万物皆对象,原型对象也有属于自己的 __proto__
属性。访问 test.__proto__.__proto__
得到的就是Object.prototype
。这样便构成了一条原型链,最终当指向 null 时停止。
在一位佬的博客上看到的图
所以总结一下,以 Test 和 test 为例,如果一个函数要访问自己的原型,使用 Test.prototype
。而如果使用函数构造出来的对象访问原型的话就是test.__proto__
了。
接下来到了重点。
当一个函数,也就是类。在创建对象后,创建出来的对象会拥有函数的原型所拥有的属性和方法
有点绕。还是以上文的 Test 举例子。
创建出的 test 对象,拥有 Test 原型所拥有的属性和方法。Test 原型有的,test 也一样全都有。
举一个 Father 和 son 之间的例子
function Father(){
this.age=60
this.sex='male'
}
function Son(){this.age=18}
var son1 = new Son()
console.log(son1.age)
console.log(son1.sex)
Son.prototype=new Father()
var son2 = new Son()
console.log(son2.sex)
根据理论,son1.age
会输出 18,因为创建的对象包含此属性,而 son1.sex
不会输出任何值,因为没有 sex 这个属性. 而 son2.sex
则会输出 male。原因是因为,我们将类 Son 的原型指向了 Father. 那么新创建的对象 son2 也就具备了 Son 类的原型的属性。
事实也确实如此,如下:
而同时,还有一个特性。当访问对象属性的时候,会首先查找对象是否含有此属性。如果没有,则会在原型链上查找,但不会查找自身的 prototype。可以结合上图的原型链理解。
原型链中,对象的 proto 是原型,而原型也是一个对象,也有自己的 proto 属性。这样便可以一直通过 proto 向上找,直到找到 null。
原型链污染
借助原型链,我们可以做一些动作。
依旧还是举例
var test = {a:1}
console.log(test.a)
test.__proto__.a = 2
console.log(test.a)
var t = {}
console.log(t.a)
此处,test 对象含有属性 a,值为 1。然后我们更改 test.__proto__.a=2
,相当于使得构造函数的原型即 Object 多了一个属性a:2
。这也就造成了原型链污染。此时我们先查看test.a
由于顺序问题,所以此处依旧还是 1. 但当我们新创建一个空对象 t。访问t.b
。可以看到,本来是空对象的 t 却在访问 b 属性时输出了值。
原因是:当访问对象 t 属性的时候,先查找,发现对象 t 不含此属性。便在原型链上查找。而我们又对其原型链进行了污染,使得 Object 对象含有属性 b。而同样用构造函数创建出来的对象 t。其 t.__proto__
也指向 Object。自然 Object 有的属性,t 也一样会有。
图片中的 b 是写多了哈哈哈哈哈哈哈
那么在题目中一般怎样才能够让我们进行原型链污染呢?
首先最为常见的就是 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]
}
}
}
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
如上面的例子, 仔细分析一下
首先由于 o1 中不存在 a,所以首先会正常的将 "a":1
合并 o1 中,此处很简单。
接下来,target 会变成 o1[proto],source 会变成 o2[proto],接下来 key 就变成了 b 了,然后就会进行:o1[proto] [b]=o2[proto] [b]=2. o2[proto]就是 {"b":2}。这里 o1[proto] [b] 就相当于 o1.proto.b= 2 也相当于 Object.prototype.b=2。这样就成功造成了原型链污染。
那么,与 merge 函数一样可以造成原型链污染的函数有很多,诸如 clone,update 等函数
比如 update 函数
function update(dst, src) {for (key in src) {if (typeof src[key] == "object" && dst[key] !== undefined) {update(dst[key], src[key]);
continue;
}
dst[key] = src[key];
}
}
仔细分析发现,其实原理都是一样的。
题目复现
正好写文章的时候正在打 Hgame2024_week3。也正好遇到了一个原型链污染的题,因此就顺手复现一下吧。
Hgame2024_week3[WebVPN]
题目给出了 js 源码,开始分析。
分析源码的过程中,发现了 update 函数,考虑是否存在原型链污染。
function update(dst, src) {for (key in src) {if (key.indexOf("__") != -1) {continue;}
if (typeof src[key] == "object" && dst[key] !== undefined) {update(dst[key], src[key]);
continue;
}
dst[key] = src[key];
}
}
而后发现了 /user/info
路由,需要我们进行 post 传参, 同时在这里调用 update 函数,也正是原型链污染的漏洞之处。
app.post("/user/info", (req, res) => {if (!req.session.username) {res.sendStatus(403);
}
update(userStorage[req.session.username].info, req.body);
res.sendStatus(200);
});
紧接着,继续阅读代码,发现了 /flag
路由,其中对我们的请求体各个方面做了限制,相当于从本地访问路由了。
app.get("/flag", (req, res) => {
if (
req.headers.host != "127.0.0.1:3000" ||
req.hostname != "127.0.0.1" ||
req.ip != "127.0.0.1"
) {res.sendStatus(400);
return;
}
const data = fs.readFileSync("/flag");
res.send(data);
});
于是思考如何才能绕过限制,分析代码,发现了 /proxy
路由,此处也是整道题最为重要的部分,需要理清中间的代码逻辑。
app.use("/proxy", async (req, res) => {const { username} = req.session;
if (!username) {res.sendStatus(403);
}
let url = (() => {
try {return new URL(req.query.url);
} catch {res.status(400);
res.end("invalid url.");
return undefined;
}
})();
if (!url) return;
if (!userStorage[username].strategy[url.hostname]) {res.status(400);
res.end("your url is not allowed.");
}
try {
const headers = req.headers;
headers.host = url.host;
headers.cookie = headers.cookie.split(";").forEach((cookie) => {
var filtered_cookie = "";
const [key, value] = cookie.split("=", 1);
if (key.trim() !== session_name) {filtered_cookie += `${key}=${value};`;
}
return filtered_cookie;
});
const remote_res = await (() => {if (req.method == "POST") {
return axios.post(url, req.body, {headers: headers,});
} else if (req.method == "GET") {
return axios.get(url, {headers: headers,});
} else {res.status(405);
res.end("method not allowed.");
return;
}
})();
res.status(remote_res.status);
res.header(remote_res.headers);
res.write(remote_res.data);
} catch (e) {res.status(500);
res.end("unreachable url.");
}
});
分析整个 /proxy
路由可以发现,需要我们传入的 session 正确,做题的时候发现,其实我们根本不需要对 session 对任何修改,因为题目默认就是 username。然后,将从请求头中获得到的 url 作为参数新建一个 URL 对象。同时判断,userStorage
对象中是否存在此 url。然后,构造 headers,host,cookie 等作为请求头采用 get 或 post 方式访问 url。
因此我们的方法是,通过 /proxy
构造的 url 对象访问 /flag
路由获得 flag。
因此所需要访问的 url 为 /proxy?url=http://127.0.0.1:3000/flag
才能达到从本地访问 /flag
的效果。
那么在代码中写道,url 必须在 userStroage
对象中存在才可以,我们怎么办到呢?
自然是通过原型链污染。代码中将我们输入的对象与 userStroage
里的内容作为参数,放到 update 里进行更新,我们输入的对象是可控的。因此只需要让 Object 类存在此 url 即可。
代码为
"a": 1, "constructor":{"prototype":{"127.0.0.1":true}}
这样便可以成功污染,当在 userStorage
中寻找此 url 时,由于寻找不到,就会寻找原生类中是否存在此 url,而原生类又被我们成功污染,因此便可以找到。
因此,总的做题流程为,在 /user/info
处进行原型链污染
此处需要注意一点,源代码中 ban 了 __
因此无法用 __proto__
,所以我们可以用constructor.prototype
来绕过
然后,刷新 /home
界面,并访问 url。且抓包修改我们所需要的 url。