SROP 是一种利用 Linux 信号处理机制缺陷来进行寄存器劫持的攻击技术。它的核心在于滥用 rt_sigreturn 系统调用

第一阶段:Linux 正常的信号处理机制

当一个 Linux 进程在用户态运行时,如果收到一个信号(例如 SIGINTSIGALRMSIGSEGV),内核会强行打断当前的执行流,转而去执行该信号对应的处理函数(Signal Handler)。

为了在信号处理结束后能恢复之前的状态,内核必须保存进程的当前上下文(Context)。具体流程如下:

  1. 挂起与保存:内核暂停进程,并将 CPU 当前所有寄存器的值(RAX, RDI, RIP, RSP, EFLAGS 等)打包成一个特定的数据结构,称为 Signal Frame(信号帧)。
  2. 压栈:内核将这个 Signal Frame 压入该进程的用户态栈(User Stack)上。
  3. 执行 Handler:内核将 RIP 寄存器指向信号处理函数,开始执行。
  4. 调用返回:信号处理函数执行完毕后,必须调用一个特殊的系统调用——rt_sigreturn(在 x86-64 架构下,系统调用号为 15)。
  5. 恢复上下文:内核捕获到 rt_sigreturn 调用后,会从当前栈指针(RSP)所在的位置,读取事先保存的 Signal Frame,将其中的数据逐一写回 CPU 的各个物理寄存器中。
  6. 继续执行:上下文恢复完毕,进程从被打断的地方继续运行。

第二阶段: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,必须满足以下条件:

  1. 足够大的栈溢出空间:
    x86-64 架构下,一个完整的 Signal Frame 大小约为 250 字节(包含浮点寄存器状态、段寄存器等)。因此,溢出长度必须大于这个值。
    (你的题目:buf16 字节,可写 0x400 字节,完美满足。)
  2. 能够控制 RAX 寄存器为 15
    必须让内核知道你要调用的是 rt_sigreturn
    (你的题目:给定的 gadgets() 函数编译后等价于 mov eax, 15; ret,完美满足。)
  3. 存在 syscall; retGadget
    用于触发进入内核态的系统调用。
    (你的题目:sys_readsys_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 指令时,真正的魔法开始:

  1. vuln 执行 ret,弹出栈顶地址,跳转到 gadgets()
  2. gadgets() 执行,将 RAX 赋值为 15,然后执行 ret
  3. ret 弹出栈顶地址,跳转到 syscall 指令所在的地址。
  4. syscall 触发。内核检查 RAX,发现是 15,得知这是一个 rt_sigreturn 请求。
  5. 核心动作:内核切换上下文,从当前的 RSP 开始读取数据,将我们伪造的 Signal Frame 解析并全部加载到物理寄存器中。
  6. 上下文恢复完毕,内核将控制权交还给用户态。此时,物理寄存器的状态已经变成了:

    • RAX = 59 (execve 调用号)
    • RDI = addr_of_binsh
    • RSI = 0
    • RDX = 0
    • RIP = syscall 的地址
  7. CPU 读取 RIP,发现下一条指令又是 syscall
  8. 再次陷入内核,内核检查 RAX59,执行 execve("/bin/sh", 0, 0)
  9. 提权成功,获得 Shell

这就是 SROP 在二进制层面的绝对事实,没有任何魔法,全靠对内存布局和寄存器状态的精准操控。

补充知识一:syscall 是什么

syscallSystem Call,系统调用)是一条极其特殊的 CPU 汇编指令。

抛开所有比喻,在计算机底层架构中,syscall 是用户态程序进入内核态、请求操作系统底层服务的唯一合法桥梁。

为了彻底明白这条指令的重量级,需要从 CPU 的权限级别说起。

1. 为什么需要 syscall?(保护模式与权限环)

现代 CPU(如 x86-64)在硬件物理层面上将运行状态划分了极严格的权限等级,最核心的两个是:

  1. Ring 3(用户态 / User Space):最低权限。你写的 C 语言程序、跑的 Pwn 题、甚至浏览器和游戏,默认都在这里运行。在这个状态下,程序没有权限直接操作硬件(不能往硬盘写数据、不能往屏幕输出像素、不能强制结束其他进程)。
  2. Ring 0(内核态 / Kernel Space):最高权限。Linux 操作系统内核运行在这里。它掌控内存分配、硬件驱动、进程调度等核心能力。

矛盾来了:你的 Pwn 题(运行在 Ring 3)想要在屏幕上输出泄露的地址(调用 sys_write),或者想要开启一个 Shell(调用 execve),但它没有物理权限。它必须请求 Ring 0 的内核帮忙。

怎么请求?就是通过执行 syscall 这条指令。

2. syscall 指令在物理层面到底干了什么?

CPU 正在一行行读取程序,突然读到 syscall 这条机器码(0x0F 0x05)时,CPU 硬件会瞬间完成一套极其精密复杂的特权级切换(Context Switch)动作:

  1. 保存现场:CPU 硬件会自动把当前的 RIP(下一条指令地址)备份到 RCX;把当前状态标志寄存器 RFLAGS 备份到 R11
  2. 权限提升:CPU 物理权限瞬间从 Ring 3 跃升到 Ring 0
  3. 强制跳转:CPU 强制将 RIP 修改为一个内核预设的固定安全入口地址(在 Linux 中通常是 entry_SYSCALL_64)。
  4. 内核接管:从这里开始,用户程序被暂停,Linux 内核代码开始执行。

3. 内核如何知道你想干什么?(寄存器传参)

内核每天要处理无数个 syscall 请求,当执行流跳到内核时,内核第一件事就是查看寄存器:

  1. RAX(调用号):内核首先盯住 RAX

    • 你把 RAX 设为 0,内核知道这是 read 请求。
    • 你把 RAX 设为 1,内核知道这是 write 请求。
    • 你把 RAX 设为 15,内核知道这是 rt_sigreturn(恢复信号帧)请求。
    • 你把 RAX 设为 59,内核知道这是 execve(开新程序)请求。
  2. 查参数寄存器:根据请求类型,内核读取其他寄存器作为参数。在 x86-64 Linux 中,系统调用传参顺序是:RDI, RSI, RDX, R10, R8, R9

    • 例如 execve 需要看 RDI,内核会顺着 RDI 指向的地址去找 "/bin/sh\x00" 字符串。

4. 内核执行完毕后的返回

当内核在 Ring 0 把请求处理完(或拒绝)后:

  1. 它会把执行结果(例如 read 实际读到的字节数,或错误码)写回 RAX
  2. 内核执行一条叫 sysret 的特权指令。
  3. CPU 瞬间将权限从 Ring 0 降回 Ring 3
  4. 把之前备份在 RCX 的地址恢复到 RIP
  5. 程序在用户态“醒来”,继续执行 syscall 后面的下一条指令。

5. 回到这道 Pwn

平常写代码时用的 printf()read(),其实是 C 标准库(glibc)提供的包装函数。它们在底层最终都会配置好 RAX 等寄存器,然后执行纯粹的 syscall 指令。

但这道题里,出题人为了考最底层理解,把 glibc 包装剥离了,直接用内联汇编手写了 sys_readsys_write,里面几乎只有设置寄存器和 syscall 指令。

SROP 的本质,就是利用巧妙的栈布局,连续操控两次 syscall 进入内核态:

  1. 第一次 syscallRAX=15):骗内核“我被信号打断了,请按栈上的数据恢复我的寄存器”。
  2. 第二次 syscallRAX=59):在寄存器被篡改后,顺势触发 execve("/bin/sh", 0, 0),下达“开 Shell”命令。

补充知识二:RAX 是什么

这是一个非常核心的问题。要彻底搞懂 Pwn 和汇编,必须认识寄存器。

简单说,RAXCPU 内部的一个高优先级通用寄存器。

如果把 CPU 看成一个厨师,而内存(Memory / RAM)是远处的大冰柜,那么寄存器就是手边的小碟子。RAX 就是其中最重要、最常用的一只。

1. RAX 的基本档案

  1. 全称:Accumulator Register(累加寄存器)。
  2. 大小:64 位(8 字节)。在 x86-64 下,以 R 开头的寄存器通常是 64 位。
  3. 兼容分身:同一物理寄存器可按低位拆分使用。

    • 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 判断你要做什么:

  1. RAX = 0read
  2. RAX = 1write
  3. RAX = 15rt_sigreturnSROP 核心)。
  4. RAX = 59execve(执行新程序,例如开 shell)。

这就是为什么在 SROP 中必须把 RAX 精确设置成 1559

特权二:它是函数返回值的专座

C/C++ 等常见调用约定中,函数返回值默认放在 RAX(或 EAX)中。

这可以解释题目里的函数:

__int64 gadgets() {
  return 15;
}

编译后本质行为就是:

  1. 把数字 15 放进 EAX/RAX(例如 mov eax, 15)。
  2. 执行 ret 返回。

把链路连起来看:通过 ret 跳转到 gadgets(),函数执行 return 15RAX=15;紧接着再跳到 syscall 指令,内核看到 RAX=15,就会进入 rt_sigreturn 的恢复路径。