原型链污染防御是 Node.js 与前端应用在处理用户可控对象时必须覆盖的风险点。当 Object.prototype 被写入恶意属性后,所有对象都会继承该属性,攻击者可借此绕过权限判断、篡改模板输出,甚至在子进程调用中升级为远程代码执行。本文从触发原理出发,给出可直接复制的深合并函数修复代码、进程级与请求级防护配置,以及一套可验证的复现与验收脚本。
适用场景
- 后端接收 JSON 请求体并做对象合并、深拷贝、默认值填充(Express、Koa、NestJS 均常见)
- 使用
lodash.merge、jQuery.extend(true, ...)、自定义 cloneDeep 处理查询参数或 postMessage 数据 - 多租户平台按对象属性做权限判断,例如
user.role、user.isAdmin - 模板引擎(EJS、Handlebars、Pug)在渲染阶段读取继承属性
判定标准很简单:只要代码里同时存在用户可控的键名与递归赋值,就应纳入检查范围。
前置条件
- Node.js 12 以上,建议 18 LTS 或更高版本(低版本不支持
--disable-proto全部取值) - 具备应用源码修改权限与一次发布窗口
- 可在 Nginx 或 WAF 层调整请求规则
- 测试环境能复现目标接口调用,用于验收
原理说明
JavaScript 中每个普通对象通过内部属性 [[Prototype]] 指向 Object.prototype。obj.__proto__ 是访问该内部属性的历史存取器,obj.constructor.prototype 同样能到达该位置。当代码执行形如 target[key] = source[key] 的递归合并、且 key 可控时,攻击者提交 {"__proto__":{"isAdmin":true}} 就能沿原型链逐级下钻,最终把属性写到全局原型上。此后 ({}).isAdmin 返回 true,所有基于属性存在性的判断全部失效。
危害分为三个层级:
- 逻辑层:权限校验、配置开关、灰度特征被污染,形成越权或功能绕过。
- 渲染层:模板读取未知属性,可能产生 XSS 或敏感信息泄露。
- 执行层:若被污染属性传入
child_process的 options(如 shell、env、NODE_OPTIONS),可升级为命令执行。
操作步骤
步骤 1:替换不安全的递归合并函数
设计原则三条:用无原型对象承载数据、拒绝危险键名、限制递归深度。
// safeMerge.js —— 可安全处理用户输入的合并函数
const DANGEROUS = new Set(['__proto__', 'constructor', 'prototype']);
function isPlainObject(v) {
if (v === null || typeof v !== 'object') return false;
const proto = Object.getPrototypeOf(v);
return proto === Object.prototype || proto === null;
}
function safeMerge(target, source, depth = 0) {
if (depth > 32) throw new Error('merge depth exceeded');
if (!isPlainObject(target) || !isPlainObject(source)) {
throw new TypeError('only plain objects are allowed');
}
for (const key of Object.keys(source)) {
if (DANGEROUS.has(key)) continue; // 丢弃危险键
const val = source[key];
if (isPlainObject(val)) {
const base = isPlainObject(target[key]) ? target[key] : Object.create(null);
target[key] = safeMerge(base, val, depth + 1);
} else if (typeof val !== 'function') { // 拒绝函数体注入
target[key] = val;
}
}
return target;
}
module.exports = { safeMerge };
关键点:Object.keys 只遍历自有可枚举属性,不会读到原型链;Object.create(null) 切断下钻路径;depth > 32 阻断超深对象导致的栈溢出。
步骤 2:进程级禁用 __proto__ 访问器
Node.js 提供启动参数,直接移除 Object.prototype.__proto__ 存取器:
# 删除该属性:读取返回 undefined,写入静默失效
node --disable-proto=delete server.js
# 严格模式:任何访问直接抛错,便于测试期暴露问题
node --disable-proto=throw server.js
PM2 托管场景写入 ecosystem 文件:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'api',
script: './server.js',
node_args: ['--disable-proto=throw'],
max_memory_restart: '512M'
}]
};
步骤 3:冻结关键原型作为兜底
// bootstrap.js —— 必须在业务模块之前 require
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
// 校验:以下写入应静默失败(非严格模式)
Object.prototype.hacked = true;
console.log({}.hacked); // undefined
注意:冻结原型会让部分在运行期改写内置原型的老版本库报错。建议先在预发环境跑完整回归;若无法解耦,可只冻结 Object.prototype,把 –disable-proto=throw 作为主防线。
步骤 4:请求入口做键名拦截
// express 中间件
const BAD_KEYS = /(__proto__|constructor|prototype)/;
function protoGuard(req, res, next) {
const raw = JSON.stringify(req.body ?? {}) + JSON.stringify(req.query ?? {});
if (BAD_KEYS.test(raw)) {
return res.status(400).json({ error: 'invalid payload' });
}
next();
}
app.use(express.json({ limit: '256kb' }));
app.use(protoGuard);
Nginx 层补充规则,在进应用前直接拒绝(仅覆盖查询串,请求体需交给 ModSecurity 或云 WAF):
# /etc/nginx/conf.d/proto-guard.conf
map $args $proto_bad {
default 0;
"~*__proto__" 1;
"~*constructor\.prototype" 1;
}
server {
if ($proto_bad) { return 400; }
}
步骤 5:依赖与供应链审计
# 筛查含原型链污染历史的依赖
npm audit --json | grep -iE 'prototype pollution' -A3
# 按锁文件精确安装并检查关键包版本
npm ci --omit=dev
npm ls lodash minimist merge deep-extend yargs-parser
历史高危版本包括 lodash < 4.17.11、merge < 1.2.1、minimist < 0.2.1、deep-extend < 0.5.1。无法升级主版本时,用 overrides 强制锁定补丁版本:
// package.json
{
"overrides": {
"lodash": ">=4.17.21",
"minimist": ">=1.2.6"
}
}
步骤 6:源码侧排查历史危险合并点
修复合并函数只解决了新增代码,历史代码里的隐患点需要一次性清出来:
# 定位所有递归合并调用(Object.assign 为浅拷贝,可排除)
grep -rnE "(merge|assign|extend|cloneDeep)\s*\(" src/ --include=*.js \
| grep -vE "Object\.assign|safeMerge" | head -50
# 定位直接操作危险键名的逻辑
grep -rnE "__proto__|constructor\s*\[|prototype\s*\[" src/ --include=*.js
# 定位把对象直接传给子进程 options 的位置
grep -rnE "spawn\(|exec\(|fork\(" src/ --include=*.js -A3 | grep -E "env|shell|options"
判定标准:Object.assign 是浅拷贝,不会沿原型链下钻,可以保留;lodash.merge、deep-extend、jQuery.extend(true, ...) 以及自研 cloneDeep 都属于高危点,需要逐个替换为 safeMerge。第三步尤其重要——当被污染的对象被当作子进程 options 传入时,危害等级会直接从越权跳到命令执行。把命中位置与修复记录写进工单,避免同类问题在后续迭代中复发。
配置验证
第一步验证拦截效果:
curl -s -X POST http://127.0.0.1:3000/api/profile \
-H 'Content-Type: application/json' \
-d '{"__proto__":{"isAdmin":true}}' -i | head -5
# 期望:HTTP/1.1 400(被 protoGuard 拦截)
第二步验证全局原型未被污染——这一步才是关键,拦截失败时会直接暴露:
node -e "
const http=require('http');
const body=JSON.stringify({__proto__:{isAdmin:true}});
const r=http.request({port:3000,path:'/api/profile',method:'POST',
headers:{'Content-Type':'application/json','Content-Length':body.length}},
res=>{res.resume();});
r.end(body);
setTimeout(()=>console.log('isAdmin =',({}).isAdmin),300);
"
# 期望输出:isAdmin = undefined
Nginx 侧验证:
nginx -t && nginx -s reload
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1/?__proto__[isAdmin]=1'
# 期望:400
常见问题
FAQ 1:正常业务请求也被拒,如何调整规则
多为正则过宽所致。把 BAD_KEYS 从全文匹配改为「仅键名精确匹配」,或直接改成白名单模式:按业务 schema 列出允许的键名,不在清单内的键丢弃而非整体拒绝。改完需确认前端没有把 constructor 一类词用作业务字段名。
FAQ 2:执行 Object.freeze 后第三方库启动报错怎么办
报错说明该库在运行期改写了内置原型,常见于老版本 ORM、日期库与模板引擎。处理顺序:先根据报错栈定位到具体包,查其更新日志是否已修复,可升级则升级;不能升级时退回「只冻结 Object.prototype」,把 –disable-proto=throw 作为主要防线,并在启动流程中明确该库的加载顺序,避免在冻结之后执行。
FAQ 3:只做输入过滤是否足够
不够。输入过滤依赖正则完备性,编码变形(Unicode 转义、数组形式 a[__proto__][x]=1、大小写混杂)都可能绕过。正确做法是三层同时落地:输入层拒绝危险键、代码层使用 Object.create(null) 且只遍历自有属性、进程层用 –disable-proto=throw 兜底。
总结
- 根因是「用户可控键名 + 递归赋值」,修复点必须落在合并函数本身,而不是外围补丁。
- 用 Object.create(null) 承载数据、用 Object.keys 遍历自有属性、丢弃
__proto__/constructor/prototype三个键。 - 进程级加 –disable-proto=throw,配合
Object.freeze(Object.prototype)形成兜底。 - 请求入口做键名拦截,并定期用
npm audit与overrides清理历史高危依赖。 - 验收标准是「污染请求被拒 + 全局原型无新增属性」,不要只看接口返回是否正常。