SROP 是一种利用 Linux 信号处理机制缺陷来进行寄存器劫持的攻击技术。它的核心在于滥用 rt_sigreturn 系统调用
第一阶段:Linux 正常的信号处理机制
当一个 Linux 进程在用户态运行时,如果收到一个信号(例如 SIGINT、SIGALRM 或 SIGSEGV),内核会强行打断当前的执行流,转而去执行该信号对应的处理函数(Signal Handler)。
为了在信号处理结束后能恢复之前的状态,内核必须保存进程的当前上下文(Context)。具体流程如下:
- 挂起与保存:内核暂停进程,并将
CPU当前所有寄存器的值(RAX,RDI,RIP,RSP,EFLAGS等)打包成一个特定的数据结构,称为Signal Frame(信号帧)。 - 压栈:内核将这个
Signal Frame压入该进程的用户态栈(User Stack)上。 - 执行
Handler:内核将RIP寄存器指向信号处理函数,开始执行。 - 调用返回:信号处理函数执行完毕后,必须调用一个特殊的系统调用——
rt_sigreturn(在x86-64架构下,系统调用号为15)。 - 恢复上下文:内核捕获到
rt_sigreturn调用后,会从当前栈指针(RSP)所在的位置,读取事先保存的Signal Frame,将其中的数据逐一写回CPU的各个物理寄存器中。 - 继续执行:上下文恢复完毕,进程从被打断的地方继续运行。
第二阶段:SROP 的漏洞原理
SROP 的根本成因在于 Linux 内核的一个设计缺陷(或者说设计假设):
内核在执行 rt_sigreturn 时,不会校验栈上的 Signal Frame 的合法性。
内核不会去验证“当前栈上的这个信号帧,是不是我之前压进去的那个”。它完全信任用户空间的栈数据。只要程序发起了 rt_sigreturn 系统调用,内核就会盲目地从 RSP 指向的内存地址读取约 250 字节的数据,强行装载进 CPU 寄存器。
攻击者的视角:如果我们能通过栈溢出(Buffer Overflow),在栈上伪造一个完全由我们控制的 Signal Frame,并且想办法让程序执行一次 rt_sigreturn 系统调用,我们就能在瞬间控制 CPU 的所有寄存器(包括决定程序走向的 RIP 和传参的 RDI, RSI, RDX)。
第三阶段:执行 SROP 的三大先决条件
要在你的这道 Pwn 题中打出 SROP,必须满足以下条件:
- 足够大的栈溢出空间:
在x86-64架构下,一个完整的Signal Frame大小约为250字节(包含浮点寄存器状态、段寄存器等)。因此,溢出长度必须大于这个值。
(你的题目:buf为16字节,可写0x400字节,完美满足。) - 能够控制
RAX寄存器为15:
必须让内核知道你要调用的是rt_sigreturn。
(你的题目:给定的gadgets()函数编译后等价于mov eax, 15; ret,完美满足。) - 存在
syscall; ret的Gadget:
用于触发进入内核态的系统调用。
(你的题目:sys_read和sys_write函数内部必然包含syscall指令,完美满足。)
第四阶段:内存视角下的攻击布局 (Stack Layout)
假设我们要通过 SROP 执行 execve("/bin/sh", 0, 0)。在发送 Payload 时,我们需要将栈构造成如下精确的物理布局:
高地址
| |
| ... (其他数据) ... |
| "/bin/sh\x00" 字符串 | <-- 假设地址为 `addr_of_binsh`
| |
|========================|
| 伪造的 CS, GS | \
| 伪造的 RDX = 0 | |
| 伪造的 RSI = 0 | |
| 伪造的 RDI = addr_of_binsh| |---> 这是一个完整的、伪造的 Signal Frame
| 伪造的 RAX = 59 | | (Pwntools 的 SigreturnFrame 会自动处理段寄存器等繁琐细节)
| 伪造的 RIP = syscall地址| |
| | /
|========================|
+------------------------+
| syscall 指令的内存地址 | <-- 2. gadgets 返回后执行这里。此时 RAX=15,触发 rt_sigreturn
+------------------------+
| gadgets 函数的内存地址 | <-- 1. 劫持原函数的返回地址。执行此函数后 RAX 将变为 15
+------------------------+
| 8 字节的 Saved RBP |
+------------------------+
| 16 字节的填充数据 | <-- 覆盖原本的 buf 空间
| |
低地址 (当前 RSP)第五阶段:执行流分析 (Execution Flow)
当脆弱函数(vuln)执行到最后的 ret 指令时,真正的魔法开始:
vuln执行ret,弹出栈顶地址,跳转到gadgets()。gadgets()执行,将RAX赋值为15,然后执行ret。ret弹出栈顶地址,跳转到syscall指令所在的地址。syscall触发。内核检查RAX,发现是15,得知这是一个rt_sigreturn请求。- 核心动作:内核切换上下文,从当前的
RSP开始读取数据,将我们伪造的Signal Frame解析并全部加载到物理寄存器中。 上下文恢复完毕,内核将控制权交还给用户态。此时,物理寄存器的状态已经变成了:
RAX=59(execve调用号)RDI=addr_of_binshRSI=0RDX=0RIP=syscall的地址
CPU读取RIP,发现下一条指令又是syscall。- 再次陷入内核,内核检查
RAX为59,执行execve("/bin/sh", 0, 0)。 - 提权成功,获得
Shell。
这就是 SROP 在二进制层面的绝对事实,没有任何魔法,全靠对内存布局和寄存器状态的精准操控。
补充知识一:syscall 是什么
syscall(System Call,系统调用)是一条极其特殊的 CPU 汇编指令。
抛开所有比喻,在计算机底层架构中,syscall 是用户态程序进入内核态、请求操作系统底层服务的唯一合法桥梁。
为了彻底明白这条指令的重量级,需要从 CPU 的权限级别说起。
1. 为什么需要 syscall?(保护模式与权限环)
现代 CPU(如 x86-64)在硬件物理层面上将运行状态划分了极严格的权限等级,最核心的两个是:
Ring 3(用户态 /User Space):最低权限。你写的C语言程序、跑的Pwn题、甚至浏览器和游戏,默认都在这里运行。在这个状态下,程序没有权限直接操作硬件(不能往硬盘写数据、不能往屏幕输出像素、不能强制结束其他进程)。Ring 0(内核态 /Kernel Space):最高权限。Linux操作系统内核运行在这里。它掌控内存分配、硬件驱动、进程调度等核心能力。
矛盾来了:你的 Pwn 题(运行在 Ring 3)想要在屏幕上输出泄露的地址(调用 sys_write),或者想要开启一个 Shell(调用 execve),但它没有物理权限。它必须请求 Ring 0 的内核帮忙。
怎么请求?就是通过执行 syscall 这条指令。
2. syscall 指令在物理层面到底干了什么?
当 CPU 正在一行行读取程序,突然读到 syscall 这条机器码(0x0F 0x05)时,CPU 硬件会瞬间完成一套极其精密复杂的特权级切换(Context Switch)动作:
- 保存现场:
CPU硬件会自动把当前的RIP(下一条指令地址)备份到RCX;把当前状态标志寄存器RFLAGS备份到R11。 - 权限提升:
CPU物理权限瞬间从Ring 3跃升到Ring 0。 - 强制跳转:
CPU强制将RIP修改为一个内核预设的固定安全入口地址(在Linux中通常是entry_SYSCALL_64)。 - 内核接管:从这里开始,用户程序被暂停,
Linux内核代码开始执行。
3. 内核如何知道你想干什么?(寄存器传参)
内核每天要处理无数个 syscall 请求,当执行流跳到内核时,内核第一件事就是查看寄存器:
查
RAX(调用号):内核首先盯住RAX。- 你把
RAX设为0,内核知道这是read请求。 - 你把
RAX设为1,内核知道这是write请求。 - 你把
RAX设为15,内核知道这是rt_sigreturn(恢复信号帧)请求。 - 你把
RAX设为59,内核知道这是execve(开新程序)请求。
- 你把
查参数寄存器:根据请求类型,内核读取其他寄存器作为参数。在
x86-64 Linux中,系统调用传参顺序是:RDI,RSI,RDX,R10,R8,R9。- 例如
execve需要看RDI,内核会顺着RDI指向的地址去找"/bin/sh\x00"字符串。
- 例如
4. 内核执行完毕后的返回
当内核在 Ring 0 把请求处理完(或拒绝)后:
- 它会把执行结果(例如
read实际读到的字节数,或错误码)写回RAX。 - 内核执行一条叫
sysret的特权指令。 CPU瞬间将权限从Ring 0降回Ring 3。- 把之前备份在
RCX的地址恢复到RIP。 - 程序在用户态“醒来”,继续执行
syscall后面的下一条指令。
5. 回到这道 Pwn 题
平常写代码时用的 printf() 或 read(),其实是 C 标准库(glibc)提供的包装函数。它们在底层最终都会配置好 RAX 等寄存器,然后执行纯粹的 syscall 指令。
但这道题里,出题人为了考最底层理解,把 glibc 包装剥离了,直接用内联汇编手写了 sys_read 和 sys_write,里面几乎只有设置寄存器和 syscall 指令。
SROP 的本质,就是利用巧妙的栈布局,连续操控两次 syscall 进入内核态:
- 第一次
syscall(RAX=15):骗内核“我被信号打断了,请按栈上的数据恢复我的寄存器”。 - 第二次
syscall(RAX=59):在寄存器被篡改后,顺势触发execve("/bin/sh", 0, 0),下达“开Shell”命令。
补充知识二:RAX 是什么
这是一个非常核心的问题。要彻底搞懂 Pwn 和汇编,必须认识寄存器。
简单说,RAX 是 CPU 内部的一个高优先级通用寄存器。
如果把 CPU 看成一个厨师,而内存(Memory / RAM)是远处的大冰柜,那么寄存器就是手边的小碟子。RAX 就是其中最重要、最常用的一只。
1. RAX 的基本档案
- 全称:
Accumulator Register(累加寄存器)。 - 大小:
64位(8字节)。在x86-64下,以R开头的寄存器通常是64位。 兼容分身:同一物理寄存器可按低位拆分使用。
RAX:完整64位(8字节)EAX:低32位(4字节)AX:低16位(2字节)AL:最低8位(1字节)
你可以把它理解为“俄罗斯套娃”结构:
|====================== RAX (64位) ======================|
|---------- 高32位 ----------|========== EAX (32位) =======|
|--高16位--|==== AX (16位) ===|
|-AH(8位)-|-AL(8位)|2. RAX 的两大特权(为什么在 Pwn 里这么重要)
虽然 CPU 有很多寄存器(如 RBX, RCX, RDI, RSI 等),但 RAX 有两个非常关键的用途。
特权一:它是系统调用(syscall)的指挥官
在 Linux 中,程序执行 syscall 就是在向内核请求服务。内核通过 RAX 判断你要做什么:
RAX = 0:read。RAX = 1:write。RAX = 15:rt_sigreturn(SROP核心)。RAX = 59:execve(执行新程序,例如开shell)。
这就是为什么在 SROP 中必须把 RAX 精确设置成 15 或 59。
特权二:它是函数返回值的专座
在 C/C++ 等常见调用约定中,函数返回值默认放在 RAX(或 EAX)中。
这可以解释题目里的函数:
__int64 gadgets() {
return 15;
}编译后本质行为就是:
- 把数字
15放进EAX/RAX(例如mov eax, 15)。 - 执行
ret返回。
把链路连起来看:通过 ret 跳转到 gadgets(),函数执行 return 15 后 RAX=15;紧接着再跳到 syscall 指令,内核看到 RAX=15,就会进入 rt_sigreturn 的恢复路径。
评论已关闭