☰
DEFCON决赛speedrun-001栈溢出漏洞利用复盘:从ROP到ret2libc
2026/10/21 5:20:55 网站建设 项目流程

复盘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,之后才是返回地址。也就是说,从输入开头到返回地址的偏移是0x58(88字节)。这个数字是这次利用里最关键的常量,后面的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. 漏洞利用思路:把一次越界读写变成交互式Shell

3.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之前,必须先把puts@got的地址弹入rdi。我用ROPgadget在二进制里搜了一圈,轻松找到了pop rdi; ret的地址,同时确认了puts@plt和puts@got的地址,以及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. 完整利用实现:从本地验证到远程拿flag

4.1 环境准备与工具链搭配

做这道题我用的环境是常见的Linux发行版加pwntools,Python版本用3系。pwntools是目前打CTF的标配,它同时负责了发送payload、接收输出、处理远程连接这些脏活,用起来比裸socket手动收发舒服太多。调试方面配了一个gdb插件来可视化栈布局和寄存器状态,定位偏移和观察ROP链执行状态时特别直观。

在开始写脚本前,先把需要固化的常量准备好:pop rdi; ret的地址、ret的地址、puts@plt、puts@got、main入口地址,以及libc里puts、system、binsh的偏移。这些常量全部写成变量放在脚本顶部,后面构造payload时直接引用,避免在字符串里手抄地址导致低级的拼写错误。

4.2 第一阶段payload的结构细节

第一阶段payload的排布从第0字节开始:先填0x58字节的填充数据,让栈指针正好落在返回地址的位置。然后依次写入pop rdi; ret、puts@got、puts@plt,最后是main入口。整个链条执行的逻辑是:先把puts@got的地址放进rdi,再调用puts打印出该内存地址处存放的8字节真实地址,最后跳回main重新开始。

这里有一个关于输出格式的细节:puts打印字符串时,遇到\x00会停止输出。由于puts@got指向的地址,其前8字节是libc中puts的真实地址,内存中的二进制表示经常包含\x00字节,所以puts打印出来的不是完整的8字节地址,而是从首字节开始一直到第一个\x00为止的字节流。接收端需要通过接收一定长度的数据,再按照长度补齐到8字节,才能得到真实的地址值。

def leak_once(): io.sendline(b'A' * 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; ret,rdi指向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 b'A' * 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 = b'B' * 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里逐步跟踪时,重点观察三个阶段:第一阶段puts@plt执行前rdi是否确实是puts@got地址;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改成Full,GOT覆写路线就彻底封死,只能靠更复杂的libc劫持手段。

这三个方向刚好对应栈利用题的三道进阶门槛,也是从"会用模板"走向"理解原理"的必经之路。下次刷题时,可以尝试在speedrun-001的基础上手动开启这些保护重新编译,体会每加一层保护后利用链复杂度是怎么涨起来的。

我个人在反复刷这类题目后最大的收获,是对"攻击面"这个词有了更具体的感知。一个看似简单的read溢出,背后串联的是PLT/GOT机制、动态链接过程、系统调用规范、内存布局规则一整条知识链。把这些链条上的每一个节点都弄明白,再回头理解更复杂的堆利用、内核利用,会发现底层逻辑都是相通的。基础题的价值恰恰在这里,它把最高频的利用原语浓缩在一个小场景里,值得反复咀嚼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询