跳转到内容

Lesson 61: 缓冲区溢出安全分析 ​

练习任务 ​

难度:中

给定 3 段 C 代码片段,完成"缓冲区溢出安全分析"——不执行实际溢出攻击,而是作为安全分析师,系统地分析每段代码的内存布局、溢出风险和防护方案。

你需要填写 overflow_lab.c 中的 7 个 TODO 区域,输出一份固定格式的安全分析报告:

片段代码风险等级
1gets(buf) — buf[16]CRITICAL
2strcpy(buf, src) — buf[32]HIGH
3fgets(buf, sizeof(buf), stdin) — buf[16]SAFE

报告内容包括:ASCII 栈帧图(含地址偏移标注)、偏移量计算、溢出风险判断、Stack Canary(金丝雀)防护原理讲解、六种防护机制对比表(Canary / ASLR / DEP / RELRO / PIE / FORTIFY)、以给出综合风险评估和安全编程准则。

提示:栈从高地址向低地址增长,数组从低地址向高地址增长——这两个方向"对撞"是缓冲区溢出能够覆盖返回地址的根本原因。画图时始终问自己:buf[N] 越界写入的方向,正好指向 rbp 和返回地址。


核心知识点 ​

  • x86-64 栈帧结构与调用约定 — call 压返回地址、push rbp 保存基址、sub rsp,N 分配局部变量,理解栈帧 = 理解溢出
  • 缓冲区溢出的根本原因 — 栈向低地址增长,数组向高地址增长,buf[N] 越界正好冲向 canary → rbp → 返回地址
  • gets 零边界检查的危害 — 唯一参数是缓冲区指针,不知道大小,C11 已正式移理)
  • strcpy \0 终止的双刃剑 — 不检查目标大小但 \0 限制 payload 内容
  • fgets size 参数的安全边界 — 只需正确传入 sizeof(buf),就能天然阻止溢出
  • 偏移量计算公式 — offset = sizeof(buf) + 对齐填充 + (8 if canary) + 8(rbp),掌握其推导过程
  • Stack Canary 哨兵机制 — 来自煤矿金丝雀比喻,返回地址前插入随机值,返回前校验,最低字节为 \0 防字符串泄露
  • Canary 的局限性 — 信息泄露可精确覆盖、Fork 子进程共享 canary 可暴力破解、不保护数据指针和 GOT 表
  • 纵深防御体系 — Canary(检测) → ASLR(隐藏) → DEP/NX(阻止执行) → RELRO(保护 GOT) → PIE(位置无关) → FORTIFY_SOURCE(编译强化)
  • 安全替代函数表 — gets→fgets、strcpy→strncpy+手动\0、sprintf→snprintf、strcat→strncat
  • 安全编译选项 — -fstack-protector-strong、-D_FORTIFY_SOURCE=2、-fPIE -pie、-Wl,-z,relro,-z,now

代码框架 ​

overflow_lab.c
c
#include <stddef.h>
#include <stdio.h>
#include <string.h>

/* 栈帧分析数据结构 */
typedef struct {
    int buf_size;
    int offset_to_ret;
    int overflow_risk;
    const char *func_name;
    const char *description;
} StackAnalysis;

static void print_separator(void) {
    printf("══════════════════════════════════════════════════════\n");
}

/* ─── TODO 1: 打印代码片段 1 的栈帧分析 (gets 溢出) ─── */
#error TODO 1-7: 完成所有分析章节
/*
 * 片段 1: void vulnerable_gets(void) { char buf[16]; gets(buf); }
 *
 * 栈帧布局 (x86-64, 无 canary):
 *   +24  返回地址(8B)    ← gets 可覆盖
 *   +16  保存的 rbp(8B)
 *   +8   [填充/对齐](8B)
 *   +0   buf[16]          ← gets 从这里写入
 *
 * buf[0] 到返回地址的偏移 = 16 + 8 + 8 = 32 字节
 */

/* ─── TODO 2: 代码片段 2 栈帧分析 (strcpy 溢出) ────── */
/*
 * 片段 2: void vulnerable_strcpy(char *src) { char buf[32]; strcpy(buf, src); }
 *
 * 偏移 = 32 + 8 = 40 字节
 */

/* ─── TODO 3: 代码片段 3 栈帧分析 (fgets 安全版) ─── */
/*
 * 片段 3: void safe_fgets(void) { char buf[16]; fgets(buf, sizeof(buf), stdin); }
 *
 * 偏移 32 字节,但 fgets 最多写入 16 字节,无法触及返回地址
 */

/* ─── TODO 4: 金丝雀 (Stack Canary) 防护原理 ─── */
/*
 * 带 canary 的栈帧: buf → [对齐] → CANARY(8B, 最低字节\0) → rbp → ret
 * 入口: mov rax, fs:0x28; 写入栈帧
 * 返回前: mov rax, [rbp-8]; xor rax, fs:0x28; je ok → call __stack_chk_fail
 */

/* ─── TODO 5: 防护机制对比表 ─── */
/*
 * Canary / ASLR / DEP / RELRO / PIE / FORTIFY_SOURCE 六种机制
 */

/* ─── TODO 6: 综合风险评估 ─── */
/*
 * 三段风险评级 + 五条安全准则
 */

/* ─── TODO 7: 学习建议 ─── */
/*
 * 动手实验、工具推荐、标准规范、编译器默认选项、经典论文
 */

int main(void) {
#error TODO 1-7: 完成所有分析章节
    return 0;
}

本题的"代码框架"比较特殊——不是实现算法,而是用 printf 输出一份结构化的安全分析报告。你需要读懂每个 TODO 上方注释中的要求,然后逐步填充,使输出与 expected_output.txt 精确匹配。

TIP

先不往下翻看参考解答。试着在纸上画出三个片段各自的栈帧布局图,标注地址偏移。关键是理解"栈向低地址增长,数组向高地址增长"这个方向差异——它是所有溢出攻击的地理基础。


深度讲解 ​

1. 栈帧结构与 x86-64 调用约定——溢出理解的地基 ​

1.1 函数调用的栈操作全景 ​

每当一个函数被调用,CPU 执行一套固定仪式——理解这套仪式是理解缓冲区溢出的绝对前提。

调用者 (caller) 视角:
  push 参数        ← 将第 7 个起的参数压栈 (x86-64 前 6 个用寄存器)
  call function    ← 将返回地址 (RIP) 压栈,然后跳转到函数

被调用者 (callee) 视角:
  push rbp         ← 保存调用者的栈帧基址
  mov  rbp, rsp    ← 建立自调用者的栈帧基址
  sub  rsp, N      ← 为局部变量分配 N 字节空间
  ... 函数体 ...
  leave            ← mov rsp, rbp; pop rbp (恢复调用者栈帧)
  ret              ← pop rip (弹出返回地址并跳转)
stack_frame_demo.c
c
/*
 * 用最简单的函数观察栈帧操作:
 * $ gcc -O0 -o demo demo.c
 * $ objdump -d demo | grep -A15 '<simple_func>'
 */
void simple_func(int x) {
    char buf[16];
    buf[0] = x;
}

/*
 * 反汇编后大约是这样的 (x86-64):
 *
 * simple_func:
 *   push   rbp              ; 保存调用用者的栈帧基址
 *   mov    rbp, rsp         ; 建立新栈帧
 *   sub    rsp, 32          ; 分配 32 字节局部空间 (16+对齐)
 *   mov    eax, edi         ; 参数 x 在 edi 中
 *   mov    BYTE PTR [rbp-16], al  ; buf[0] = x
 *   leave                   ; 恢复栈帧
 *   ret                     ; 返回
 */

NOTE

sub rsp, 32 而不是 sub rsp, 16 说明了 x86-64 ABI 的 16 字节栈对齐要求——即使 buf 只有 16 字节,编译器仍然多分配 16 字节来维持对齐。这些"多出来的"空间在溢出攻击中充当了填充物。

1.2 x86-64 典型栈帧布局 ​

      高地址 (栈底方向,地址值更大)
      ┌──────────────────────┐
       │  调用者的栈帧         │  ← 调用者的局部变量等
      ├──────────────────────┤
      │  函数参数 (第 7 个起)  │  ← x86-64 前 6 个参数用寄存器
      ├──────────────────────┤
      │  返回回地址 (8 bytes)   │  ← call 指令压入的 RIP
      ├──────────────────────┤
      │  保存的 rbp (8 bytes) │  ← 调用者的栈帧基址
      ├──────────────────────┤ ← rbp (当前函函数栈帧基址)
      │  canary (8 bytes)     │  ← -fstack-protector 插入的哨兵
      ├──────────────────────┤
      │  [栈对齐填充]          │  ← x86-64 ABI 要求 16 字节对齐
      ├──────────────────────┤
      │  局部变量 / 数组      │  ← char buf[N] 从这里开始
      ├──────────────────────┤
      │  callee-saved 寄存器  │  ← rbx, r12-r15 (如果使用)
      └──────────────────────┘
      低地址 (栈顶方向) ← rsp

IMPORTANT

注意看各区域的相对位置:返回地址在高地址,缓冲区在低地址。这意味着如果从缓冲区向高地址方向溢出(数组索引递增),会依次经过:对齐填充 → canary → rbp → 返回地址。这就是溢出攻击的地理路线图。

1.3 栈增长 vs 数组增长——致命的方向碰撞 ​

这是理解缓冲区溢出的最关键的一张图:

栈增长方向:  高地址 → 低地址  (push 操作递减 rsp)
数组增长方向: 低地址 → 高地址  (buf[i] 的地址 = buf + i)

因此:
  buf[0]   在最低地址
  buf[N-1] 在较高地址
  向 buf[N] 写入 → 向高地址越界 → 覆盖 canary → 覆盖 rbp → 覆盖返回地址

示意:
  低地址                    buf                        canary/rbp/ret   高地址
  ─────────────────────────────────────────────────────────────────────────→
  [buf[0]][buf[1]]...[buf[N-1]][超出部分覆盖canary][覆盖rbp][覆盖返回地址]
  写入起点 ←──────────────────── 溢出方向 ────────────────────────────→
简易记忆口诀:
  "push 减 (向低),数组增 (向高)"
  二者面对面走 → 越界写入刚好撞上返回地址

CAUTION

许多初学者在这里搞反方向——认为溢出是向低地址方向覆盖。如果你把方向画反了,整个安全模型就错了:你会认为 canary 放在 buf 的"另一边",而实际它放在 buf 的"溢出路径"上。


2. gets(buf) — 缓冲区溢出最危险的入口 ​

2.1 gets 为什么如此危险 ​

gets_vulnerability.c
c
void vulnerable_gets(void) {
    char buf[16];
    gets(buf);  /* 危险!gets 不检查边界 */
}

gets() 只有一个参数——缓冲区指针。它从 stdin 读取字符直到遇到换行符或 EOF,但完全不知道缓冲区有多大。

正常输入 "hello" (5 字节 + \0):
  buf: [h][e][l][l][o][\0]... ← 安全,未触及返回地址

恶意输入 'A' × 32 + 恶意地址 (8 字节):
  buf: [A][A]...[A][A][A][A]...[A][恶意地址]
       └── buf[0..15] ──┘└─填充─┘└─rbp─┘└─返回地址─┘
  结果: 函数 "返回" 到攻击者指定的地址 → 控制流劫持

gets() 的危险四重奏:

  1. 零边界检查:gets 只有一个参数(缓冲区指针),不知道缓冲区大小
  2. 任意长长度输入:从 stdin 读取直到到换行符,可以是任意长度
  3. 直接可攻击:攻击者通过 stdin 直接控制溢出内容
  4. 已被标准移除:C11 标准正式移除 gets(),但遗留代码中仍大量存在

IMPORTANT

C11 移除 gets() 是 C 语言标准化史上最重要的安全决策之一。但 GCC 为向后兼容仍支持它(编译时带 -Wimplicit-function-declaration 警告)。任何出现 gets() 的代码都应立即修复。

2.2 片段 1 的完整栈帧分析 ​

stack_frame_gets.c
c
/*
 * 代码: void vulnerable_gets(void) { char buf[16]; gets(buf); }
 *
 * 栈帧布局 (x86-64, 无 canary):
 *
 *   地址偏移       内容                说明
 *   ─────────────────────────────────────────────────
 *   +24           ┌──────────────┐
 *                 │  返回地址     │ ← gets 溢出可覆盖!
 *   +16           ├──────────────┤
 *                 │  保存的 rbp   │
 *   +8            ├──────────────┤
 *                 │  [栈对齐填充]  │  (8 bytes, 对齐到 16 字节)
 *   +0            ├──────────────┤
 *                 │  buf[0..15]   │ ← gets() 从这里开始写入
 *                 └──────────────┘
 *
 * 偏移量计算:
 *   buf[0] 到返回地址的距离 = 16 (buf) + 8 (对齐) + 8 (rbp) = 32 字节
 *
 * 注:若编译器紧密排列(无对齐填充),则为 24 字节。
 *     实际偏移取决于编译器版本和优化级别。本题使用 32 字节作为教学示例。
 */
溢出攻击的精确 payload:
  输入 "A" × 32 + 8 字节恶意地址
         └── 填充 ──┘ └─ 覆盖返回地址 ─┘

  当函数执行 ret 指令时:
    CPU 从栈上弹出"返回地址"
    但这个地址已被替换为恶意地址
    → 跳转到攻击者指定的内存位置
    函数体执行攻击者植入的代码 (shellcode) 或 ROP 链

3. strcpy(buf, src) — 隐蔽的杀手 ​

3.1 strcpy 的溢出机制 ​

strcpy_vulnerability.c
c
void vulnerable_strcpy(char *src) {
    char buf[32];
    strcpy(buf, src);  /* 危险!strcpy 不检查边界 */
}

strcpy 有两个参数(目标和源),但同样不检查目标缓冲区的大小。它从 src 拷贝字节直到遇到 \0,完全无视 buf 的容量。

如果 src 指向一个超长字符串(无 \0 或很晚才有 \0):
  strcpy 将持续拷贝直到遇到 \0
  → 覆盖 buf[32..39] (rbp)
  → 覆盖 buf[40..47] (返回地址)
  → 继续覆盖调用者的栈帧...

注意:strcpy 的 \0 终止是一把尴尬的双刃剑:
  - 保护侧:限制了 payload 中不能包含 \0 字节 (很多 shellcode 含 \0)
  - 危险侧:但依然不能防止溢出本身——源字符串可能长达 MB

3.2 片段 2 的完整栈帧分析 ​

stack_frame_strcpy.c
c
/*
 * 代码: void vulnerable_strcpy(char *src) { char buf[32]; strcpy(buf, src); }
 *
 * 栈帧布局 (x86-64, 无 canary):
 *
 *   地址偏移       内容                说明
 *   ─────────────────────────────────────────────────
 *   +40           ┌──────────────┐
 *                 │  返回地址     │ ← strcpy 溢出可覆盖!
 *   +32           ├──────────────┤
 *                 │  保存的 rbp   │
 *   +0            ├──────────────┤
 *                 │  buf[0..31]   │ ← strcpy() 从这里开始写入
 *                 └──────────────┘
 *
 * 偏移量计算:
 *   buf[0] 到返回地址的距离 = 32 (buf) + 8 (rbp) = 40 字节
 *   (32 字节已对齐到 16 字节,无需额外填充)
 */

3.3 gets vs strcpy 差异对比 ​

维度gets()strcpy()
输入源stdin(标准输入)字符串参数(内存中的 src)
终止条件换行符 '\n'空字符 '\0'
攻击难度低(直接 stdin 输入)中(需控制 src 内容和容和长度)
\0 限制无(可包含任意字节)有(\0 终止拷贝,限制 payload)
典型场景网络服务、命令行工具字符串处理、配置文件解析
C 标准状态C11 移除仍保留(但有安全替代)

TIP

gets 比 strcpy 更危险的原因:gets 的输入源是 stdin——攻击者通过键盘输入就能直接触发。而 strcpy 需要先控制 src 字符串的内容(通常需要组合其他漏洞),攻击链路更长。


4. fgets 安全版本 — 边界受控的正确范例 ​

4.1 fgets 为什么安全 ​

fgets_safe.c
c
void safe_fgets(void) {
    char buf[16];
    fgets(buf, sizeof(buf), stdin);  /* 安全!限定了最大最大长度 */
}

fgets(buf, size, stream) 有三个参数,最关键的第二个参数 size 告诉了函数缓冲区的容量:

fgets(buf, 16, stdin) 的行为:
  1. 最多读取 15 个字符(第 2 个参数 - 1)
  2. 第 16 个字节固定写入 '\0'
  3. 遇到换行符 '\n' 也停止
  4. 遇到 EOF 也停止

因此:最大写入范围 = buf[0..15],共 16 字节
     返回地址在 +24 偏移处,完全无法触及
fgets_stack_frame.c
c
/*
 * 代码: void safe_fgets(void) { char buf[16]; fgets(buf, sizeof(buf), stdin); }
 *
 * 栈帧布局 (同片段 1,但行为完全不同):
 *
 *   地址偏移       内容                说明
 *   ─────────────────────────────────────────────────
 *   +24           ┌──────────────┐
 *                 │  返回地址     │ ← fgets 无法到达!
 *   +16           ├──────────────┤
 *                 │  保存的 rbp   │ ← fgets 无法到达!
 *   +8            ├──────────────┤
 *                 │  [栈对齐填充]  │ ← fgets 无法到达!
 *   +0            ├──────────────┤
 *                 │  buf[0..15]   │ ← fgets 最多写 15+1 字节
 *                 └──────────────┘
 *
 * 偏移量同样是 32 字节,但 fgets 只写入 16 字节,
 * 距返回地址还有 16 字节"安全距离"。
 */

4.2 正确使用姿势与常见陷阱 ​

fgets_correct_usage.c
c
/* ✅ 正确:使用 sizeof 自动适配缓冲区大小 */
char buf[16];
fgets(buf, sizeof(buf), stdin);

/* ✅ 也正确:显式指定(但需确保与声明一致) */
fgets(buf, 16, stdin);

/* ❌ 错误:传入错误的 size(大于大于实际缓冲区) */
char buf[16];
fgets(buf, 256, stdin);  /* 缓冲区区只有 16 字节,却告诉 fgets 有 256! */

/* ❌ 错误:忘记 sizeof 是字节数 */
wchar_t wbuf[16];
fgets((char*)wbuf, sizeof(wbuf), stdin);  /* 32 字节!可能超出 wbuf 实际大小 */

WARNING

fgets 的安全性建立在正确传入 size 参数的前提下。如果你错误地传入了一个比实际缓冲区更大的数值,fgets 就会变成 | gets 一样危险的函数。这就是为什么 sizeof(buf) 如此重要——它让编译器自动追踪真实大小。


5. Stack Canary(栈金丝雀)— 栈上的哨兵 ​

5.1 名称由来:煤矿中的金丝雀 ​

19世纪的煤矿:
  ┌─────────────────────────────────┐
  │ 矿工带着金丝雀笼子下矿           │
  │                                 │
  │ 金丝雀对一氧化碳 (CO) 极度敏感   │
  │ CO 浓度升高 → 金丝雀先死         │
  │ → 矿工看到金丝雀死亡 → 立 立即撤离  │
  │                                 │
  │ 金丝雀 = 生物传感器 / 早期预警   │
  └─────────────────────────────────┘

计算机中的 Stack Canary:
  ┌─────────────────────────────────┐
  │ 编译器在栈帧中放置一个随机值      │
  │                                 │
  │ 溢出覆盖 → canary 值被改变       │
  │ → 函数返回回前检测到变化           │
  │ → 立即终止程序 (__stack_chk_fail)│
  │                                 │
  │ canary = 栈溢出检测器 / 哨兵     │
  └─────────────────────────────────┘

5.2 带 Canary 的栈帧与检测流程 ​

      地址偏移       内容                说明
      ─────────────────────────────────────────────
      +32           ┌──────────────┐
                    │  返回地址     │ ← 攻击者的最终目标
      +24           ├──────────────┤
                    │  保存的 rbp   │
      +16           ├──────────────┤
                    │  CANARY ★     │ ← 哨兵值 (8 bytes)
      +8            ├──────────────┤
                    │  [栈对齐填充]  │
      +0            ├──────────────┤
                    │  buf[0..15]   │ ← 溢出从这里开始
                    └──────────────┘

溢出路径:
  buf[0] → buf[15] → [对齐] → CANARY → rbp → 返回地址
  安全区域 ────────→ 触发警报!→ 程序终止
canary_lifecycle.c
c
/*
 * Canary 生命周期 (编译器的视角):
 *
 * ╔══════════════════════════════════════════════╗
 * ║  函数入口 (prologue):                        ║
 * ║    1. 从 TLS 读取随机 canary 值              ║
 * ║       mov rax, QWORD PTR fs:0x28            ║
 * ║    2. 写入栈帧 (rbp-8)                      ║
 * ║       mov QWORD PTR [rbp-8], rax            ║
 * ║                                              ║
 * ║  函数体执行: ... 可能的溢出 ...              ║
 * ║                                              ║
 * ║  函数返回前 (epilogue):                      ║
 * ║    3. 读取栈帧中的 canary                    ║
 * ║       mov rax, QWORD PTR [rbp-8]            ║
 * ║    4. 与 TLS 原始值异或                     ║
 * ║       xor rax, QWORD PTR fs:0x28            ║
 * ║    5. 若零 → je .L_ok → ret                 ║
 * ║    6. 若非零 → call __stack_chk_fail        ║
 * ╚══════════════════════════════════════════════╝
 */

5.3 Canary 的三个巧思 ​

巧思 1:最低字节为 \0(NULL 字节)

canary 值示例: 0x00 7f 3a 91 4c 2e b8 d5
               ↑
               \0 字节

为什么这样设计?
  大多数字符串函数 (strcpy, gets, sprintf 等) 遇到 \0 停止拷贝。
  如果攻击者想通过字符串溢出来覆盖 canary:
    "AAAA...\x00\x7f\x3a..." ← 在 \0 处 strcpy 停止!

  攻击者必须精确知道 canary 值并原样写入,
  这大大增加了攻击难度。

巧思 2:随机值(每次运行不同)

程序启动时:
  canary 由内核的随机数生成器 (getrandom/urandom) 初始化
  存储在 TLS (Thread-Local Storage) 中

每次运行:
  $ ./vuln    → canary = 0x00a3f91c...
  $ ./vuln    → canary = 0x007b2e4d...  (完全不同!)

攻击者无法硬编码 canary 值。

巧思 3:存储在 TLS 中

TLS (Thread-Local Storage):
  - 每个线程独立的存储区域
  - 不能通过普通内存访问直接读取
  - x86-64: fs 段寄存器指向 TLS
  - 需要内存泄露漏洞才能读取

5.4 Canary 的局限性与绕过方法 ​

局限性详细说明利用场景
信息泄露若攻击者能先读取 canary 值(如通过 format string 漏洞),则可精确覆盖printf("%s", buf) 泄露栈内容
非连续写入Canary 只能检测从低地址向高地址的顺序覆盖。直接写入返回地址(如任意地址写漏洞)不触发 canary*(int*)(buf+40) = evil_addr
Fork 暴力破解fork() 后子进程继承父进程的 canary 值。攻击者可逐字节尝试,每次崩溃只杀死子进程网络服务器 fork 模型
不保护其他目标覆盖函数指针、数据指针、GOT 表、或修改关键局部变量(如 is_admin flag)可能绕过 canary逻辑漏洞利用
绕过 Canary 的 4 种经典方法:

方法 A: 信息泄露 + 精确覆盖
  Step 1: 利用格式化字符串漏洞泄露 canary 值
  Step 2: 构造 payload = 填充 + canary原值 + rbp + 恶意返回地址
  Step 3: 触发溢出 → canary 检测通过 → 劫持返回地址

方法 B: 改写 GOT 表(不碰 canary)
  Step 1: 溢出 buf,但不覆盖返回地址
  Step 2: 覆盖 buf 后面的函数指针 → 指向 system()
  Step 3: 后续代码调用该函数指针 → 执行 system()

方法 C: 覆盖局部变量
  void login() {
      char buf[16];
      int is_admin = 0;     ← 在 buf 之后(更高地址)
      gets(buf);            ← 溢出覆盖 is_admin 为 1
      if (is_admin) { ... } ← 绕过认证!
  }

方法 D: 逐字节暴力破解(fork 服务器)
  for (byte_pos = 1; byte_pos < 8; byte_pos++) {
      for (guess = 0; guess < 256; guess++) {
          发送 payload: 填充 + [已破解字节] + guess
          if (子进程没有崩溃) → 该字节正确!
      }
  }
  // 平均需要 8 × 128 = 1024 次尝试

6. 纵深防御体系——多道防线的协同 ​

6.1 多层防护链 ​

现代操作系统采用多层防护,每层针对不同攻击阶段:

    攻击者
      │
      ▼
  ┌──────────┐
  │  Canary  │  第 1 层: 检测栈溢出
  └────┬─────┘       (阻止简单的栈粉碎)
       │ 绕过
       ▼
  ┌──────────┐
  │   ASLR   │  第 2 层: 隐藏地址布局
  └────┬─────┘       (使 ROP/JOP 难以定位 gadget)
       │ 绕过
       ▼
  ┌──────────┐
  │ DEP/NX   │  第 3 层: 阻止代码注入
  └────┬─────┘       (栈/堆不可执行)
       │ 绕过
       ▼
  ┌──────────┐
  │  RELRO   │  第 4 层: 保护 GOT 表
  └────┬─────┘       (阻止 GOT 覆写)
       │ 绕过
       ▼
     目标

6.2 完整机制对比表 ​

防护机制全称防护目标工作原理启用方式局限性
Stack CanaryStack Smashing Protector栈缓冲区溢出检测返回地址前放置随机哨兵值,返回前校验-fstack-protector-strong信息泄露可绕过;不保护非栈目标
ASLRAddress Space Layout Randomization代码复用攻击 (ROP/JOP)随机化栈、堆、mmap、库、可执行文件的基地址内核默认 (/proc/sys/kernel/randomize_va_space)信息泄露可绕过;32-bit 熵不足 (~8 bit)
DEP / NX-bitData Execution Prevention / No-eXecute代码注入入攻击 (Shellcode)CPU 标记数据页为不可执行;栈/堆不能同时写 + 执行硬件 NX-bit + OS 支持 (-z noexecstack)ROP/JOP 可绕过(复用已有代码);JIT 需要可执行堆
RELRORelocation Read-OnlyGOT 表覆写攻击将重定位表设为只读;Full RELRO 在启动时解析所有符号-Wl,-z,relro / -Wl,-z,nowPartial RELRO 只保护 .got;Full RELRO 增加启动时间
PIEPosition Independent Executable固定地址攻击可执行文件编译为位置无关代码,配合 ASLR 随机加载-fPIE -pie需 ASLR 配合;轻微性能开销 (~1-3%)
FORTIFY_SOURCECompile-time Fortification不安全库函数调用编译时替换 strcpy → __strcpy_chk(带边界检查)-D_FORTIFY_SOURCE=2需源码重编译;不能检测所有情况;对某些模式无效

6.3 实际编译示例 ​

compile_security_options.sh
bash
# 最小防护(危险!仅用于教学演示)
gcc -fno-stack-protector -z execstack -no-pie -o vuln vuln.c

# 标准防护(现代 Linux 默认)
gcc -fstack-protector-strong -D_FORTIFY_SOURCE=2 \
    -fPIE -pie -Wl,-z,relro -Wl,-z,now \
    -o secure secure.c

# 查看二进二进制启用的安全特性
checksec --file=./secure
# 输出示例:
#   RELRO:    Full RELRO
#   STACK CANARY:  Found
#   NX:       Enabled
#   PIE:      Enabled

NOTE

这些编译选项在现代 GCC(≥6)中很多已经是默认开启的。但了解它们仍然重要——当你在嵌入式系统或不完整的工具链上编译时,可能需要手动确保这些防护生效。


7. 安全编程准则——从源头消灭漏洞 ​

7.1 危险函数及安全替代 ​

| 危险函数 | 风险 | | 安全替代 | 注意事项 | | ------------------------ | --------------- | --------------------------------------- | -------------------------------- | | gets(buf) | 零边界检查 | fgets(buf, sizeof(buf), stdin) | fgets 保留换行行符,需手动去除 | | strcpy(dst, src) | 不检查 dst 大小 | strncpy(dst, src, n-1); dst[n-1]='\0' | strncpy 不保证 \0 结尾 | | strcat(dst, src) | 不检查剩余空间 | strncat(dst, src, n-strlen(dst)-1) | 需计算剩余空间 | | sprintf(buf, fmt, ...) | 不检查 buf 大小 | snprintf(buf, sizeof(buf), fmt, ...) | snprintf 保证 \0 结尾(POSIX) | | scanf("%s", buf) | 不检查 buf 大小 | scanf("%15s", buf) 或 fgets | 宽度限定限定符只限字符数,不含 \0 | | vsprintf(buf, fmt, va) | 不检查 buf 大小 | vsnprintf(buf, sizeof(buf), fmt, va) | 同 snprintf |

IMPORTANT

strncpy 虽然在名字里有个 "n",但它的行为让人意外:如果源字符串长度 ≥ n,目标不会以 \0 结尾。安全用法是 strncpy(dst, src, sizeof(dst)-1); dst[sizeof(dst)-1]='\0';——手动在最后一个字节置 \0。

7.2 五条金科玉律 ​

规则 1: 永远使用带边界检查的函数
  ❌ gets(buf)
  ✅ fgets(buf, sizeof(buf), stdin)

规则 2: 编译时启用所有安全选项
  ✅ -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -pie

规则 3: 永远不要假设输入数据的长度是安全的
  ❌ "用户名不会超过 32 字节"
  ✅ 无论如何都使用 sizeof() 或已知边界

规则 4: 理解栈帧布局有助于写出更安全的代码
  ✅ 知道 buf 溢出后会覆盖什么 → 知道如何防护

规则 5: 代码审查时特别关注危险函数
  ✅ grep -nE '\bgets\b|\bstrcpy\b|\bstrcat\b|\bsprintf\b|\bscanf\("%s"\)' *.c

参考解答 ​

练习1: TODO 1 — gets() 栈帧分析
solution_todo1_gets.c
c
    /* ─── TODO 1: 代码片段 1 gets() 栈帧分析 ─── */
    printf("╔══════════════════════════════════════════════════════╗\n");
    printf("║      缓冲区溢出安全分析实验报告                      ║\n");
    printf("║      Buffer Overflow Security Analysis Lab           ║\n");
    printf("╚══════════════════════════════════════════════════════╝\n");
    printf("\n");
    printf("【预备知识】x86-64 栈帧结构\n");
    printf("\n");
    /* ... 完整的栈帧基础介绍 ... */

核心输出包括:

  1. ASCII 框展示代码片段
  2. 栈帧布局图(标注 +0, +8, +16, +24 偏移)
  3. 偏移计算:buf[0] 到返回地址 = 16 + 8 + 8 = 32 字节
  4. 风险判断:gets 无边界检查,CRITICAL
  5. 攻击示意:'A' × 32 + 恶意地址 劫持返回地址
练习2: TODO 2 — strcpy() 栈帧分析
solution_todo2_strcpy.c
c
    /* ─── TODO 2: 代码片段 2 strcpy() 栈帧分析 ─── */
    print_separator();
    printf("\n");
    printf("【代码片段 2】strcpy() 缓冲区溢出 — 风险等级:HIGH\n");
    printf("\n");
    /* ... 代码展示 ... */
    /* ... 栈帧图(buf[32] + rbp = +40 偏移)... */
    /* ... gets vs strcpy 对比表 ... */

核心输出包括:

  1. ASCII 框展示片段 2 代码
  2. 栈帧布局图(偏移 +0, +32, +40)
  3. 偏移计算:buf[0] 到返回地址 = 32 + 8 = 40 字节
  4. gets vs strcpy 对比表(输入源、终止条件)
  5. \0 终止的双刃剑分析
练习3: TODO 3 — fgets() 安全版分析
solution_todo3_fgets.c
c
    /* ─── TODO 3: 代码片段 3 fgets() 安全版分析 ─── */
    print_separator();
    printf("\n");
    printf("【代码片段 3】fgets() 安全版本 — 风险等级:SAFE\n");
    printf("\n");
    /* ... 代码展示 ... */
    /* ... 栈帧图(标注"fgets 无法到达")... */
    /* ... fgets 安全机制说明 ... */

核心输出包括:

  1. ASCII 框展示片段 3 代码
  2. 栈帧布局图(标注所有"无法到达")
  3. 偏移同样是 32 字节,但 fgets 最多写入 16 字节
  4. fgets 安全机制:size 参数限制、换行/EOF 停止、强制 \0 结尾
  5. 正确使用 vs 误用对比(sizeof(buf) 是核心)
练习4: TODO 4 — Stack Canary 原理讲解
solution_todo4_canary.c
c
    /* ─── TODO 4: 金丝雀 (Stack Canary) 防护原理 ─── */
    print_separator();
    printf("\n");
    printf("【深入分析】金丝雀 (Stack Canary) 防护原理\n");
    printf("\n");
    /* ... 煤矿金丝雀故事 ... */
    /* ... 带 canary 的栈帧图 ... */
    /* ... 检测流程 (入口/返回前) ... */
    /* ... canary 特征表格 ... */
    /* ... 局限性 (4 点) + 绕过方法 (4 种) ... */

核心输出包括:

  1. 名称由来(煤矿金丝雀故事)
  2. 带 canary 的栈帧布局图(buf → 对齐 → CANARY → rbp → ret)
  3. 检测流程:入口读取 fs:0x28 → 写入栈帧 → 返回前异或比较
  4. Canary 三个特征:随机值、\0 最低字节、TLS 存储
  5. 四种局限性及绕过方法
练习5: TODO 5 — 防护机制对比表
solution_todo5_defense_table.c
c
    /* ─── TODO 5: 防护机制对比表 ─── */
    print_separator();
    printf("\n");
    printf("【防护机制对比】纵深防御体系\n");
    printf("\n");
    /* ... 6 种防护机制表格 ... */
    /* ... 纵深防御链示意图 ... */

核心输出包括:

  1. 6 行 × 4 列表格(防护机制 | 防护目标 | 原理 | 局限性)
  2. 覆盖 Canary / ASLR / DEP / RELRO / PIE / FORTIFY_SOURCE
  3. 纵深防御链示意:攻击者 → [Canary] → [ASLR] → [DEP] → [RELRO] → 目标
练习6: TODO 6 — 综合风险评估
solution_todo6_risk_assessment.c
c
    /* ─── TODO 6: 综合风险评估 ─── */
    print_separator();
    printf("\n");
    printf("【综合风险评估】\n");
    printf("\n");
    /* ... 三段代码风险评级表 ... */
    /* ... 五条安全编程准则 ... */

核心输出包括:

  1. 3 行风险评估表(片段 | 等级 | 原因)
    • gets → CRITICAL:无边界检查,stdin 直接控制
    • strcpy → HIGH:无边界检查,需控制 src
    • fgets → SAFE:显式指定最大长度
  2. 五条安全编程准则(含具体替代函数)
  3. 安全编译选项汇总
练习7: TODO 7 — 学习建议
solution_todo7_further_learning.c
c
    /* ─── TODO 7: 学习建议 ─── */
    print_separator();
    printf("\n");
    printf("【进一步学习建议】\n");
    printf("\n");
    /* ... 动手实验命令 ... */
    /* ... 工具推荐 (objdump/checksec/gdb) ... */
    /* ... 标准规范 (CWE/CERT/MISRA) ... */
    /* ... 编译器默认安全选项 ... */
    /* ... 经典论文推荐 ... */

核心输出包括:

  1. 动手实验命令(-fno-stack-protector 观察溢出效果)
  2. 工具推荐(objdump、checksec、gdb)
  3. 标准规范(CWE-120、CWE-121、CERT C STR31-C、MISRA C Rule 21.6)
  4. 编译器默认安全选项(GCC ≥6、Clang ≥5)
  5. 经典论文(Smashing The Stack、StackGuard、ROP)

对照检查:计算偏移时是否考虑了栈对齐填充?犯溢出的栈增长方向是否正确(向高地址覆盖)?Canary 的位置是否在 rbp 之下、局部变量之上?fgets 的 size 参数用了 sizeof(buf) 吗?报告中标注了 \0 字节对字符串函数的影响吗?


课堂讨论 ​

  1. 为什么 C11 要移除 gets() 而不是修复它?
  2. 如果攻击者已经知道 canary 的值,canary 还剩下什么保护作用?
  3. 为什么 fork 出来的子进程 canary 相同?这个设计合理吗?
  4. strncpy 和 strcpy 的溢出风险一样吗?
  5. 在现代 Linux 上,一个简单的栈溢出还能成功吗?
  6. 这道题为什么选择"分析"而不是"利用"?

讨论答案 ​

Q1: 为什么 C11 要移除 gets() 而不是修复它?

gets() 的接口设计从根本上就是有缺陷的——它只有一个参数(缓冲区指针),没有缓冲区大小信息。要"修复"它必须改变函数签名(增加 size 参数),这实际上就是 fgets() 的接口。标准化委员会选择移除它并引导程序员使用 fgets(),而不是维护一个向后兼容的危险接口。

这一决策也引发了 C 社区的广泛讨论——部分人认为标准化委员会应该保持向后兼容(即使函数危险,至少程序员知道它的用法),另一部分人认为安全优先(使用危险函数 = 写出危险代码,标准化组织有责任移除)。最终安全派胜出。ISO C11 不仅是移除了 gets(),还引入了 Annex K(bounds-checking interfaces),提供了 gets_s() 等带边界检查的替代函数——虽然 Annex K 本身在业界争议很大。

Q2: 如果攻击者已经知道 canary 的值,canary 还剩下什么保护作用?

几乎没有。canary 的安全模型假设攻击者不知道它的值。一旦通过信息泄露获取了 canary 值,攻击者可以在溢出 payload 中原样放回,__stack_chk_fail 不会被触发。这就是为什么纵深防御如此重要——ASLR 和 DEP 作为第二二、第三道防线继续提供保护。

在真实攻击链中,信息泄露通常是整个攻击的第一步——攻击者先用 format string 漏洞或越界读取来泄漏 canary 值和 libc 基址,然后再用缓冲区溢出来劫持控制流。这种"Info Leak → Canary Bypass → ROP Chain"的攻击模式在 CTF 比赛和 APT 攻击中屡见不鲜。

但 canary 仍然有意义——它把"直接溢出就可以"变成了"必须先信息泄露再溢出",显著提高了攻击门槛。就像你家的门锁——专业窃贼能撬开,但不装锁的话任何人一推就能进。

Q3: 为什么 fork 出来的子进程 canary 相同?这合理吗?

技术上合理(fork 复制整个地址空间),但安全上是一个已知弱点。fork() 之后子进程是父进程的精确副本,包括 TLS 中的 canary 值。对于使用 fork() 模型的网络服务器(如 Apache prefork),攻击者可以通过反复尝试逐字节破解 canary,因为每次崩溃只杀死子进程,父进程继续存活并 fork 出新的(具有相同 canary 的)子进程。

具体攻击方法:攻击者逐字节尝试 canary 值。第 1 字节始终是 \0(已知)。对于第 2 字节,尝试 0x00-0xFF 的每个值,发送到服务器。如果猜测正确,子进程继续执行(canary 检测通过);如果错误,子进程崩溃——但父进程不受影响,立即 fork 出新的子进程准备接收下一次尝试。平均需要 8 × 128 = 1024 次尝试即可破解完整 canary。

这种攻击对使用 fork() 模型的网络服务(如 xinetd、Apache prefork MPM)尤其有效。现代方案如 posix_spawn 或 clone + execve 可以避免这个弱点,因为新进程有全新的地址空间和 canary。

Q4: strncpy 和 strcpy 的溢出风险一样吗?

不完全一样。strncpy(dst, src, n) 最多拷贝 n 个字符,如果 n ≤ dst 的大小则不会溢出。但 strncpy 有一个隐藏陷阱:如果 strlen(src) ≥ n,则 dst 不会以 \0 结尾,后续的字符串操作可能读取越界。

strncpy_trap.c
c
#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[8];
    strncpy(buf, "Hello, World!", sizeof(buf));
    /* buf = {'H','e','l','l','o',',',' ','W'} — 没有 \0! */
    printf("%s\n", buf);  /* 会继续打印 buf 后面的内存,直到遇到 \0 */
    return 0;
}

正确的安全用法:

strncpy_safe.c
c
strncpy(dst, src, sizeof(dst) - 1);
dst[sizeof(dst) - 1] = '\0';  // 手动确保 \0 结尾

所以答案是:strncpy 可以安全——但前提是你知道并处理了它的 \0 陷阱。许多初学者以为 strncpy 就是"安全的 strcpy",这种误解本身就是安全隐患。

Q5: 在现代 Linux 上,一个简单的栈溢出还能成功吗?

默认情况下非常困难。现代 Linux 发行版默认启用:Stack Canary (-fstack-protector-strong)、ASLR、NX-bit、PIE、Full RELRO。单独的一个 gets() 溢出需要同时绕过 Canary(需要信息泄露)和 ASLR(需要地址泄露),再加上 NX-bit 阻止了 shellcode 直接执行,攻击者需要构造 ROP 链。

但"困难"不等于"不可能"。一个足够有动机的攻击者可以通过组合漏洞完成攻击链:

  1. 格式化字符串漏洞 → 泄露 canary 值和 libc 地址
  2. 缓冲区溢出 → 精确覆盖 canary + ROP 链
  3. ROP 链 → 调用 mprotect 使栈可执行 → 跳转到 shellcode

而且,攻击面比你想象的更广:

  • IoT 设备、嵌入式系统往往没有这些防护
  • 遗留系统(如运行 RHEL 5 的服务器)防护不完整
  • 第三方库可能用 -O0 编译,canary 被优化掉
  • 静态链接的二进制不能享受 ASLR 的完整保护

这也是为什么安全分析如此重要——不能指望防护替你解决一切问题。

Q6: 这道题为什么选择"分析"而不是"利用"?

安全教育的目的是培养安全的开发者,而非攻击者。理解溢出原理和防护机制后,开发者在写代码时就能本能地避免危险模式。相比之下,CTF 式的利用教学虽然有吸引力,但容易让学生将注意力放在攻击技巧上而非防御思维。

两者的视角差异:

维度CTF 利用教学安全分析教学(本题)
目标获取 flag / shell理解漏洞根源
技能编写 exploit、ROP 链栈帧分析、防护评估
心态"我怎么打破它""我怎么确保它不会破"
适用人群安全研究员、渗透测试所有 C/C++ 开发者
副作用可能强化攻击技能建立安全编码本能

先学会防御,再了解攻击,这才是安全教育的正确顺序。


课后练习 ​

  1. 验证偏移量计算。编写一个简单的程序,在 buf[16] 后声明一个标记变量,用 printf("%p") 打印 buf 和标记变量的地址,计算实际偏移。与课程中的理论值(32 字节)对比,分析差异原因。

    知识点提示:编译器可能重排局部变量顺序、插入 padding、或省略未使用的变量。使用 -O0 -fno-stack-protector 编译以获得最可预测的布局。用 objdump -d 查看反汇编确认实际栈分配大小。

    参考解答
    ex1_verify_offset.c
    c
    #include <stdio.h>
    
    int main(void) {
        char buf[16];
        int marker = 0xDEADBEEF;
    
        printf("buf    address: %p\n", (void*)buf);
        printf("marker address: %p\n", (void*)&marker);
        printf("difference: %ld bytes\n",
               (unsigned long)((char*)&marker - buf));
        return 0;
    }
    
    /* 编译: gcc -O0 -fno-stack-protector -o verify verify.c */
    /* 运行: ./verify */
    /* 期望: difference >= 16 (可能更大,取决于编译器) */
  2. fgets 的安全边界验证。编写程序测试 fgets(buf, 16, stdin) 在输入不同长度时的行为。分别输入 5、14、15、20、100 个字符,用 strlen 和内存 dump 观察 buf[15] 位置的值。

    知识点提示:用管道或重定向提供不同长度的输入。验证 fgets 的行规:最多读 (size-1) 个字符、总是 \0 结尾、超长输入时剩余部分留在输入缓冲区中。

    参考解答
    ex2_fgets_verify.c
    c
    #include <stdio.h>
    #include <string.h>
    
    int main(void) {
        char buf[16];
        if (fgets(buf, sizeof(buf), stdin)) {
            printf("strlen: %zu\n", strlen(buf));
            printf("buf[15] value: 0x%02x\n",
                   (unsigned char)buf[15]);
            /* 检查 buf[15] 总是 \0 */
            if (buf[15] == '\0')
                printf("OK: buf[15] is null terminator\n");
            else
                printf("BUG: buf[15] is NOT null!\n");
        }
        return 0;
    }
    
    /* 测试: echo "12345" | ./a.out         → strlen: 5 (含 \n) */
    /*       echo "12345678901234567890" | ./a.out → strlen: 15 */
    /*       注意: 输入超长时,剩余部分留在 stdin 缓冲区中 */
  3. 观察 Canary 在反汇编中的体现。写一个含有 char buf[16] 的函数,用 gcc -fstack-protector-strong -O0 编译,然后用 objdump -d 查看反汇编。找出 canary 从 TLS 读取和返回前校验的汇编指令。

    知识点提示:在函数序言中找 mov rax, QWORD PTR fs:0x28 和 mov QWORD PTR [rbp-8], rax;在函数尾声找 mov rax, QWORD PTR [rbp-8]、xor rax, QWORD PTR fs:0x28 和条件跳转到 __stack_chk_fail。

    参考解答
    ex3_observe_canary.c
    c
    #include <stdio.h>
    
    void demo_canary(void) {
        char buf[16];
        fgets(buf, sizeof(buf), stdin);
        printf("%s", buf);
    }
    
    /* 编译: gcc -fstack-protector-strong -O0 -S -o canary.s canary.c */
    /* 或: gcc -fstack-protector-strong -O0 -o canary canary.c */
    /* 然后: objdump -d canary | grep -A20 '<demo_canary>' */
    asm
    ; 关键汇编片段 (x86-64):
    ;
    ; 函数序言:
    ;   push   rbp
    ;   mov    rbp, rsp
    ;   sub    rsp, 32
    ;   mov    rax, QWORD PTR fs:0x28      ← 从 TLS 读取 canary
    ;   mov    QWORD PTR [rbp-8], rax      ← 写入栈帧
    ;
    ; ... 函数体 ...
    ;
    ; 函数尾声:
    ;   mov    rax, QWORD PTR [rbp-8]      ← 从栈帧读取 canary
    ;   xor    rax, QWORD PTR fs:0x28      ← 与 TLS 原始值比较
    ;   je     .L_ok                        ← 相同 → 安全退出
    ;   call   __stack_chk_fail             ← 不同 → 终止
    ; .L_ok:
    ;   leave
    ;   ret
  4. 编写 is_stack_safe 检查函数。实现一个函数,接受字符串长度和缓冲区大小,返回 0(安全)或 1(可能溢出)。考虑 fgets、strncpy(需 \0)、snprintf 等不同函数的边界语义。

    知识点提示:不同的安全函数的边界语义不同——fgets(buf, N, ...) 最多写 N 字节(含 \0),要求 N <= sizeof(buf);strncpy(dst, src, N) 最多写 N 字节但不保证 \0,安全条件是 N <= sizeof(dst) 且 strlen(src) < N。

    参考解答
    ex4_stack_safety_check.c
    c
    #include <stddef.h>
    
    /* 检查 fgets 是否安全 */
    int fgets_is_safe(size_t buf_size, int fgets_n) {
        /* fgets(buf, n, ...) 最多写 n 字节 (含 \0)
         * 安全条件: n <= buf_size */
        return fgets_n <= (int)buf_size;
    }
    
    /* 检查 strncpy 是否安全 (含 \0 语义) */
    int strncpy_is_safe(size_t buf_size, size_t src_len, int n) {
        /* strncpy(dst, src, n): 最多拷贝 n 字节
         * 安全条件: n <= buf_size AND src_len < n (确保 \0) */
        return n <= (int)buf_size && src_len < (size_t)n;
    }
    
    /* 检查 snprintf 是否安全 */
    int snprintf_is_safe(size_t buf_size, int n) {
        /* snprintf(buf, n, ...): 最多写 n 字节 (含 \0)
         * 安全条件: n <= buf_size */
        return n <= (int)buf_size;
    }
  5. 用 checksec 分析系统二进制。在自己的 Linux 系统上,使用 checksec --file=/bin/ls(或 readelf -l / readelf -d)查看系统二进制的安全特性。记录哪些防护已启用,哪些未启用,并思考原因。

    知识点提示:checksec 是 pwntools 中的工具。也可用 readelf -l binary | grep GNU_STACK 检查 NX、readelf -d binary | grep BIND_NOW 检查 Full RELRO、readelf -h binary | grep Type 检查 PIE。

    参考解答
    bash
    # 安装 checksec (来自 pwntools)
    pip install pwntools
    checksec --file=/bin/ls
    
    # 手动检查 (无需 checksec):
    # PIE: ELF type 应为 DYN (shared object file)
    readelf -h /bin/ls | grep Type:
    
    # NX: GNU_STACK 段应缺少 E (Execute) 标志
    readelf -l /bin/ls | grep GNU_STACK
    # 期望输出: GNU_STACK 0x... RW  0x... (没有 E)
    
    # RELRO: 查找 BIND_NOW 标志
    readelf -d /bin/ls | grep -E 'BIND_NOW|FLAGS_1'
    # 期望: (BIND_NOW) 或 (FLAGS_1) 包含 NOW
    
    # Full RELRO: 需要有 BIND_NOW
    # Partial RELRO: 有 GNU_RELRO 段但没有 BIND_NOW

    思考:为什么 /bin/ls 的一些防护可能不如预期全面?可能是因为它不处理不可信输入,性能优先于安全;也可能是因为某些防护(如 Full RELRO)对简单二进制收益不大。


参考资料 ​

  • Aleph One. "Smashing The Stack For Fun And Profit." Phrack Magazine, Vol 7, Issue 49, 1996. — 栈溢出利用的经典开山之作
  • Cowan, C. et al. "StackGuard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks." USENIX Security, 1998. — Stack Canary 的原始论文
  • Shacham, H. "The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)." ACM CCS, 2007. — ROP 的奠基论文
  • CWE-120: Buffer Copy without Checking Size of Input. https://cwe.mitre.org/data/definitions/120.html
  • CERT C Coding Standard: STR31-C — Guarantee that storage for strings has sufficient space for character data and the null terminator
  • MISRA C:2012, Rule 21.6 — "The Standard Library function gets shall not be used."
  • GCC Manual: Program Instrumentation Options — -fstack-protector, -fstack-protector-strong, -fstack-protector-all

"Those who cannot remember the past are condemned to repeat it." — George Santayana

Released under the MIT License.