Software Security · 4 Cases · 2026

4 段 C/C++ 代码,4 类经典漏洞,4 条控制权交付路径

  • example1 · 栈缓冲区溢出:8 字节口令配合 strcpy 写穿 auth → 直接绕过认证
  • example2 · 格式化字符串:用户输入做 printf 模板 → %n 任意地址写 → 改 GOT 拿 shell
  • example3 · 释放后使用:tcache 把同 size 块 LIFO 还回 → 悬空指针写入即改 vtable
  • example4 · 竞争条件 / TOCTOU:检查与使用之间的 1 秒窗口被另一线程改值 → "hack it!"
TL;DR · 一句话定性
四例分别命中 栈、堆、栈格式化串、共享变量 四个攻击面,最终终点都是 "攻击者夺走程序的下一条指令决定权"—— 从浅到深排序:认证绕过 → 信息泄露 → 任意地址写 → 远程代码执行。 共同根因不是 "C 语言不安全",而是 四种"误信任":信任输入长度、信任输入语义、信任指针仍有效、信任时序状态不变。
Part I · 单进程内存漏洞

§1 栈缓冲区溢出 · 8 字节口令直接绕过认证 CWE-121 · Stack Overflow

输入 8 字节非密码字符串,strcpy 写 9 字节(含 '\0'),那个 '\0' 正好覆盖到相邻整数 auth 的最低位—— 认证 flag 被自己改成 0,不需要任何 shellcode,简单到只要 8 个 'A'。

漏洞源码

int verify(char *password) {
    int  auth;                              // 栈上 4 字节整数
    char buffer[8];                         // 栈上 8 字节缓冲区
    auth = strcmp(password, PASSWORD);      // 密码错 → auth ≠ 0
    strcpy(buffer, password);              // ❌ 无边界检查
    return auth;
}

栈帧布局(高地址 → 低地址,写入方向 ↓)

0xffff_d018
return addr · main 调 verify 后的返回点
进阶利用 = 改这里 → ret2libc / ROP
0xffff_d014
saved ebp · 上层栈帧指针
改这里 = stack pivot
0xffff_d010
int auth · 认证标志 (4 B)
← strcpy 的第 9 字节落在这里
0xffff_d008
char buffer[8] · 8 字节缓冲区
strcpy 起点 · 写入方向 ↓ 向高地址

利用 1 · 最简 PoC · 认证绕过

输入 "AAAAAAAA"(8 字节,不是 "1234567"):

  1. strcmp("AAAAAAAA", "1234567") 返回非零 → auth ≠ 0
  2. strcpy(buffer, "AAAAAAAA") 实际复制 9 字节(含字符串结尾的 '\0'
  3. 第 9 字节 '\0' 写入 buffer[8] 之后相邻的 auth 最低位 → 小端机上 auth = 0
  4. verify 返回 0 → flag = 0 → 进入 congratulation! 分支
整个攻击不需要 shellcode、不需要 ROP、不需要 ASLR 信息泄露 —— 只需要让 strcpy 多写一个零字节,让认证标志自己把自己清零。这正是栈溢出最朴素的形态。

利用 2 · 进阶 · 覆盖返回地址实现远程代码执行(Remote Code Execution, RCE)

继续把输入加长到几十字节,写到 return addr

[ buffer 8B ][ auth 4B ][ saved ebp 4B ][ return addr 4B ][ shellcode ... ]
🛡️
修复:① strncpy(buffer, password, sizeof(buffer)-1) + 显式补 '\0',或 snprintf;② 编译时打开 -fstack-protector-strong + PIE + _FORTIFY_SOURCE=2;③ 运行时启用 ASLR + DEP/NX;④ scanf("%s", pass) 改为 scanf("%1023s", pass)

§2 格式化字符串 · 用户输入即模板,从泄露到任意地址写 CWE-134 · Format String

printf 把用户输入当成 format string,按 %x %s %n 依次去栈/寄存器取"参数"—— 调用者根本没传那些参数,于是 printf 读到的是栈上残值,写到的是攻击者指定的地址。

漏洞源码

char a[100];
scanf("%s", a);
printf(a);     // ❌ 用户输入做 format string · 正确写法 printf("%s", a);

四级利用梯度

输入效果用途
%x %x %x %x打印栈上 4 个 dword信息泄露:泄露 canary、libc 基址、返回地址 → 绕 ASLR
%s把"参数"当指针解引用任意地址读,配合 %N$s 选第 N 个参数槽
%n把已输出字节数写入参数指向的地址任意地址写—— 真正的杀招
%hn / %hhn按 short / byte 写入精确控制:4 步写完一个 64 位指针

经典 RCE 利用链

01
定位偏移
%p %p %p ... 找到"格式串自身在第几个参数槽"(例:第 6 个)。
02
放入目标地址
在输入开头放 printf@GOT 的地址 A(GOT, Global Offset Table, 全局偏移表)。
03
构造写入
%<count>x%6$hn 把 A 指向的位置写成 system 的地址。
04
触发劫持
下次调用 printf 实际跳到 system;首参数控成 "/bin/sh"Shell
格式化字符串是 C 标准库给攻击者的"免费的任意地址读写原语"——只要 printf 的第一个参数被污染,从信息泄露到代码执行就是一条直线。
🛡️
修复:永远用常量格式串 printf("%s", a) 或直接 puts(a);编译时开 -Wformat -Wformat-security -Werror=format-security,编译器会直接拒绝可疑写法;输入端 scanf("%99s", a) 限制长度。

§3 释放后使用(Use After Free, UAF)· 同 size 块被原地还回 CWE-416 · UAF

free 之后 p1 仍持有旧地址(悬空指针,dangling pointer);下次 malloc 同 size,glibc tcache / fastbin 把刚释放的块原地还回 → p2 == p1,通过 p1 写就是改 p2 的内容。

漏洞源码

p1 = malloc(10);
memcpy(p1, "hello", 10);
free(p1);                  // ① 释放 p1,但变量 p1 仍持有旧地址 ← 悬空指针

p2 = malloc(10);            // ② tcache LIFO 还回同一块 → p2 == p1
memcpy(p1, "world", 10);   // ③ 通过 p1 写,实际改了 p2
printf("%s\n", p2);         // 输出 "world" · 印证 p1==p2

tcache / fastbin 复用示意

step 1
malloc(10) → 块 X · 写 "hello"
p1 = &X
step 2
free(p1) · 块 X 挂回 tcache freelist
p1 未置 NULL · 仍指 &X(悬空)
step 3
malloc(10) → tcache LIFO 返回块 X
p2 = &X · p1 == p2
step 4
memcpy(p1, "world", 10) → 改的是块 X
从 p2 视角看就是数据被外部篡改

glibc 2.26+ 默认开启 tcache,单线程的小块(≤ 0x80)走 LIFO 复用 —— 这正是上面 p1==p2 的根因。fastbin 行为类似。

三条真实利用路径

01
vtable 劫持
C++ 场景:delete obj 后悬空指针残留,攻击者 堆喷射new TargetObject 落到同地址 → obj->virtual_func() 跳到攻击者控制的 vtable → RCE。浏览器 V8 / JSC / Chakra 漏洞 90% 是这条路。
02
tcache 投毒
被 free 的块前 8 字节是 fd(freelist 下一指针)。悬空指针写 fd = 攻击者目标地址 → 下次 malloc 返回该目标地址 → 写任意地址
03
double free
若再 free(p1),旧 glibc 可绕过 double-free 检测,同一块被挂回 freelist 两次 → 后续 malloc 返回攻击者控制的指针。
释放后使用 的本质是 "指针的生命周期 ≠ 内存的生命周期"—— 内存已经被分配器回收并重新分配给别人,但程序仍按旧角色调用它,类型混淆由此发生。
🛡️
修复:① free(p1) 之后立刻 p1 = NULL(消除悬空指针,再 deref 即段错误,不可利用);② C++ 全面切到 std::unique_ptr / std::shared_ptr 等 RAII 智能指针;③ 编译运行期加 AddressSanitizer (-fsanitize=address)、MemorySanitizer、GWP-ASan;④ Chrome 引入的 MiraclePtr 在 deref 时主动检查 quarantine。
Part II · 并发与共性

§4 竞争条件 · 检查与使用之间的时序裂缝 CWE-362 / 367 · TOCTOU

两次检查 i 之间的 1 秒窗口足够另一线程把 i 从 1 改成 2 —— 这是 检查时间与使用时间分离(Time-Of-Check to Time-Of-Use, TOCTOU)的最小可执行教学样本。

漏洞源码

int i = 1;                          // 共享变量, 无 mutex、无 atomic

void *mythread1() {
    if(i == 1) {                    // ① TOC: 检查
        sleep(1);                   // ② 窗口期 1 秒
        if(i == 2)                  // ③ TOU: 使用 · 期望"没被改",实际被改了
            printf("hack it!\n");
        else
            printf("you can try again!\n");
    }
}
void *mythread2() {
    sleep(1);
    i = 2;                          // ④ 窗口内修改 i
}

线程时序图

t (s)Thread 1 · mythread1Thread 2 · mythread2
t=0.00if (i==1) ✓
t=0.00sleep(1) ──┐sleep(1) ──┐
t≈1.00i = 2 · 落入 T1 窗口
t≈1.00wake ─────┘
t≈1.00if (i==2) ✓ → "hack it!"

现实世界的同类漏洞

⏱️
如何"稳定"触发? 攻击者拉长窗口:拖慢 CPU、注入大量 IO、堆喷射延迟、甚至简单地把 sleep(1) 改成 sleep(3) —— 现实里的内核 race 利用 90% 的工作量都在放大窗口的概率
🛡️
修复:① 互斥锁——pthread_mutex_lock/unlock 把"检查—使用"包成临界区;② 原子化——_Atomic int istdatomic.hatomic_load / atomic_compare_exchange_strong;③ 消除窗口比加锁更彻底——用 openat(O_NOFOLLOW) 一步完成、数据库用 UPDATE ... WHERE balance >= amount 原子操作。

§5 四例横向对比 · 触发条件 · 危害终点 · 修复抓手 L20 · Multi-row Comparison

把四例放到同一张表上看,可以同时识别 攻击面(栈/堆/格式化串/共享变量)、危害梯度、修复入口—— 任何一行修不彻底,链条就成立。

编号 漏洞 CWE 攻击面 触发条件 危害 经典终点 修复抓手
栈缓冲区溢出 CWE-121 strcpy 写入 ≥ 9 字节 认证绕过 → 覆盖 ret addr → ret2libc / ROP → RCE strncpy / snprintf-fstack-protector-strong;ASLR + PIE + NX
格式化字符串 CWE-134 栈 + 寄存器 printf(用户输入) 极高 泄露 canary/libc → %n 任意地址写 → 改 GOT → RCE printf("%s", a)-Wformat-securityscanf("%99s")
释放后使用 CWE-416 free 后悬空指针被写/读 极高 vtable 劫持 · tcache 投毒 · double free → RCE free 后置 NULL;unique_ptr / shared_ptr;ASAN;MiraclePtr
竞争条件 / TOCTOU CWE-362 / 367 共享变量 · 文件 · 内核 共享资源无同步 + 时序窗口 中-高 文件 / 权限 / 余额绕过 → 提权 · 双花 · Dirty COW pthread_mutex · _Atomicopenat(O_NOFOLLOW);原子 UPDATE
📊
梯度规律:①②③ 都能走到 RCE,④ 更常停在"权限 / 状态绕过"。从修复成本看,①④ 改一行代码即可(边界检查 / 加锁),②③ 涉及 API 习惯 + 内存生命周期模型,是更深的架构改动。

§6 共同根因 · 四种"误信任" Root Cause

四个漏洞不是 C 语言的"语言罪",而是 程序员对外部世界的四类隐含假设全部错了—— 假设一旦被打破,程序就把决定权交给了攻击者。

信任输入长度
栈溢出 假设用户输入不会超过 buffer 大小。修复 = 把"长度"作为输入的一等公民来检查与裁剪。
信任输入语义
格式化字符串 假设用户输入是"数据",但 printf 把它当成"代码"(模板)。修复 = 数据通道与控制通道严格分离
信任指针仍有效
释放后使用 假设变量持有的地址依然指向"那个对象"。修复 = 让指针的生命周期与对象的生命周期严格同步(RAII / NULL 化)。
信任时序状态不变
竞争条件 假设"我刚刚检查过的东西,现在还是那样"。修复 = 让"检查 + 使用"是一步原子操作,而不是两步。
四种漏洞,四种信任 —— 安全代码的根本范式从来不是 "更小心一点",而是 "把所有隐含假设变成显式校验"
Recap · 四例分析要点回顾
01
4 类漏洞
栈溢出 · 格式化字符串 · 释放后使用 · 竞争条件 — 覆盖 C/C++ 经典攻击面
02
共同终点
三例可走到 RCE,一例走到提权 / 状态绕过 — 攻击者夺指令决定权
03
四种误信任
长度 · 语义 · 指针有效性 · 时序状态 — 都是未显式校验的隐含假设
04
现代缓解
ASAN · ASLR · NX · Canary · RAII · Atomic · -Wformat-security多层防御组合
栈漏洞堆漏洞格式化串时序漏洞