MyGO’s Live!!!!!
启动题目环境:
根路由
1 2 3 4 5 6 7 8 9 10 11
| app.get('/', (req, res) => { fs.readFile(__dirname + '/public/index.html', 'utf8', (err, data) => { if (err) { console.error(err); res.status(500).send('Internal Server Error'); } else { res.send(data); } }) }
|
/checker路由:
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
| app.get('/checker', (req, res) => { let url = req.query.url; if (url) { if (url.length > 60) { res.send("我喜欢你"); return; } url = [...url].map(escaped).join(""); console.log(url);
let host; let port; if (url.includes(":")) { const parts = url.split(":"); host = parts[0]; port = parts.slice(1).join(":"); } else { host = url; } let command = "";
if (port) { if (isNaN(parseInt(port))) { res.send("我喜欢你"); return; } command = ["nmap", "-p", port, host].join(" "); } else { command = ["nmap", "-p", "80", host].join(" "); }
var fdout = fs.openSync('stdout.log', 'a'); var fderr = fs.openSync('stderr.log', 'a'); nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } );
nmap.on('exit', function (code) { console.log('child process exited with code ' + code.toString()); if (code !== 0) { let data = fs.readFileSync('stderr.log'); console.error(`Error executing command: ${data}`); res.send(`Error executing command!!! ${data}`); } else { let data = fs.readFileSync('stdout.log'); console.error(`Ok: ${data}`); res.send(`${data}`); } }); } else { res.send('No parameter provided.'); } });
|
逐行解释:
url变量从请求的url参数获值,如果url长度大于60渲染”我喜欢你”,
该行代码将url字符串分隔为字符数组逐个参与escaped()函数,最后再连接起来成为一个新的字符串
1
| url = [...url].map(escaped).join("");
|
escaped()函数中一共过滤了以下字符:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ' '//空格 '$' '\' '`'//反引号 '"' '|' '&' ';' '<' '>' '(' ')' ''' '\n' '*'
|
下面判断url中是否存在:
,对host和port进行赋值
然后拼接构造一个nmap的命令,将各个字符串用空格分隔开拼接起来:
1 2 3 4 5 6 7 8 9
| if (port) { if (isNaN(parseInt(port))) { res.send("我喜欢你"); return; } command = ["nmap", "-p", port, host].join(" "); } else { command = ["nmap", "-p", "80", host].join(" "); }
|
起一个bash进程执行nmap命令:
1
| nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } );
|
对nmap对象进行处理,发现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| var fdout = fs.openSync('stdout.log', 'a'); var fderr = fs.openSync('stderr.log', 'a'); nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } ); nmap.on('exit', function (code) { console.log('child process exited with code ' + code.toString()); if (code !== 0) { let data = fs.readFileSync('stderr.log'); console.error(`Error executing command: ${data}`); res.send(`Error executing command!!! ${data}`); } else { let data = fs.readFileSync('stdout.log'); console.error(`Ok: ${data}`); res.send(`${data}`); } });
|
将报错结果存入stderr.log,将进程的运行结果存入stdout.log,(而不是过程中的)
那么如何获得nmap的运行结果呢?翻阅namp的使用手册,总结出了两个可以用的参数:
-iL
:从外部打开文件,读取里面的ip地址进行扫描;
-oN
:将执行结果存入外部文件中,可以将结果写入静态目录直接读取
使用-iL时发现如果打开的文件中不存在合法ip,返回的错误中就会存在文件的内容:
接下来只需要将这个报错的结果存到外部文件直接访问就好了,但是现在问题来了,上面被过滤了那么多的字符该怎么绕过
观察我们期望传入的字符,空格被ban了是最大的问题,曾经在另一篇文章中总结过相关的trick,这里$都被ban了,那么就试试{,},Linux中可用{,}来无空格执行命令:
测试成功:
最后一个问题就是,在Dockerfile对flag的处理中发现flag被拼接上了-加上16个随机字符,思路是正则绕过,但是*被ban了,但是知道位数,可以用?来匹配:
最后将结果写入stdout.log:
payload:
1
| http://192.168.64.1:36002/checker?url={-iL,/flag-????????????????,-oN,stdout.log}
|
Easylatex
一道xss
1 2 3 4 5 6 7 8 9 10 11 12
| app.post('/login', (req, res) => { let { username, password } = req.body
if (md5(username) != password) { res.render('login', { msg: 'login failed' }) return }
let token = sign({ username, isVip: false }) res.cookie('token', token) res.redirect('/') })
|
查看路由以用户名的md5为密码进行登录,登陆成功后给Cookie中的token设置为非vip
preview路由:
1 2 3 4 5 6 7 8 9 10 11 12
| app.get('/preview', (req, res) => { let { tex, theme } = req.query if (!tex) { tex = 'Today is \\today.' } const nonce = getNonce(16) let base = 'https://cdn.jsdelivr.net/npm/latex.js/dist/' if (theme) { base = new URL(theme, `http://${req.headers.host}/theme/`) + '/' } res.render('preview.html', { tex, nonce, base }) })
|
base = new URL(theme, http://${req.headers.host}/theme/) + '/'
这一段代码,theme我们是可控的
拼接完之后:
这道题XSS漏洞存在的关键是一个URL构造方法引起的,theme可控,我们可以把它指向自己的服务器:
服务器上装载恶意的js让网页来加载:
Story
先从程序入口看看,有这样几个重要的路由:
captcha路由,调用Capcha类的generate()方法来获取验证码来实现登录的验证
1 2 3 4 5 6 7 8 9 10
| @app.route('/captcha') def captcha(): gen = Captcha(200, 80) buf, captcha_text = gen.generate()
session['captcha'] = captcha_text return buf.getvalue(), 200, { 'Content-Type': 'image/png', 'Content-Length': str(len(buf.getvalue())) }
|
vip路由,从请求体的json中获取captcha的键值并于generate_code()产生的验证码进行比较如果相同就将session中的vip字段设置为true:
1 2 3 4 5 6 7
| @app.route('/vip', methods=['POST']) def vip(): captcha = generate_code() captcha_user = request.json.get('captcha', '') if captcha == captcha_user: session['vip'] = True return render_template("home.html")
|
接着是这个write路由,经过了几层判断,当是vip的时候可以将写入的story存入session的story字段中,但是如果没通过waf的话就重置session:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @app.route('/write', methods=['POST', 'GET']) def rename(): if request.method == "GET": return redirect('/')
story = request.json.get('story', '') if session.get('vip', ''):
if not minic_waf(story): session['username'] = "" session['vip'] = False return jsonify({'status': 'error', 'message': 'no way~~~'})
session['story'] = story return jsonify({'status': 'success', 'message': 'success'})
return jsonify({'status': 'error', 'message': 'Please become a VIP first.'}), 400
|
最后是这里的story路由,我们写入的story将被render_template_string()
渲染出来,一个非常明显的ssti了,:
1 2 3 4 5 6 7
| @app.route('/story', methods=['GET']) def story(): story = session.get('story', '') if story is not None and story != "": tpl = open('templates/story.html', 'r').read() return render_template_string(tpl % story) return redirect("/")
|
接下来剩下两个待解决的问题:
- 想写story就需要成为vip,如何成为vip?
- 怎么绕过waf?
这里先从如何成为vip开始:
观察vip路由,跟进generate_code()
方法,逻辑就是从待选字符中依次随机获取4个字符拼接在一起组成验证码:
1 2 3
| def generate_code(length: int = 4): characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' return ''.join(random.choice(characters) for _ in range(length))
|
使用的是random.choice()伪随机,采用的算法是固定的,当设置的种子是固定的时候,产生的随机结果就是固定的,
eg:
两次运行以下代码时,都是获取到’k’字符,但是当我把种子换成1145141,每次第一个产生的随机字符就是r,想伪造验证码,我们就得去找找种子在哪设置的
1 2 3 4 5 6
| import random
random.seed("114514") str = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' char = random.choice(str) print(char)
|
最后一行可以看出是在captcha类的构造函数中播撒的seed,种子是_key
,_key
的获取逻辑是在时间戳后面加上1到100的随机整数,因此实际上完全可能爆破获取_key来进行随机数预测的
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Captcha: lookup_table: t.List[int] = [int(i * 1.97) for i in range(256)]
def __init__(self, width: int = 160, height: int = 60, key: int = None, length: int = 4, fonts: t.Optional[t.List[str]] = None, font_sizes: t.Optional[t.Tuple[int]] = None): self._width = width self._height = height self._length = length self._key = (key or int(time.time())) + random.randint(1, 100) self._fonts = fonts or DEFAULT_FONTS self._font_sizes = font_sizes or (42, 50, 56) self._truefonts: t.List[FreeTypeFont] = [] random.seed(self._key)
|
去看看哪里实现了Captcha,其实只有一处,在captcha路由下,这下可以得到一个思路:通过不断地访问captcha路由来不断刷新种子,当: