原创 首页 技术

JavaScript 原型污染

LinJHS 15

前言

这几天写作业看到一篇论文,提及了“原型污染”,遂整理一篇博客,供后续翻阅。

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) 的名字来源:他不是单纯地污染某个对象,而是能够污染一堆对象的祖宗。

攻击者是怎么做的?

有三种可能的攻击方式,值得注意的是,这些都是正常的语法:

路径

含义

obj.__proto__

一个遗留的访问器属性,读写它就等于读写 [[Prototype]]

obj.constructor.prototype

取到构造函数,再拿它的 prototype,绕到同一地方

obj["constructor"]["prototype"]

同上,只是用字符串下标绕过一些过滤

其中,最常见的一种入口是深度合并(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 参与资料检索、润色与示例草稿。

分享
如果这篇文章帮到你,可以请我喝一杯茶。

微信扫码打开

请我喝杯茶

如果这篇文章帮到你,可以请我喝一杯茶。

微信收款码
在商城请我一杯