1. 从一个“卡住三天”的崩溃现场说起如果你正在学二进制安全大概率会在某个深夜遇到这样一幕栈溢出能打通ret2text 能打通ret2libc 也能打通结果翻到一道新题checksec 一看——NX 开着PIE 开着栈上能写的就那么几十个字节gadget 翻来覆去只有 syscall 和 syscall ret。你盯着屏幕想这怎么打我当年卡在这一步整整三天。后来才知道这类题有个专门的解法叫SROP全称Sigreturn Oriented Programming中文一般叫“信号返回导向编程”。它不是什么新出的漏洞类型而是一种在极端受限条件下依然能完成系统调用的利用技术。核心思路只有一句话利用内核在处理信号返回时自动恢复寄存器上下文的机制伪造一个 sigframe让内核替我们把所有寄存器设成我们想要的值。这篇文章面向的是刚学完栈溢出基础、正在往“高级 ROP”方向走的萌新。我会把 SROP 的来龙去脉、sigframe 的结构、pwntools 里怎么用、真实题目里怎么打全部拆开讲一遍。不堆术语不甩文档就按我自己踩坑的顺序来。2. SROP 到底解决的是什么问题2.1 普通 ROP 的天花板在哪里先回顾一下普通 ROP 的套路。栈溢出之后我们通过覆盖返回地址把程序执行流导向一个个 gadget拼出一条调用链。比如想执行execve(/bin/sh, 0, 0)标准做法是用pop rdi; ret把/bin/sh的地址放进 rdi用pop rsi; ret把 0 放进 rsi用pop rdx; ret把 0 放进 rdx用pop rax; ret把 59 放进 rax最后跳到syscall这套流程能跑通的前提是二进制里得有这些 gadget。如果题目给的 gadget 极少比如只有一个syscall; ret那普通 ROP 直接歇菜。更极端的情况是你连pop rdi都找不到因为程序被 strip 过或者本身代码量极小。另一个限制是栈空间。有些题目只允许你溢出 0x20 字节左右连一条完整的 ROP 链都放不下。这时候你需要一种“一次就能设置所有寄存器”的方法。2.2 SROP 的核心洞察SROP 的巧妙之处在于它借用了操作系统的一个既有功能信号处理。当进程收到一个信号比如 SIGSEGV、SIGALRM内核会做这几件事暂停当前执行流在用户栈上压入一个sigframe信号帧保存当前所有寄存器的值跳转到用户注册的信号处理函数信号处理函数执行完后调用rt_sigreturn系统调用内核从 sigframe 中恢复所有寄存器继续原来的执行流关键点在第 5 步内核恢复寄存器时完全信任 sigframe 里的内容。如果我们能伪造一个 sigframe然后触发rt_sigreturn内核就会按照我们伪造的内容设置所有寄存器。这相当于一条指令完成了“设置 rax、rdi、rsi、rdx、rip、rsp”全部操作。用生活类比来说普通 ROP 像是你一个个手动拨动保险箱的密码轮得找到每个轮子的拨杆gadgetSROP 则是你直接写一张纸条塞进保险箱的“恢复配置”槽里告诉它“下次开锁时按这个配置来”然后按一下恢复键保险箱自己就按你的配置设好了。2.3 为什么叫“Sigreturn Oriented”rt_sigreturn是一个系统调用在 x86_64 上系统调用号是 15。它本身是内核提供给用户态用来从信号处理返回的接口。攻击者利用它是因为它不需要额外的 gadget只要能把 rax 设成 15 并执行 syscall 即可它从栈上读取 sigframe而栈内容我们可控它一次性恢复几乎所有寄存器包括 rip 和 rsp所以 SROP 本质上是一种“用系统调用代替 gadget 链”的利用范式。它不依赖大量 gadget只依赖一个syscall指令和可控的栈。3. sigframe 结构拆解内核到底读了什么3.1 x86_64 下的 sigframe 布局要伪造 sigframe必须知道内核期望的内存布局。在 x86_64 Linux 上rt_sigreturn恢复的 sigframe 对应struct rt_sigframe其中最关键的是ucontext部分。实际利用时我们只关心寄存器区域布局如下从低地址到高地址偏移内容说明0x00uc_flags通常置 00x08uc_link通常置 00x10ss_sp栈指针可置 00x18ss_flags可置 00x20ss_size可置 00x28r8通用寄存器0x30r9通用寄存器0x38r10通用寄存器0x40r11通用寄存器0x48r12通用寄存器0x50r13通用寄存器0x58r14通用寄存器0x60r15通用寄存器0x68rdi第一个参数0x70rsi第二个参数0x78rbp栈基址0x80rbx通用寄存器0x88rdx第三个参数0x90rax系统调用号0x98rcx通用寄存器0xa0rsp栈指针0xa8rip指令指针0xb0eflags标志寄存器0xb8cs代码段0xc0gs段寄存器0xc8fs段寄存器0xd0ss栈段0xd8err错误码0xe0trapno陷阱号0xe8oldmask旧掩码0xf0cr2页错误地址这个表不需要死记pwntools 已经帮我们封装好了。但理解它的意义在于你知道哪些偏移对应哪些寄存器就能在调试时快速定位问题。比如你发现 rax 没设对就去检查 0x90 偏移处的值。3.2 为什么 rax 和 rip 最关键在 SROP 利用中sigframe 里有两个字段是“灵魂”rax偏移 0x90决定rt_sigreturn返回后执行哪个系统调用。比如设成 59 就是execve设成 0 就是read设成 1 就是write。rip偏移 0xa8决定系统调用执行完后跳到哪里。通常设成syscall指令的地址这样内核恢复寄存器后直接执行 syscall完成我们想要的系统调用。rsp偏移 0xa0也很重要它决定系统调用执行时的栈位置。如果后续还要继续 ROP就需要把 rsp 设到一个可控区域。3.3 32 位与 64 位的差异32 位下的 sigframe 布局不同系统调用号是 119sigreturn寄存器顺序也不一样。而且 32 位下int 0x80和sysenter的行为有差异。萌新建议先从 64 位入手因为 pwntools 对 64 位 SROP 的支持最成熟资料也最多。等你把 64 位打熟了再回头看 32 位就是换个结构体的事。4. pwntools 里的 SROP 实战用法4.1 核心 APISigreturnFramepwntools 提供了SigreturnFrame类直接帮你构造 sigframe。基本用法from pwn import * context.arch amd64 frame SigreturnFrame() frame.rax 59 # execve frame.rdi binsh_addr # /bin/sh frame.rsi 0 frame.rdx 0 frame.rip syscall_addr frame.rsp 0然后把这个 frame 拼接到 payload 里。注意frame 必须放在栈上可控的位置因为rt_sigreturn会从 rsp 指向的地方读取它。4.2 触发 rt_sigreturn 的标准套路触发rt_sigreturn需要两步把 rax 设成 15执行syscall如果题目里有pop rax; ret和syscall那直接payload bA * offset payload p64(pop_rax_ret) payload p64(15) payload p64(syscall_ret) payload bytes(frame)如果连pop rax都没有那就需要先用一次read系统调用把 rax 设成 15。因为read的返回值是读入的字节数我们可以控制读入 15 个字节这样 rax 就变成 15。这个技巧叫“用 read 的返回值设置 rax”是 SROP 题里的常见操作。4.3 一个最小可复现的示例假设有一个 64 位程序存在栈溢出有syscall; ret没有pop rax。我们可以这样构造from pwn import * context.arch amd64 context.log_level debug p process(./vuln) elf ELF(./vuln) syscall_ret elf.symbols[syscall_ret] # 假设有这个符号 read_plt elf.plt[read] bss elf.bss() 0x500 # 第一步用 read 把 rax 设成 15同时把 sigframe 读到 bss payload bA * 0x20 payload p64(read_plt) payload p64(syscall_ret) # read 返回后跳到 syscall payload p64(0) # rdi 0 (stdin) payload p64(bss) # rsi bss payload p64(15) # rdx 15读 15 字节rax 变成 15 p.send(payload) p.send(bB * 15) # 这 15 字节让 rax 15 # 此时执行 syscall触发 rt_sigreturn从 rsp 读 sigframe # 但 rsp 当前指向哪里需要确保它指向我们控制的区域这个示例还缺一环rt_sigreturn从 rsp 读 sigframe所以我们需要在触发 syscall 之前把 rsp 设到 bss。这通常需要pop rsp或者用leave; ret。实际题目里往往第一次 read 之后会有一个栈迁移把 rsp 迁到 bss然后再触发 sigreturn。5. 真实题目里的完整利用链5.1 题目特征识别什么样的题适合 SROP我总结几个信号gadget 极少checksec 之后用 ROPgadget 一查只有syscall和少数几个 gadget栈溢出空间小只能覆盖返回地址和少量数据有 read 函数因为需要用 read 来设置 rax 和读入 sigframeNX 开启不能执行栈上代码必须走 ROP静态编译或 syscall 丰富有些题是静态编译的syscall 指令很多如果同时满足“gadget 少 有 read 有 syscall”那基本就是 SROP 题。5.2 利用链设计从 read 到 shell完整利用链通常分两阶段第一阶段栈迁移 设置 rax 15用栈溢出控制返回地址跳到 read 函数read 的目标地址设为 bss 段某处read 的长度设为 15这样返回值 rax 15read 返回后通过leave; ret或pop rsp把栈迁到 bss第二阶段触发 sigreturn 执行 execve在 bss 上布置 sigframesigframe 里 rax 59rdi “/bin/sh” 地址rip syscall执行 syscall触发 rt_sigreturn内核恢复寄存器rax 59rdi “/bin/sh”再次执行 syscall完成 execve这里有个细节rt_sigreturn执行完后rip 被设成 syscall 地址所以会再执行一次 syscall。这次 syscall 的 rax 是 59rdi 是 “/bin/sh”正好是 execve。5.3 参数计算与偏移确认实际操作中偏移量必须精确。我一般用 cyclic 找偏移cyclic 200 # 输入到程序崩溃后看 rip cyclic -l 0x6161616161616166得到偏移后还要确认 bss 地址。用readelf -S或 pwntools 的elf.bss()。注意 bss 段可写但要注意不要覆盖到程序运行时的关键数据。我一般选bss 0x500之后的位置避开 GOT 表和已有变量。sigframe 的大小在 64 位下是 0xf8 字节左右。pwntools 的SigreturnFrame会生成完整结构len(bytes(frame))可以查看实际长度。布置时确保 bss 区域足够大。6. 常见问题与排查技巧实录6.1 为什么我的 sigreturn 没触发最常见的原因是rax 不等于 15。检查方法在 gdb 里下断点到 syscall 指令处看 rax 的值如果 rax 不是 15说明 read 的返回值不对。可能是 read 读到的字节数不是 15或者 read 失败了注意read 从 stdin 读如果你发送的数据长度不对返回值会变另一个原因是rsp 没有指向 sigframe。rt_sigreturn从 rsp 读如果 rsp 指向的是垃圾数据内核会恢复出一堆乱值通常直接崩溃。检查方法在 syscall 前看 rsp 的值确认它指向你布置的 sigframe 起始地址。6.2 execve 执行了但没拿到 shell这种情况通常是 rdi 指向的字符串不对。检查“/bin/sh” 是否真的写到了内存里rdi 是否精确指向该字符串起始地址字符串是否以 null 结尾我踩过一次坑把 “/bin/sh” 写到了 bss但 rdi 指向的是 bss 起始而 bss 起始处还有别的数据导致 execve 收到的不是 “/bin/sh”。后来改成bss 0x200专门放字符串问题解决。6.3 32 位下 sigreturn 的坑32 位下sigreturn系统调用号是 119但触发方式不同。32 位通常用int 0x80而rt_sigreturn在 32 位下叫sigreturn。另外 32 位的 sigframe 结构更小pwntools 的SigreturnFrame在context.arch i386下会自动适配。但要注意32 位下int 0x80和sysenter的寄存器约定不同有些题用sysenter需要额外处理。6.4 速查表SROP 常见故障与解决现象可能原因解决方法程序直接崩溃无输出rax 不等于 15检查 read 返回值确认读入 15 字节崩溃在 syscall 处rsp 未指向 sigframe用 gdb 查看 rsp调整栈迁移目标执行了 execve 但无 shellrdi 指向错误确认 “/bin/sh” 地址和 null 结尾sigframe 被截断bss 空间不足换更大的 bss 区域或分段读入32 位下不触发系统调用号错误32 位用 119不是 15read 返回值不对发送数据长度不对精确发送 15 字节不要多不要少6.5 独家避坑心得先用 gdb 验证 sigframe 布局在rt_sigreturn前 dump 栈内容对照偏移表检查每个寄存器值。这一步能省掉大量盲目试错。read 的 fd 要确认有些题关闭了 stdinread 会失败。确认 fd 0 可用或者改用其他 fd。注意 sigframe 的对齐某些内核版本对 sigframe 起始地址有对齐要求通常 16 字节对齐比较稳妥。pwntools 版本差异老版本 pwntools 的SigreturnFrame可能有 bug建议用最新版。如果发现 frame 长度不对手动补齐。静态编译程序更友好静态编译的二进制通常有大量 syscall 指令且地址固定适合练手。动态编译的题要注意 libc 地址泄露。7. 从 SROP 延伸出去还能怎么玩SROP 不只能用来执行 execve。它的本质是“一次性设置所有寄存器”所以可以用来做很多事ORW 链如果题目禁用了 execve可以用 SROP 设置 open、read、write 三个系统调用分别读取 flag。每个系统调用用一个 sigframe串起来就是完整的 ORW。mprotect 改权限用 SROP 调用 mprotect把某段内存改成可执行然后跳过去执行 shellcode。多次 sigreturn如果一次 SROP 不够可以在 sigframe 里把 rip 设成再次触发 sigreturn 的代码实现多次连续系统调用。我个人的经验是SROP 题打多了之后会对“寄存器上下文”这个概念有更深的理解。普通 ROP 是拼 gadgetSROP 是直接操作上下文思维层次不一样。等你把 SROP 打熟再回头看 ret2libc会觉得那只是体力活。最后分享一个我常用的调试技巧在 gdb 里用x/40gx $rsp查看栈布局对照 sigframe 偏移表一眼就能看出哪个寄存器设错了。这个习惯帮我省了无数个深夜调试的时间。