[DDCTF 2019]homebrew event loop
代码审计
from flask import Flask, session, request, Response
import urllib
import urllib.parseapp = Flask(__name__)
app.secret_key = '*********************' # censored
url_prefix = '/d5afe1f66147e857'def FLAG():return '*********************' # censoreddef trigger_event(event):session['log'].append(event)if len(session['log']) > 5:session['log'] = session['log'][-5:]if type(event) == type([]):request.event_queue += eventelse: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 haystackclass RollBackException(Exception):passdef execute_event_loop():valid_event_chars = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')resp = Nonewhile 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:')):continuefor c in event:if c not in valid_event_chars:breakelse: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']breakexcept Exception as e:if resp is None:resp = ''# resp += str(e) # only for debuggingcontinueif ret_val is not None:if resp is None:resp = ret_valelse:resp += ret_valif resp is None or resp == '':resp = ('404 NOT FOUND', 404)session.modified = Truereturn resp@app.route(url_prefix+'/')
def entry_point():querystring = urllib.parse.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'] = 0session['points'] = 3session['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 htmldef 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('&', '&').replace('\t', ' '*4).replace(' ', ' ').replace('<', '<').replace('>', '>').replace('\n', '<br />')else:html += linesource.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 htmlelse: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_itemstrigger_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_consumedef 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 worriestrigger_event('func:show_flag;' + FLAG())trigger_event('action:view;index')if __name__ == '__main__':app.run(debug=True, host='0.0.0.0')
代码实在太长了,最好是先从flag开始分析
def get_flag_handler(args):if session['num_items'] >= 5:# show_flag_function has been disabled, no worriestrigger_event('func:show_flag;' + FLAG())trigger_event('action:view;index')
想要拿到flag需要num_items>=5
然后在buy_handler可以增加数字
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_itemstrigger_event(['func:consume_point;{}'.format(num_items), 'action:view;index'])
可以看到先+=num_items
然后执行consume操作
def consume_point_function(args):point_to_consume = int(args[0])if session['points'] < point_to_consume:raise RollBackException()session['points'] -= point_to_consume
这种购买类的一般都存在购买的逻辑漏洞
trigger_event
就是加入进程队列
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 += eventelse:request.event_queue.append(event)
可以直接传数组进去,这样不就直接就能多次执行了吗?我们执行一次买钻石,然后执行get_flag_handler就好了
再看看execute_event_loop
函数
def execute_event_loop():valid_event_chars = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')resp = Nonewhile 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:')):continuefor c in event:if c not in valid_event_chars:breakelse: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)
调用函数的逻辑,每次取队列中的第一个元素event = request.event_queue[0]
action:的话自动加_handler;func:的话自动加_function
想法很好但是现实很骨感,我们并不能传入数组给trigger_event
后端没有代码实现让我们传入数组
trigger_event
函数只能执行一次且我们无法传入数组
那如果我们调用trigger_event
自己呢,不就可以多次调用函数了吗
所以这里其实就是给我们的提示
我们传入?action:trigger_event:%23;action:buy;5%23action:get_flag;
使用#
号来绕过后缀拼接
然后用工具解密cookie就拿到flag了,这里用在线网站解不了