SROP信号返回导向编程:原理、实战与避坑指南 1. 从一个“卡了三天”的崩溃现场说起如果你刚接触二进制安全大概率经历过这样的场景跟着教程把栈溢出、ret2text、ret2shellcode 都跑通了觉得自己已经入门了结果遇到一道新题checksec 一看——NX 开着PIE 开着能用的 gadget 少得可怜syscall 倒是有但寄存器根本凑不齐。你盯着 IDA 里那几行汇编发呆心里想的是“这也能 getshell”我当年卡在 SROP 上的时间前前后后加起来差不多三天。不是这个概念有多难而是大多数教程一上来就甩给你一张 sigreturn frame 的结构图然后告诉你“把寄存器按这个顺序摆好就行”至于为什么要这么摆、内核到底在背后干了什么、什么场景下该用它全靠自己悟。悟出来了觉得“就这”悟不出来就一直卡着。这篇东西就是把我自己踩过的坑、绕过的弯、以及后来带新人时反复讲的那套理解路径完整地摊开写一遍。SROPSigreturn Oriented Programming信号返回导向编程本质上是利用 Linux 信号处理机制里的一个“历史遗留设计”用一条syscall加上一个精心构造的栈帧一次性控制几乎所有通用寄存器。它解决的核心问题是当你能控制的 gadget 极其有限、甚至连一个完整的pop rdi; ret都凑不出来的时候如何依然完成一次系统调用级别的攻击。适合谁看如果你已经理解了栈溢出的基本原理知道什么是 gadget、什么是 syscall但还没搞明白 SROP 到底怎么落地那这篇就是写给你的。如果你完全没接触过 pwn建议先把栈溢出和 ROP 的基础打牢再回来不然会看得很痛苦。下面我按“先讲清楚它为什么成立再讲怎么用最后讲怎么排错”的顺序来展开中间会穿插大量我实际调试时的记录和参数计算过程。2. SROP 的核心原理拆解2.1 信号处理机制里藏着的“后门”要理解 SROP得先理解 Linux 是怎么处理信号的。当一个进程收到信号比如你按 CtrlC 发的 SIGINT内核不会直接跳到用户注册的处理函数而是先做一件事把当前进程的完整上下文保存到用户栈上然后才把控制权交给信号处理函数。这个保存下来的上下文就是一个sigcontext结构里面包含了几乎所有通用寄存器、浮点寄存器、信号掩码等等。信号处理函数执行完之后会调用rt_sigreturn这个系统调用系统调用号 15告诉内核“我处理完了把之前保存的上下文恢复回来吧。”内核就从栈上把那个sigcontext读出来逐个恢复寄存器然后跳回原来的执行流。问题就出在这里内核在恢复上下文时并不会验证这个sigcontext是不是它当初自己写进去的。它默认栈上那块数据就是合法的。那么如果我们能伪造一个sigcontext然后主动触发rt_sigreturn系统调用内核就会乖乖地按照我们伪造的内容来设置所有寄存器。这就是 SROP 的全部本质。一句话概括用一次rt_sigreturn换取对全部寄存器的写权限。2.2 为什么它比传统 ROP 更“省”传统 ROP 的思路是“拼积木”你需要pop rdi; ret来设置第一个参数需要pop rsi; ret来设置第二个参数需要pop rdx; ret来设置第三个……每设置一个寄存器就要消耗一段 gadget 和栈空间。如果二进制里 gadget 稀缺或者你要设置的寄存器特别多比如 execve 需要 rdi、rsi、rdx 三个拼起来就很费劲。SROP 的思路完全不同它不拼积木而是“一次性填表”。你只需要两样东西一条syscall指令用来触发 rt_sigreturn足够的栈空间用来放伪造的 sigframe触发之后rax 会被设成 15rt_sigreturn 的调用号然后你通过 sigframe 把 rax 改成你想调用的系统调用号比如 execve 是 59把 rdi、rsi、rdx 设成对应参数把 rip 设成syscall的地址把 rsp 设成一块可写内存。等 rt_sigreturn 执行完内核恢复完上下文下一条指令就是 syscall而此时寄存器已经全部就位。对比一下两种方式设置 execve(/bin/sh, 0, 0) 的成本方式需要的 gadget栈空间消耗适用场景传统 ROPpop rdi; ret、pop rsi; ret、pop rdx; ret、pop rax; ret约 7-8 个栈槽gadget 充足时SROP仅需 syscall约 1 个 sigframe248 字节gadget 稀缺时代价是栈空间消耗更大但在大多数栈溢出场景下栈空间根本不是瓶颈。所以 SROP 的真正价值在于当 gadget 稀缺到传统 ROP 无法完成时它提供了一条“降维打击”的路径。2.3 sigframe 的结构与关键字段不同架构下 sigframe 的结构不一样这里以最常见的 x86_64 为例。一个完整的rt_sigreturnframe 是 248 字节但真正需要关心的字段没那么多。下面这张表是我自己整理的核心字段清单按重要性排序偏移字段作用攻击时的设置要点0x00uc_flags标志位一般设 00x08uc指向 ucontext一般设 00x10uc_stack.ss_sp备用栈指针可设 00x18uc_stack.ss_flags栈标志可设 00x20uc_stack.ss_size栈大小可设 00x28r8通用寄存器按需设置0x30r9通用寄存器按需设置0x38r10通用寄存器按需设置0x40r11通用寄存器按需设置0x48r12通用寄存器按需设置0x50r13通用寄存器按需设置0x58r14通用寄存器按需设置0x60r15通用寄存器按需设置0x68rdi第一个参数execve 时设为 /bin/sh 地址0x70rsi第二个参数execve 时设为 00x78rbp栈基址可设 00x80rbx通用寄存器可设 00x88rdx第三个参数execve 时设为 00x90rax系统调用号设为 59execve0x98rcx通用寄存器可设 00xa0rsp栈指针指向可写内存0xa8rip指令指针指向 syscall 指令0xb0eflags标志寄存器可设 00xb8cs代码段一般设 0x330xc0gs段寄存器一般设 00xc8fs段寄存器一般设 00xd0ss栈段一般设 0x2b0xd8err错误码可设 00xe0trapno陷阱号可设 00xe8oldmask旧掩码可设 00xf0cr2控制寄存器可设 0注意cs 和 ss 这两个段寄存器的值在 64 位模式下其实不太重要但有些环境如果设成 0 会导致恢复后崩溃建议按表里的值设置。这是我踩过的一个坑后面排错部分会详细讲。真正决定攻击成败的字段其实就五个rax系统调用号、rdi、rsi、rdx参数、rip下一条指令、rsp栈指针。其他字段设 0 通常不会出问题但 cs/ss 建议按惯例值填。3. 从零构造一次 SROP 攻击3.1 攻击场景与前置条件假设我们面对的是一个典型的 64 位 ELF 程序保护全开NX、PIE、Canary但存在一个栈溢出漏洞且程序里有一条syscall指令。没有pop rdi没有pop rsi没有pop raxgadget 极度匮乏。这种情况下传统 ROP 基本没戏但 SROP 可以。我们需要确认几个前置条件存在栈溢出且能控制至少 248 字节以上的栈数据放 sigframe。程序里有syscall指令且我们能知道它的地址PIE 开启时需要先泄露基址。有一块可写内存用来作为恢复后的 rsp通常用 bss 段。知道/bin/sh字符串的地址或者能自己写入。如果 PIE 开着还需要先通过格式化字符串或者别的漏洞泄露一个地址来绕过。这部分不是 SROP 特有的就不展开了。3.2 用 pwntools 构造 sigframepwntools 提供了SigreturnFrame类省去了手动拼字节的麻烦。下面是我常用的构造模板from pwn import * context.arch amd64 context.log_level debug # 假设已经泄露了基址算出了这些地址 syscall_addr base 0x1234 # syscall 指令地址 binsh_addr base 0x5678 # /bin/sh 字符串地址 bss_addr base 0x9000 # 可写内存地址 # 构造 sigframe frame SigreturnFrame() frame.rax 59 # execve 系统调用号 frame.rdi binsh_addr # 第一个参数/bin/sh 地址 frame.rsi 0 # 第二个参数argv NULL frame.rdx 0 # 第三个参数envp NULL frame.rip syscall_addr # 恢复后执行 syscall frame.rsp bss_addr # 恢复后的栈指针 # 构造 payload payload bA * offset # 填充到返回地址 payload p64(syscall_addr) # 第一次 syscall触发 rt_sigreturn payload bytes(frame) # 伪造的 sigframe p.send(payload) p.interactive()这里有个关键点第一次syscall执行时rax 必须等于 15。因为rt_sigreturn的调用号是 15如果 rax 不是 15内核就不会走信号返回的逻辑。那怎么保证 rax 等于 15 呢有两种常见情况如果溢出前 rax 恰好是 15那直接触发就行。如果 rax 不是 15就需要先用一个read系统调用把 rax 读成 15。因为read的返回值是读到的字节数我们控制读入 15 个字节rax 就变成 15 了。第二种情况更通用也是大多数题目里的标准套路。具体做法是先调用一次read(0, writable_addr, 15)读入 15 个字节rax 变成 15然后跳到syscall此时就触发了 rt_sigreturn。3.3 完整攻击链的两次 syscall把上面的思路串起来一次完整的 SROP 攻击通常包含两次 syscall第一次 syscall目的是触发rt_sigreturn。前提是 rax 15。如果 rax 不满足需要先用 read 把 rax 调成 15。第二次 syscallrt_sigreturn 恢复上下文后rip 指向 syscallrax 已经被设成 59execverdi/rsi/rdx 也已就位于是执行 execve(/bin/sh, 0, 0)拿到 shell。整个流程用文字描述就是栈溢出 → 控制 rip 到 syscallrax15→ 内核执行 rt_sigreturn → 从栈上读取伪造的 sigframe → 恢复所有寄存器 → rip 跳到 syscallrax59 → 执行 execve → getshell看起来简单但实际调试时会遇到各种问题。下面我把常见坑逐个列出来。3.4 参数计算offset 怎么确定offset 的计算是栈溢出的基本功但 SROP 场景下有个容易忽略的点sigframe 是紧跟在返回地址后面的所以 offset 必须算准否则 frame 会错位。确定 offset 的标准方法是用 cyclic 模式# 生成 200 个字符的 cyclic 模式 cyclic 200 # 在 gdb 里运行崩溃后查看 rsp 的值 # 假设 rsp 0x6161616161616166 cyclic -l 0x6161616161616166 # 输出 72说明 offset 72但要注意如果程序在溢出前有额外的栈操作比如leave; retoffset 可能会变。我一般会在 gdb 里下断点在实际的ret指令上直接看 rsp 和输入缓冲区的距离这样最准。实操心得不要迷信教程里的 offset每道题都不一样。养成用 gdb 动态确认的习惯比背公式靠谱得多。4. 实战调试与常见问题排查4.1 rt_sigreturn 没触发先查 rax这是最常见的第一个坑。payload 打过去程序直接崩了gdb 里看 rip 停在 syscall 后面但寄存器完全没变。原因几乎都是 rax 不等于 15。排查方法在 gdb 里下断点到 syscall 指令处看 rax 的值。gdb b *syscall_addr gdb c gdb info registers rax如果 rax 不是 15说明前面的 read 没有正确执行或者 read 的返回值不是 15。检查两点read 的第三个参数读入字节数是不是 15。read 是否真的成功读入了 15 个字节有时候输入被截断。我遇到过一次read 的 count 参数设成了 0x100结果 rax 变成了实际读入的字节数不是 15。后来改成精确的 15 才成功。4.2 恢复后崩溃在奇怪的地方查 cs 和 sssigframe 里 cs 和 ss 这两个字段很多教程说“设 0 就行”但实测下来在某些内核版本或容器环境里设 0 会导致恢复后直接 SIGSEGV。建议按惯例值设置frame.cs 0x33 frame.ss 0x2b frame.eflags 0x202这三个值是我在多次调试后固定下来的组合兼容性最好。eflags 设 0x202 是为了保证中断标志位正常避免恢复后某些指令行为异常。4.3 rsp 指向哪里才安全frame.rsp 决定了恢复后的栈指针。如果指向不可写内存下一条指令只要涉及栈操作就会崩。标准做法是选 bss 段的一块可写区域。但要注意bss 段的大小要足够。如果 rsp 指向 bss 末尾后续 execve 执行时栈向下增长可能会越界。我一般选 bss 段中间偏前的位置留足空间。另外如果程序开了 ASLRbss 的地址每次运行都不同需要先泄露基址。如果没开 ASLR比如本地调试可以直接用固定地址。4.4 execve 参数为 NULL 的坑execve(/bin/sh, NULL, NULL)在大多数情况下能拿到 shell但有些环境里 argv 为 NULL 会导致 shell 启动异常。更稳妥的做法是让 rsi 指向一个包含 NULL 指针的数组# 在 bss 上构造 argv 数组 argv_addr bss_addr 0x100 frame.rsi argv_addr # 同时确保 argv_addr 处的内容是 0不过对于 CTF 场景直接设 0 通常够用。如果遇到 shell 拿不到的情况可以试试这个改进。4.5 常见问题速查表现象可能原因排查方法解决方案程序直接崩溃寄存器无变化rax ! 15gdb 断点看 rax确保 read 读入 15 字节恢复后 SIGSEGVcs/ss 设 0看崩溃地址设 cs0x33, ss0x2b恢复后栈操作崩溃rsp 不可写看 rsp 值指向 bss 可写段getshell 失败argv 为 NULL手动测试rsi 指向 NULL 数组frame 错位offset 不准cyclic 重新计算gdb 动态确认PIE 导致地址错误基址未泄露检查泄露逻辑先泄露再构造避坑技巧调试 SROP 时把 context.log_level 设成 debugpwntools 会把每一步的收发都打出来能快速定位是 payload 构造问题还是交互问题。5. 进阶玩法与工具链补充5.1 用 ropper 或 ROPgadget 找 syscallsyscall 指令的地址是 SROP 的锚点。找它的方法ROPgadget --binary ./target | grep syscall ropper --file ./target --search syscall如果程序本身没有 syscall可以看看有没有调用 libc 里的 syscall 函数或者通过其他方式间接调用。实在没有的话SROP 就用不了得换思路。5.2 结合 ret2csu 处理更复杂的情况有些题目里连 syscall 都没有但存在__libc_csu_init里的通用 gadget。这种情况下可以先用 ret2csu 调用 read 把 rax 调成 15再跳到某个包含 syscall 的地址。这种组合打法在难题里很常见。5.3 32 位下的 SROP 差异32 位 Linux 的 sigframe 结构和 64 位不同字段偏移完全不一样。而且 32 位的 rt_sigreturn 调用号是 119不是 15sigframe 大小是 168 字节左右。如果你做的是 32 位题目记得换一套偏移表。pwntools 的 SigreturnFrame 会根据 context.arch 自动适配所以只要设对 arch 就行。context.arch i386 frame SigreturnFrame() # 后续设置逻辑类似但字段名和偏移不同5.4 自动化脚本模板下面是我常用的一个 SROP 自动化模板把常见步骤都封装好了from pwn import * def srop_attack(binary, offset, syscall_addr, binsh_addr, bss_addr, libcNone): if libc: p process([libc, binary]) else: p process(binary) context.arch amd64 # 构造 frame frame SigreturnFrame() frame.rax 59 frame.rdi binsh_addr frame.rsi 0 frame.rdx 0 frame.rip syscall_addr frame.rsp bss_addr frame.cs 0x33 frame.ss 0x2b frame.eflags 0x202 # 第一次 payload触发 rt_sigreturn payload1 bA * offset payload1 p64(syscall_addr) payload1 bytes(frame) p.send(payload1) p.interactive() if __name__ __main__: srop_attack(./target, 72, 0x401234, 0x402000, 0x404000)这个模板可以直接改地址用省去每次重写的时间。6. 我踩过的那些坑与个人体会SROP 这个概念本身不复杂但真正落地时细节决定成败。我印象最深的一次是本地调试通了远程死活拿不到 shell。查了半天发现是远程环境的 cs/ss 值和本地不一样本地设 0 能跑远程必须设 0x33/0x2b。从那以后我养成了一个习惯不管本地通不通cs/ss/eflags 都按标准值填省得来回折腾。另一个体会是SROP 的调试一定要善用 gdb 的info registers和x/40gx $rsp。前者看寄存器恢复情况后者看栈上的 frame 有没有错位。很多时候问题就出在 frame 的某个字段偏移算错了肉眼看不出来一 dump 栈就一目了然。还有一点pwntools 的 SigreturnFrame 虽然方便但它的字段名和实际偏移的对应关系最好自己心里有数。我建议至少手动构造一次 sigframe用p64一个个字段拼出来这样能真正理解每个字节的作用。手动拼过一次之后再用 pwntools 就是如虎添翼。最后分享一个小技巧如果题目给的栈空间不够放完整的 248 字节 frame可以试试把 frame 放到 bss 段然后用read先把 frame 读进去再触发 rt_sigreturn。这样栈上只需要放一个指向 bss 的指针空间压力小很多。这个思路在栈空间紧张的题目里特别好用。SROP 说到底是一种“用空间换控制力”的思路。当你手里的 gadget 少得可怜时它给了你一次“重新定义所有寄存器”的机会。理解了这个本质剩下的就是熟练度和调试经验的问题了。多打几道题把上面那些坑都踩一遍自然就通了。