4 段 C/C++ 代码,4 类经典漏洞,4 条控制权交付路径
- example1 · 栈缓冲区溢出:8 字节口令配合 strcpy 写穿 auth → 直接绕过认证
- example2 · 格式化字符串:用户输入做 printf 模板 → %n 任意地址写 → 改 GOT 拿 shell
- example3 · 释放后使用:tcache 把同 size 块 LIFO 还回 → 悬空指针写入即改 vtable
- example4 · 竞争条件 / TOCTOU:检查与使用之间的 1 秒窗口被另一线程改值 → "hack it!"
§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;
}
栈帧布局(高地址 → 低地址,写入方向 ↓)
利用 1 · 最简 PoC · 认证绕过
输入 "AAAAAAAA"(8 字节,不是 "1234567"):
strcmp("AAAAAAAA", "1234567")返回非零 →auth ≠ 0strcpy(buffer, "AAAAAAAA")实际复制 9 字节(含字符串结尾的'\0')- 第 9 字节
'\0'写入buffer[8]之后相邻的auth最低位 → 小端机上auth = 0 verify返回 0 →flag = 0→ 进入 congratulation! 分支
利用 2 · 进阶 · 覆盖返回地址实现远程代码执行(Remote Code Execution, RCE)
继续把输入加长到几十字节,写到 return addr:
[ buffer 8B ][ auth 4B ][ saved ebp 4B ][ return addr 4B ][ shellcode ... ]
- 栈可执行 → 跳到栈上 shellcode(DEP 之前的远古玩法)
- NX / 数据执行保护 启用 → ret2libc:把 return addr 改成
system,参数放成"/bin/sh" - 地址空间布局随机化(Address Space Layout Randomization, ASLR)+ 位置无关可执行文件(Position-Independent Executable, PIE)→ ROP(Return-Oriented Programming, 返回导向编程),从已加载库里凑指令片段
main是while(1)循环,失败可重试 → 适合 爆破 stack canary(一字节 256 次)
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 利用链
%p %p %p ... 找到"格式串自身在第几个参数槽"(例:第 6 个)。printf@GOT 的地址 A(GOT, Global Offset Table, 全局偏移表)。%<count>x%6$hn 把 A 指向的位置写成 system 的地址。printf 实际跳到 system;首参数控成 "/bin/sh" → Shell。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 复用示意
glibc 2.26+ 默认开启 tcache,单线程的小块(≤ 0x80)走 LIFO 复用 —— 这正是上面 p1==p2 的根因。fastbin 行为类似。
三条真实利用路径
delete obj 后悬空指针残留,攻击者 堆喷射让 new TargetObject 落到同地址 → obj->virtual_func() 跳到攻击者控制的 vtable → RCE。浏览器 V8 / JSC / Chakra 漏洞 90% 是这条路。fd(freelist 下一指针)。悬空指针写 fd = 攻击者目标地址 → 下次 malloc 返回该目标地址 → 写任意地址。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。§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
}
线程时序图
现实世界的同类漏洞
- 文件 TOCTOU:
access(path, R_OK)通过后open(path),攻击者两步之间把path换成/etc/shadow软链 → setuid 程序泄密 / 提权 - 内核 race:Dirty COW(CVE-2016-5195)—— Copy-On-Write 与 madvise 的 race,写只读映射 → root 提权
- 业务层 race:余额检查 + 扣款不原子 → 双花 / 重复领券(电商高频被刷的入口)
- TLS race:
pthread_create后未同步初始化 → 拿到未初始化的线程局部存储槽 → 信息泄露
sleep(1) 改成 sleep(3) —— 现实里的内核 race 利用 90% 的工作量都在放大窗口的概率。pthread_mutex_lock/unlock 把"检查—使用"包成临界区;② 原子化——_Atomic int i 或 stdatomic.h 的 atomic_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-security;scanf("%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 · _Atomic;openat(O_NOFOLLOW);原子 UPDATE |
§6 共同根因 · 四种"误信任" Root Cause
四个漏洞不是 C 语言的"语言罪",而是 程序员对外部世界的四类隐含假设全部错了—— 假设一旦被打破,程序就把决定权交给了攻击者。