JavaScript 原型污染
前言
这几天写作业看到一篇论文,提及了“原型污染”,遂整理一篇博客,供后续翻阅。
JavaScript 的“原型”是什么?
首先,JavaScript 是基于原型(prototype-based) 的语言。他没有类的继承机制,继承是靠一套“对象串起来的链”实现的。
const user = { name: "alice" };
user.toString();
// 结果是 "alice"
// 但是 user 自己没定义 toString 啊?
user 自己没有 toString,这个方法是从它的原型上"继承"来的。
你访问
obj.x,如果obj自己没有x,就去obj.[[Prototype]]上找;还没有,就再往上找;一路找到顶端的Object.prototype,再找不到就返回undefined。
这条链叫原型链。而顶端那个 Object.prototype 是全局唯一的——所有普通对象的链最终都汇到它这里。
举个栗子:
Object.prototype.isAdmin = true;
const anOrdinaryUser = {};
console.log(anOrdinaryUser.isAdmin); // true ← 普通用户凭空变成管理员
这就是原型污染(prototype pollution) 的名字来源:他不是单纯地污染某个对象,而是能够污染一堆对象的祖宗。
攻击者是怎么做的?
有三种可能的攻击方式,值得注意的是,这些都是正常的语法:
其中,最常见的一种入口是深度合并(deep merge)。几乎所有应用都有这种工具函数,用来把用户传来的配置合并进默认配置:
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
merge(target[key], source[key]); // 递归
} else {
target[key] = source[key];
}
}
}
这时候,攻击者发送一个 JSON:
{ "__proto__": { "isAdmin": true } }
merge 递归到 target["__proto__"] 时,因为 target.__proto__ 和 source.__proto__ 指向同一个对象(都是 Object.prototype),于是 target["__proto__"]["isAdmin"] = true 实际就等于 Object.prototype.isAdmin = true。
污染成功了。 整个进程里从此刻起创建的所有对象都带着 isAdmin: true。
但是,注意这一步本身只是“脏数据”——它还没造成危害。
真正的杀伤力——gadget
gadget(小工具) 这个词是从二进制利用领域借来的,它指的是:
应用里(或依赖库里)一段本来就是合法功能的代码,它按照正常逻辑读取某个属性,但如果这个属性被污染了,它的正常逻辑就会做出危险动作。
几个经典形态:
// gadget 形态一:模板引擎选项合并
// 模板引擎接收开发者传入的配置项,若其中 clean/source 等属性被污染
Object.prototype.source = "process.mainModule.require('child_process').execSync('id')";
// 模板引擎读到 options.source(其实是继承来的),就 eval 执行了它 → RCE
// gadget 形态二:子进程调用选项
child_process.execSync("ls", env); // env 从对象继承,攻击者污染了 shell / env / NODE_OPTIONS
// gadget 形态三:沙箱逃逸
Object.prototype.shebang = "..."; // 某些 vm 模块的实现会读取它
还有一条更“框架级”的路:NODE_OPTIONS 环境变量。如果某处的选项对象能被污染到带上 NODE_OPTIONS=--require /tmp/evil.js,那么下一次 fork 出 Node 子进程时就会加载攻击者的代码。
笔者注:上面的四个例子均为 AI 给出的例子,笔者并未深入了解其中机制与触发条件(毕竟只是写作业遇到的,本人亦非研究此方向的学者),此处点到为止,有机会再深入研究吧。
所以完整攻击链是四步:
入口 (JSON/query) → 污染原语 (merge/拷贝/路径赋值) → Object.prototype 被写入 → gadget 触发 → RCE
Gadget 检测为什么难?
要找到 RCE,你必须找到 gadget。而 gadget 检测为什么难?
它藏在别人的库里。 gadget 的分布是跨整个依赖树的,你甚至不知道自己在依赖它。
静态分析要跨越库边界做污点追踪,而 JavaScript 的
for...in、动态属性名、Object.assign、解构、Proxy让“某个属性会被读”这件事极难静态判定。gadget 可以是组合的。 一个 gadget 提供值,另一个 gadget 消费它,链条长度不定。
没有对照实验。 你没法枚举所有属性名去试(那是无穷的)。
此前许多论文工作(2018 年前后)均只能够证明“进程会崩溃、toString 会报错、逻辑判断被绕过”(即 DoS 和逻辑绕过),而“污染能不能变成 RCE”只能靠零散的个案报告——“anecdotal evidence(传闻性证据)”。
后续有许多检测方法,此处笔者也没有深入探究,读者若感兴趣可以自行深入研究。
AI 声明 本文由作者撰写,AI 参与资料检索、润色与示例草稿。