跳到主要内容

原型链污染 (Prototype Pollution)

原型链污染是 JavaScript 特有的安全漏洞,它利用了 JavaScript 基于原型的继承机制。攻击者通过修改全局的 Object.prototype,能够影响应用程序中所有对象的行为,可能导致权限绕过、拒绝服务,甚至远程代码执行。

理解 JavaScript 原型链

要理解原型链污染,首先需要理解 JavaScript 的原型继承机制。

原型继承基础

在 JavaScript 中,几乎所有对象都继承自 Object.prototype。当你访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript 会沿着原型链向上查找:

const obj = {};
console.log(obj.toString); // function toString() { [native code] }

// obj 本身没有 toString 方法
// 但它从 Object.prototype 继承了这个方法
console.log(obj.hasOwnProperty('toString')); // false
console.log(Object.prototype.hasOwnProperty('toString')); // true

__proto__ 与原型链

每个 JavaScript 对象都有一个内部属性 [[Prototype]](在代码中通常通过 __proto__ 访问),指向其原型对象:

const obj = {};
console.log(obj.__proto__ === Object.prototype); // true

// 原型链的终点是 null
console.log(Object.prototype.__proto__); // null

原型链污染的本质

当攻击者能够修改 Object.prototype 时,所有继承自它的对象都会受到影响:

// 攻击前
const user = { name: 'Alice' };
console.log(user.isAdmin); // undefined

// 攻击:污染原型链
Object.prototype.isAdmin = true;

// 攻击后:所有对象都被"污染"了
const anotherUser = { name: 'Bob' };
console.log(anotherUser.isAdmin); // true!
console.log(user.isAdmin); // true!

为什么原型链污染很危险?

服务器端与客户端的区别

原型链污染在 Node.js 服务端的影响比浏览器端更为严重:

环境影响范围持续时间危害程度
浏览器当前页面页面生命周期内XSS、权限绕过
Node.js整个进程进程生命周期内RCE、权限绕过、DoS

在 Node.js 中,原型链污染会影响整个进程的所有请求和操作,且可能持续运行数天甚至数周。

攻击后果

  1. 权限绕过:注入 isAdmin: true 等属性绕过权限检查
  2. 拒绝服务 (DoS):污染 toStringvalueOf 等核心方法导致程序崩溃
  3. 远程代码执行 (RCE):通过"链式利用"配合模板引擎等库执行任意代码

漏洞是如何产生的?

原型链污染通常发生在以下三种操作中:

1. 对象合并 (Object Merge)

递归合并对象时,如果没有过滤特殊键名,攻击者可以注入 __proto__

// 不安全的递归合并函数
function unsafeMerge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) {
target[key] = {};
}
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

// 攻击载荷
const maliciousInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
const userProfile = { name: 'Alice' };

unsafeMerge(userProfile, maliciousInput);

// 原型链已被污染
const anyObject = {};
console.log(anyObject.isAdmin); // true!

攻击原理分析

key"__proto__" 时:

  • target[key] 实际上是 target.__proto__
  • 对于普通对象,这就是 Object.prototype
  • 所以 target.__proto__.isAdmin = true 污染了全局原型

2. 路径赋值 (Path Assignment)

允许用户指定属性路径进行赋值的函数:

// 不安全的路径赋值函数
function setValueByPath(obj, path, value) {
const keys = path.split('.');
let current = obj;

for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}

current[keys[keys.length - 1]] = value;
}

// 攻击载荷
const config = {};
setValueByPath(config, '__proto__.polluted', 'yes');

// 原型链被污染
const obj = {};
console.log(obj.polluted); // 'yes'

3. constructor 链利用

某些过滤了 __proto__ 的代码仍然可能通过 constructor.prototype 被绕过:

// 只过滤了 __proto__,仍然不安全
function mergeWithFilter(target, source) {
for (let key in source) {
if (key === '__proto__') continue; // 过滤 __proto__

if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
mergeWithFilter(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}

// 攻击者使用 constructor.prototype 绕过
const payload = JSON.parse('{"constructor": {"prototype": {"isAdmin": true}}}');
mergeWithFilter({}, payload);

// 仍然被污染
console.log(({}).isAdmin); // true

真实漏洞案例分析

案例 1:Lodash CVE-2019-10744

漏洞描述:2019年 7 月,Snyk 安全研究团队发现 lodash 库的 defaultsDeep 函数存在原型链污染漏洞,影响所有版本。lodash 是 npm 上最流行的库之一,每月下载量超过 8000 万次,被超过 400 万个项目使用。

漏洞代码

const mergeFn = require('lodash').defaultsDeep;

// 攻击载荷
const payload = '{"constructor": {"prototype": {"a0": true}}}';

mergeFn({}, JSON.parse(payload));

// 验证漏洞
if (({}).a0 === true) {
console.log('Vulnerable to Prototype Pollution');
}

修复方案:在合并操作中检查 constructor 键名:

// lodash 的修复方案
function safeMerge(target, source) {
for (let key in source) {
// 阻止污染原型链的键名
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
// ... 正常的合并逻辑
}
}

影响版本:lodash < 4.17.12 修复版本:lodash >= 4.17.12

案例 2:从原型链污染到 RCE(EJS 模板引擎)

这是一个更严重的案例,展示了原型链污染如何导致远程代码执行。

攻击场景:一个使用 EJS 模板引擎的 Express 应用:

const express = require('express');
const app = express();

app.use(express.json());

// 假设存在原型链污染漏洞的合并函数
app.post('/api/config', (req, res) => {
unsafeMerge(appSettings, req.body);
res.send({ status: 'ok' });
});

// EJS 渲染页面
app.get('/', (req, res) => {
res.render('index', { title: 'Welcome' });
});

攻击原理

EJS 在渲染时会将配置选项合并到内部 opts 对象中。其中 escapeFunction 属性会被直接拼接到生成的模板代码中:

// EJS 内部逻辑(简化)
// 如果我们能够污染 escapeFunction,就能注入恶意代码
const src = `var __escape = ${opts.escapeFunction || 'escapeFn'};`;

攻击载荷

// 攻击者发送的请求
POST /api/config
Content-Type: application/json

{
"__proto__": {
"escapeFunction": "function() { require('child_process').execSync('id'); }"
}
}

攻击效果

用户输入

污染 Object.prototype.escapeFunction

EJS 渲染时读取被污染的属性

escapeFunction 被编译到模板代码中

渲染时执行恶意代码 → RCE

案例 3:子进程利用链

在 Node.js 中,child_process 模块会读取 Object.prototype 上的某些属性:

// 如果原型链被污染
Object.prototype.shell = '/proc/self/exe';
Object.prototype.argv0 = 'node';
Object.prototype.env = {
NODE_OPTIONS: '--require /tmp/payload.js'
};

// 后续任何 spawn 调用都可能使用这些被污染的属性
const { spawn } = require('child_process');
spawn('any-command'); // 可能执行恶意代码

深度防御策略

策略 1:过滤危险键名

在合并或复制对象时,过滤掉危险键名:

const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];

function isSafeKey(key) {
return typeof key === 'string' && !DANGEROUS_KEYS.includes(key);
}

function safeMerge(target, source) {
if (source === null || typeof source !== 'object') {
return source;
}

for (let key in source) {
if (!isSafeKey(key)) {
continue; // 跳过危险键名
}

if (typeof source[key] === 'object' && source[key] !== null) {
if (typeof target[key] !== 'object' || target[key] === null) {
target[key] = Array.isArray(source[key]) ? [] : {};
}
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}

return target;
}

// 使用示例
const userInput = JSON.parse(req.body.data);
const safeConfig = safeMerge({}, userInput);

策略 2:使用 Object.create(null)

创建不继承原型的"干净"对象:

// 创建没有原型的对象
const safeDict = Object.create(null);
console.log(safeDict.__proto__); // undefined
console.log(safeDict.constructor); // undefined

// 安全地使用用户输入作为键
function safeSet(obj, key, value) {
// 使用无原型对象作为中介
const temp = Object.create(null);
temp[key] = value;

// 检查是否成功设置(避免原型链污染)
if (temp[key] === value) {
obj[key] = value;
}
}

策略 3:使用 Map 替代普通对象

Map 数据结构不受原型链影响:

// 使用 Map 存储用户数据
const userStore = new Map();

// 安全操作
userStore.set('__proto__', 'safe value');
console.log(userStore.get('__proto__')); // 'safe value'

// Map 不会受到原型链污染影响
const anotherMap = new Map();
console.log(anotherMap.get('__proto__')); // undefined(而不是被污染的值)

策略 4:Node.js 启动参数保护

Node.js 12+ 提供了启动参数来限制原型链操作:

# 禁用 __proto__ 属性
node --disable-proto=delete server.js

# 或者使用 throw 模式(访问 __proto__ 时抛出错误)
node --disable-proto=throw server.js

注意:这可能会影响某些依赖 __proto__ 的库的正常运行。

策略 5:冻结内置原型

在应用启动时冻结 Object.prototype

// 在应用最早期执行
Object.freeze(Object.prototype);

// 尝试修改将静默失败(严格模式下抛出错误)
Object.prototype.evil = true;
console.log({}.evil); // undefined(修改失败)

注意:这可能影响某些依赖动态扩展原型的库,需要在生产环境充分测试。

策略 6:使用安全的库函数

优先使用经过安全审计的库:

// 使用安全的深拷贝/合并库

// 方案 1:使用 lodash 4.17.12+ 并配合自定义校验
const _ = require('lodash');

function safeDefaults(target, source) {
// 先过滤危险键
const cleanSource = _.omit(source, ['__proto__', 'constructor', 'prototype']);
return _.defaultsDeep({}, target, cleanSource);
}

// 方案 2:使用专门的库如 dset
const { dset } = require('dset');
// dset 内置了原型链污染防护
dset(obj, 'user.name', 'Alice');

策略 7:使用 hasOwnProperty 检查

在关键逻辑中使用 hasOwnProperty 区分自身属性和继承属性:

// 不安全的权限检查
function checkPermission(user) {
if (user.isAdmin) { // 危险:可能读取被污染的属性
return true;
}
return false;
}

// 安全的权限检查
function checkPermissionSafe(user) {
if (Object.prototype.hasOwnProperty.call(user, 'isAdmin') && user.isAdmin) {
return true;
}
return false;
}

// 或者使用 Object.hasOwn(Node.js 16.9+ / ES2022)
function checkPermissionModern(user) {
if (Object.hasOwn(user, 'isAdmin') && user.isAdmin) {
return true;
}
return false;
}

安全检测与测试

静态代码分析

搜索代码中的危险模式:

# 搜索可能存在问题的模式
grep -r "__proto__" --include="*.js" .
grep -r "constructor.prototype" --include="*.js" .
grep -r "\.prototype\[" --include="*.js" .

# 搜索危险函数调用
grep -r "Object.assign(.*req\." --include="*.js" .
grep -r "\.merge(.*req\." --include="*.js" .
grep -r "\.extend(.*req\." --include="*.js" .

动态测试载荷

测试应用程序是否存在原型链污染漏洞:

// 测试载荷集合
const testPayloads = [
// 直接使用 __proto__
{ '__proto__': { 'polluted': 'yes' } },

// 使用 constructor.prototype
{ 'constructor': { 'prototype': { 'polluted': 'yes' } } },

// 嵌套路径
{ '__proto__': { '__proto__': { 'polluted': 'yes' } } },

// 数组绕过尝试
{ '__proto__': [ { 'polluted': 'yes' } ] },

// JSON 字符串形式
'{"__proto__":{"polluted":"yes"}}'
];

// 测试函数
function testForPollution(payload) {
const testObj = {};

try {
// 尝试应用载荷(根据你的代码调整)
unsafeMerge(testObj,
typeof payload === 'string' ? JSON.parse(payload) : payload
);
} catch (e) {
// 解析错误
}

// 检查是否被污染
const check = {};
return check.polluted === 'yes';
}

// 运行测试
testPayloads.forEach((payload, i) => {
if (testForPollution(payload)) {
console.log(`Payload ${i + 1} is vulnerable!`);
}
});

使用自动化工具

# 使用 npm audit 检查依赖漏洞
npm audit

# 使用 Snyk 扫描
npx snyk test

# 使用 OWASP Dependency-Check
dependency-check --scan ./

安全检查清单

代码审计

  • 所有对象合并操作都过滤了 __proto__constructorprototype
  • 用户输入不经检查不用于属性路径赋值
  • 关键权限检查使用 hasOwnPropertyObject.hasOwn
  • 敏感操作不依赖对象属性的默认值

依赖管理

  • lodash 版本 >= 4.17.12
  • 所有依赖都经过安全扫描
  • 定期更新依赖以修复已知漏洞
  • 使用 npm audit 或 Snyk 监控依赖安全

运行时防护

  • 考虑使用 --disable-proto=delete 启动参数
  • 考虑在应用启动时冻结 Object.prototype
  • 使用 Map 替代普通对象存储用户数据
  • 输入验证时检查危险键名

总结

原型链污染是一种独特且危险的 JavaScript 安全漏洞,其核心在于攻击者能够修改全局的 Object.prototype,进而影响应用程序中所有对象的行为。

核心要点

  1. 理解漏洞本质:JavaScript 的原型继承机制使得修改 Object.prototype 可以影响所有对象
  2. 识别危险操作:对象合并、路径赋值、JSON 解析是常见的漏洞入口
  3. 认识危害程度:服务端原型链污染可能导致 RCE,比客户端更为严重
  4. 实施深度防御:过滤危险键名、使用 Object.create(null)、冻结原型等多种措施并用

防御优先级

1. 使用安全的库(lodash 4.17.12+)
2. 过滤 __proto__、constructor、prototype 键名
3. 使用 Object.create(null) 或 Map 存储用户数据
4. 关键检查使用 hasOwnProperty
5. 考虑冻结 Object.prototype

参考资料

进一步学习

原型链污染通常与其他漏洞结合利用。想要了解其他常见的 Web 安全漏洞,请阅读 XSS 跨站脚本攻击SQL 注入