如果你刷过 CISCN 2019 的 pwn 题,多半会对 ciscn_2019_es_7 这串字符有印象。题目名字看起来只是比赛内部编号,却是我个人学会 SROP(Sigreturn-Oriented Programming)的第一块跳板。很多人第一次做这道题时会盯着 syscall 指令发呆,因为它不像常规栈题那样可以直接 ret2libc,也没有现成的 win 函数可以跳——你必须理解内核在 sigreturn 时到底做了什么,才能真正把 shell 弹出来。这篇文章就以 ciscn_2019_es_7 为入口,把 SROP 的原理、gadget 搜索、exploit 构造和调试方法从头到尾捋一遍,希望对刚接触 pwn 栈利用的同学有点帮助。
SROP 这个东西,说难不难,说简单也不简单。它不像 ret2text 那样拷贝 payload 就能打,也不像 ret2libc 那样只需要泄露一个地址。你需要同时理解系统调用号、栈上伪造的 SigreturnFrame、以及 read 返回值怎么变成 rax。更麻烦的是,一旦某个环节不对,程序不是直接段错误,就是走了一个完全看不懂的 syscall。所以我建议不要直接抄脚本,而是跟着下面的思路,把这道题从信息收集到最终 getshell 完整过一遍。
1. 拿到 ciscn_2019_es_7:先别急着开打,把信息收干净
拿到任何 pwn 题,我都习惯先跑一遍 checksec,再拖进 IDA 或者 Ghidra 里看反汇编,最后才决定用什么攻击手法。这一步看起来简单,但很多人会跳过,结果就是 payload 写得乱七八糟。ciscn_2019_es_7 这类题目尤其吃信息收集,因为它的利用点很可能不是传统栈溢出链。
1.1 checksec 给出的答案:这题大概率是 SROP
我拿到的这份 ciscn_2019_es_7 是 x86_64 的 ELF,防护状态大概是这样的:
| 防护项 | 状态 | 说明 |
|---|---|---|
| Arch | amd64-64-little | 64 位小端序 |
| RELRO | Partial RELRO | GOT 可写 |
| Stack Canary | No canary found | 栈上没有金丝雀 |
| NX | NX enabled | 栈不可执行 |
| PIE | No PIE (0x400000) | 基址固定,gadget 地址不会变 |
看到 No canary 和 No PIE,第一反应是直接栈溢出 ret2libc。但继续往下看,发现程序根本没有输出函数,也没有 puts、printf 之类的 PLT 入口,导致我无法泄露 libc 地址。同时 NX 开启,意味着我塞到栈上的 shellcode 也跑不了。那突破口是什么?答案往往藏在代码段里某个不起眼的 syscall 指令上。
如果你也遇到类似情况,记住一个判断原则:一旦程序里出现 syscall 或 int 0x80,并且存在栈溢出,SROP 就应该进入你的候选列表。尤其是 x86_64 下,syscall 指令配合 sigreturn 机制可以完全绕过 libc 泄露。
1.2 反汇编中的三个关键符号:漏洞点、syscall、可写区
用 IDA 打开后,我看到一个典型的漏洞函数,伪代码可以缩成下面这样:
c复制ssize_t vulnerable_function()
{
char buf[32]; // [rsp+0h] [rbp-20h]
return read(0, buf, 0x200uLL);
}
这里 read 向栈上读入 0x200 字节,而 buf 只有 32 字节,所以从 rbp-0x20 开始就能覆盖到返回地址。偏移量是 0x20 到 rbp,再加上 8 字节 rbp,所以第 0x28 个字节开始就是返回地址。
然后我在反汇编里搜索 syscall,找到了类似这样的片段:
asm复制0x4001a0: syscall
0x4001a8: ret
这基本就是整道题的题眼。syscall; ret 这个组合在正常程序里可能只是某个系统调用包装函数的一部分,比如 read 的汇编包装。但在攻击者手里,它变成了一个可以反复利用的万能跳板。只要我能控制 rax,让它等于 15,那么下一次 syscall 执行的就是 rt_sigreturn,内核会按照栈上伪造的上下文恢复寄存器,从而让我实现任意系统调用。
另外我还需要一块可写的内存来放 /bin/sh 字符串。因为开启了 Partial RELRO 和 No PIE,.bss 段地址固定,可以直接用:
code复制bss = elf.bss() + 0x200
为什么要加 0x200?因为 elf.bss() 返回的通常是 .bss 起始段,有些位置可能被 glibc 的全局变量占用,也可能离 GOT 太近。加一个偏移,能避免覆盖到关键数据,同时确保这块区域是可读可写的。
1.3 为什么不能直接 ret2libc
有人可能会问,为什么不先去泄露 libc 地址,然后 one_gadget 或者 system("/bin/sh")?问题在于,题目二进制里连 puts 都没有。没有输出函数,就意味着你无法把 GOT 里某个函数的真实地址打印出来,也就拿不到 libc 基址。当然你可以用 ROP 调用 write 或 printf,但这个题目连这些 PLT 都没有,所以这条路从根上就不通。
另一方面,虽然二进制里没有 system 和 execve 的显式调用,但内核的 execve 系统调用是始终存在的。SROP 的核心思路就是:我不需要 libc 里任何函数,我只需让内核替我执行一次 execve。这就绕过了泄露 libc 的难题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SROP 内核机制拆解:sigreturn 不是普通的函数返回
SROP 的完整名称是 Sigreturn-Oriented Programming,核心是利用信号返回机制来控制系统调用。很多人第一次接触这个概念时,会被 SigreturnFrame 这些名词吓到,其实背后的原理并不复杂。只要你用过一个调试器,或者观察过进程收到信号时的行为,就能理解它。
2.1 内核如何恢复线程上下文
在 Linux 里,当一个进程收到信号时,内核会先保存当前线程的完整上下文,包括所有通用寄存器、段寄存器、信号掩码、栈指针和指令指针等,然后跳转到用户态安装的信号处理函数去执行。当信号处理函数执行完毕,会调用 sigreturn 这个系统调用,告诉内核:“我处理完了,帮我恢复之前被中断的上下文吧。”
刚才说的“完整上下文”,在内核里就是一个 rt_sigframe 结构。这个结构被打在用户栈上,内核在 sigreturn 时直接从这个结构中读取寄存器值并恢复。
这里的重点在于:用户态栈上的这个结构,本来应该是内核和信号处理函数之间的约定,但对于攻击者来说,它只是普通内存数据。 如果我能在栈上伪造一个 rt_sigframe,然后触发 sigreturn,那么
