[DDCTF 2019]homebrew event loop
1.view-source python代码审计,trigger_event函数接收到来自url的字符串参数,首先先在session['log']
后追加这次的访问字符串,然后判断日志log数组长度是否大于5,是则清空最早的一次记录,保存后五次的访问记录。如果字符串参数是数组形式,则将请求的event_queue
全部追加。这个功能就是初始化event_queue数组和session中log更新的作用。
def trigger_event(event):
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)
2.get_mid_str分割字符串参数,出现的位置分别是action = get_mid_str(event, ':', ';')
以及args = get_mid_str(event, action+';').split('#')
,其中假设字符串参数为action:index;True#False
,第一次分割为index;True#False
,然后再次变为action = index;同理,第二次分割args变为数组[‘True’,‘False’]。
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
3.这个函数首先request.event_queue首个元素取出作为event,然后再将其后移一位。将event进行判断是否以action:和func:开头,以及是否包含其他除字母数字的字符,最后根据数据第1个字符判断是否为action然后进行分割,最后分割出的index添加上_handle字符成为访问路由,如果不为action则加上_function成为访问路由。到底就将参数args放入路由里面作为响应返回。
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
4.审计/url_prefix
路由中的功能entry_point()
,该功能就是session以及querystring语句的初始化,并保存到event_queue
中。
@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()
5.view_handler()功能是返回主界面视图。
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
6.buy_handler函数是我们购买物品时的功能,这也是漏洞的产生点,首先它判断我们购买的数量是否小于0,然后直接将我们购买的数量保存再session中,并没有立刻扣分,随机就将我们购买的数量转到consume_point_function(args)
函数中进一步判断。
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'])
7.在这个函数中,判断我们session中的积分是否小于我们要购买的数量,否则购买成功并将数量和积分改变后保存到session中,是则立刻返回。
def consume_point_function(args):
point_to_consume = int(args[0])
if session['points'] < point_to_consume:
raise RollBackException()
session['points'] -= point_to_consume
8.get_flag_handler函数是当我们的购买数量大于5时,将flag加载到show_flag_function中,这里注意读取的flag作为trigger_event参数传入了。
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')
9.通过上面代码分析,那么就有以下几条规则:
- 第一:可以注意到
execute_event_loop()
函数,它是按照trigger_event()
函数处理之后的request.event_queue
队列的顺序来依次执行该队列保存的方法的。 - 第二:在
buy_handler(args)
方法可以注意到,该方法是先判断我们输入的数量是否大于0,然后再把session['num_items']
的数量给添加我们输入的数量。最后,再通过trigger_event()
函数将consume_point_function(args)
方法加入到request.event_queue
队列,随后通过该方法进行校验。 - 第三:通过第一点和第二点,可以发现我们在购买完指定数量后,它添加到
session['num_items']
中,然后才按照队列顺序执行的consume_point_function
方法校验。那么,我们是不是可以在它执行了buy_handler
方法后,再执行get_flag_handler(args)
方法,最后才通过consume_point_function
方法校验呢? - 第四:通过第三点,那就可以发现了,
execute_event_loop()
函数它是通过request.event_queue
队列中的顺序来执行方法的,那么我们如果将get_flag_handler(args)
方法插在第三点中的两个方法中间,就能将flag读取到session中。而且队列的插入是通过trigger_event()
函数来依次实现的,而且该函数如果参数为数组,那么会直接将数组追加到request.event_queue
队列。所以,我们只需要把["action:buy;5","action:get_flag;"]
作为参数来实现在校验前就获取flag的效果。
10.那么,我们就需要想办法来执行trigger_event()
函数,我们可以看到在execute_event_loop()
存在下面的语句,该语句将通过action = get_mid_str(event, ':', ';')
处理后的action进行了拼接执行,同时args也是通过 args = get_mid_str(event, action+';').split('#')
来分割截取的,在这里就能实现上一步的操作了:
try:
event_handler = eval(
action + ('_handler' if is_action else '_function'))
ret_val = event_handler(args)
11.首先我们要绕过action + _handler
的字符拼接,这里就能用到python
的注释符#
来绕过了,于是可以变成action:trigger_event#;
,这里最后加单引号是为了符合解析规则。
‘12.这一步就是将args参数进行分割成数组来传入,根据get_mid_str(event, action+';').split('#')
的切割规则,我们传入action:buy;5#action:get_flag;
,利用注释符实现参数切割传入到trigger_event
函数进行加入request.event_queue
队列中。
13.于是最终的payload:?action:trigger_event#;action:buy;5#action:get_flag;
,然后用flask-cookie-session-manager
解码一下,就能得到flag了。