原型链污染防御实战:Node.js 配置与代码修复

原型链污染防御是 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 清理历史高危依赖。
  • 验收标准是「污染请求被拒 + 全局原型无新增属性」,不要只看接口返回是否正常。