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 *,你的攻击脚本就拥有了强大的内力。
-
Pwntools是Python库,是编写漏洞利用脚本(Exploit)的瑞士军刀。它能帮你处理进程交互(发送数据、接收输出)、打包地址(
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
)被调用时,会发生以下几步:
-
参数传递
: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
就变成了
0x400123
,CPU接下来就会去执行
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_SOURCE=2(强化安全函数)、-Wformat-security等。 - 代码审计与模糊测试 :定期进行代码安全审计,并对输入接口进行模糊测试。
回过头看pwn 036这道题,它像是一个解剖标本,剥离了所有现代防护,让我们能最清晰地看到栈溢出漏洞的骨骼和肌肉。理解它是你迈向更复杂二进制漏洞利用世界的坚实第一步。下次当你遇到开启了Canary、PIE、RELRO全保护的题目时,你就能明白,攻击的艺术正是在与这些防护机制的博弈中不断演进的。而这一切,都始于对栈上那几十个字节的精确掌控。
369

被折叠的 条评论
为什么被折叠?



