0%

[DDCTF2019]homebrew_event_loop

[DDCTF 2019]homebrew event loop

滴滴ctf的一个题,python的代码审计,没审出来

给了源码,很长一截,首先理清楚头绪就花了不久

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):
    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 as 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')

看一遍下来大概能看懂怎么回事,定义了trigger_event用来向列表中添加事件,而命令的执行则集中在execute_event_loop中完成,定义了一系列handler和function,实现用户功能,并且用户只能传action的参数执行handler,所有的function均由handler函数进一步调用

工具函数get_mid_str,获取action内容,也被用来提取参数,function和handler中前面两个渲染界面的没什么用,直接不看,后面四个就比较关键,那个show_flag_function真的是在骗我,我看那里有那么长一串注释我还以为是出题人的提示,但是返回值写死了flag也是自己赋的值,我在一个奇奇怪怪的地方卡了半天

这里的buy_handler先进行购买,再使用trigger_event将付款操作加入处理队列,如果钱不够,触发一个回滚异常,将钱货全部回滚到交易前的状态,看起来是一系列操作流畅执行完美无缺的,但是他的问题在于不是buy_handler直接调用consume_point_function,而是将其添加到处理队列中,如果我们正常的去执行的话,一次的确也只能请求一个handler,那么自然是一系列流程无懈可击,但是在我们的入口entry_point中,这个query_string是可控的,而几经辗转它在execute_event_loop中,被eval拼接_headler或_function,合着它的参数被动态执行

这里这个eval我愚蠢的认为拼出来的函数会直接执行,就没看懂后面的event_handler(args),呜呜呜

事实上这里如果我们在函数名后面加上#,eval会认为后面的内容被注释掉,这样就可以进行任意函数执行了,如果我们执行一下trigger_event,就可以控制命令执行的顺序

更有意思的一点是,trigger_event函数可以处理参数是一个列表,那么我们如果传入buy_handler之后立马传入get_flag_handler,那么在执行队列执行完buy_handler之后添加的consume_point_function就会被添加到get_flag_function的后面,而get_flag_function发现我们的货已经够了,就把flag写进了session,而这个时候consume_point_function才来得及验货,再回滚也没机会了,flag就到我们的手上了

其实一开始就应该考虑flag怎么拿的,显然整个程序下来只有get_flag_handler拿得到flag,而他调用的show_flag已经被禁用了,但是会将flag写入session,我们直接查session就能获得,就不会一开始对着那个被禁用的函数发呆了

看到有一篇wp写的,买东西的题很有可能是逻辑漏洞,有道理