第三章 常见边界漏洞类型
边界验证这个事儿,说起来简单,做起来全是坑。我在项目中见过太多因为边界没封住导致的线上事故了。今天我就把这四种最常见的漏洞类型掰开揉碎了讲清楚。
为什么要先搞懂漏洞类型?因为不同漏洞的根因、攻击路径、修复手段完全不一样。你要是把所有边界问题都当成同一类来对待,那就是给自己挖坑。
核心观念:边界漏洞的本质是“程序假设了一个闭合区间,但攻击者找到了一个可以流出的缺口”。
3.1 整数溢出
整数溢出,说白了就是数字在内存里装不下了,翻出去变成另一个数。我遇到过好几次因为 unsigned 下溢导致的认证绕过,惨痛啊。
先看一个典型例子:
// 示例:整数溢出导致分配过小缓冲区
size_t size = 0;
scanf("%zu", &size);
// 用户输入 0xFFFFFFFF,size + 1 溢出变成 0
char *buf = malloc(size + 1);
// 实际只分配了 0 字节
memcpy(buf, data, user_len); // 堆溢出
为什么会这样?因为 size_t 是无符号类型,最大值加 1 就回绕到 0。你想想看,这里如果用户传入 0xFFFFFFFF,那么 size + 1 就变成了 0,后续 memcpy 直接写穿堆。
| 溢出类型 | 典型场景 | 后果 |
|---|---|---|
| 无符号上溢 | 计数器累加到最大值后归零 | 循环条件误判、死循环 |
| 无符号下溢 | 从 0 减 1 变成最大值 | 无限循环、越界访问 |
| 有符号上溢/下溢 | int 最大值 +1 变为负数 | 符号反转、逻辑分支错误 |
| 截断转换 | uint64 转 uint32 丢失高位 | 大数变小、绕过大小检查 |
我踩过的坑:曾经有个金融系统,金额字段用了 int,做累加时服务器内存 2^31-1 后溢出成负数。结果用户的余额变成了负的几亿。修复那件事,我加了 3 天班。
防御手段其实很固定:
- 使用安全算术函数(如 C 的
__builtin_add_overflow、Go 的math.AddUint32、Rust 的checked_add) - 在类型转换前做显式范围检查
- 避免把用户可控值直接用于长度计算
小技巧:我习惯在每一个做加法的地方都问自己 "这个操作会不会溢出?" 如果答案是 "可能会",那就必须加检查。
3.2 缓冲区溢出
缓冲区溢出,经典中的经典。我入行第一年就在一个网络协议栈里发现了栈溢出漏洞,当时差点被吓死。
核心问题就一句话:写入的数据超过了分配的空间。分两种——栈溢出和堆溢出。
栈溢出例子:
void vulnerable(char *input) {
char buf[64];
// 如果 input 长度超过 63,直接覆盖返回地址
strcpy(buf, input);
}
堆溢出例子:
void heap_overrun(char *input, size_t len) {
char *buf = malloc(128);
// len > 128 时写入堆块之外,可能覆盖堆管理元数据
memcpy(buf, input, len);
}
这里有个关键点:偏移量计算也会导致缓冲区溢出。我记得在一个加密库中看到如下代码:
int offset = user_offset;
if (offset < 0 || offset > MAX_SIZE) {
// 校验通过
return safe_copy(base + offset); // 但 offset 可能是 MAX_SIZE,base+MAX_SIZE 越界了
}
看清楚没?条件是 offset > MAX_SIZE 才拒绝,但 offset == MAX_SIZE 时 base + MAX_SIZE 指向了分配空间的末尾之外。
| 缓冲区类型 | 特征 | 攻击向量 |
|---|---|---|
| 栈缓冲区 | 局部变量,返回地址紧邻其后 | 覆盖返回地址 → ROP |
| 堆缓冲区 | 动态分配,相邻堆块可能包含数据指针 | 覆盖堆元数据 → 任意地址写 |
| 全局 BSS | 静态数组,溢出可能影响相邻全局变量 | 覆盖函数指针 → 劫持流程 |
警告:别以为用了安全函数就万无一失。像 strncpy 不保证字符串以 \0 结尾,照样能酿成麻烦。
3.3 逻辑边界错误
这种漏洞最隐蔽,因为编译器检查不出来,测试也容易漏。说白了就是你的业务逻辑假设了一个区间,但实际输入能突破这个假设。
典型例子:数组越界索引。嗯,这个很直白。
int arr[10];
int idx = get_user_input(); // 用户输入 10
arr[idx] = 42; // 写入 arr[10],越界
更隐蔽的是符号错误导致的逻辑边界。我修复过一个协议实现:
int length = read_u32(packet);
if (length < PACKET_HEADER_SIZE) {
// 认为长度太小,直接丢弃
return ERROR;
}
// 后面用 size = length - PACKET_HEADER_SIZE 做拷贝
// 如果 length 是负数(实际存成了有符号),length < PACKET_HEADER_SIZE 成立,但 length=0x80000001 代表负数,减来减去反而得到一个超大正数
为什么会这样?因为 length 本应是无符号数,但用了 int。攻击者把最高位改成 1,立刻变成负数,检查虽然通过了,但后续运算全乱套。
我的原则:所有长度、索引、偏移量,一律用无符号类型。有符号数只能在需要表示负数的业务场景里用,比如温度。
逻辑边界错误还包括「状态机边界」——比如一个协议握手有 4 个状态,攻击者可以跳过中间状态直接发送最后一步,导致资源泄露或认证绕过。
3.4 并发边界冲突
并发边界的漏洞,是我觉得最头疼的一类。你单线程跑一万次都正常,一上并发就炸。为什么?因为边界环境的判断在时间窗口里被别人改了。
经典例子——TOCTOU(Time-of-Check Time-of-Use,检查时刻与使用时刻不一致):
if (!file_exists(path)) {
// 检查通过,路径可用
create_file(path);
} else {
// 文件已存在,报错退出
return ERROR;
}
问题在于:在检查通过之后、创建文件之前,另一个线程可能已经把那个路径占用了。这就是边界条件在时间维度上的撕裂。
另一个常见的是「非原子减引」:
if (counter > 0) {
// 两个线程同时进入这里
counter--;
// 结果 counter 从 1 变成了 -1
}
我参与过一个游戏服务器项目,排行榜分数就用这种模式。结果并发压力下分数变成了负数,最高分玩家被系统封号了。这事儿后来全公司都知道,我脸上挂不住啊。
| 并发漏洞类型 | 描述 | 典型场景 |
|---|---|---|
| TOCTOU | 检查与使用之间被篡改 | 文件操作、权限验证 |
| 非原子递减 | 读-改-写序列不是原子的 | 计数器、资源池 |
| 双重释放 | 两个线程同时释放同一资源 | 内存管理、句柄关闭 |
| 时钟回绕 | 超时计算溢出 | 心跳超时、会话过期 |
避开坑的方法:我一般用三种武器——原子操作(如 atomic_add)、锁(粒度要小)、以及业务层的事务隔离。但要注意,锁本身也有边界问题(死锁、活锁)。
3.5 这四种漏洞的共性思维
到这儿你应该发现了,不管什么漏洞,背后都是一个共同的思维模型:
- 定义边界:程序能接受的合法值/状态/时间窗口
- 寻找缺口:攻击者能否在边界边缘构造一个「刚好跨出去」的输入
- 利用缺口:跨出去之后会产生什么后果——覆写内存、绕过逻辑、破坏原子性
我个人习惯在写完每一段边界验证代码后,主动问自己三个问题:
- 如果用户传进来的是最大值/最小值/负数/空,会怎样?
- 如果两个用户同时操作同一个资源,会不会有竞态?
- 如果类型转换/算术运算溢出,后续逻辑能否兜底?
提醒:边界验证不是一次性工作。你改了代码的任意一部分,就需要重新审视所有涉及边界的地方。我曾经因为加了日志打印,顺手改了一个变量类型,结果偷偷引入了一个整数截断漏洞。测试都没测出来,上线后被攻击了。
好了,这四种基础漏洞类型就讲到这里。后面的章节我们会逐个深入,给出具体的测试方法和防护代码。你先把这个四象限图印在脑子里,以后看到任何边界代码,就能快速归类和排查。