第三章 常见边界漏洞类型

边界验证这个事儿,说起来简单,做起来全是坑。我在项目中见过太多因为边界没封住导致的线上事故了。今天我就把这四种最常见的漏洞类型掰开揉碎了讲清楚。

为什么要先搞懂漏洞类型?因为不同漏洞的根因、攻击路径、修复手段完全不一样。你要是把所有边界问题都当成同一类来对待,那就是给自己挖坑。

核心观念:边界漏洞的本质是“程序假设了一个闭合区间,但攻击者找到了一个可以流出的缺口”。

常见边界漏洞四象限 边界验证漏洞 整数溢出 缓冲区溢出 逻辑边界错误 并发边界冲突 上溢 / 下溢 类型转换截断 栈溢出 / 堆溢出 格式化字符串 越界索引 / 符号误判 协议/状态机缺陷 竞态条件 / 死锁 线程安全 / 事务隔离 💡 实战要点 每种漏洞的测试方法不同:整数溢出靠边界值枚举,缓冲区溢出靠模糊测试, 逻辑边界靠状态覆盖,并发边界靠压力与时间窗口探测。

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 这四种漏洞的共性思维

到这儿你应该发现了,不管什么漏洞,背后都是一个共同的思维模型:

  • 定义边界:程序能接受的合法值/状态/时间窗口
  • 寻找缺口:攻击者能否在边界边缘构造一个「刚好跨出去」的输入
  • 利用缺口:跨出去之后会产生什么后果——覆写内存、绕过逻辑、破坏原子性

我个人习惯在写完每一段边界验证代码后,主动问自己三个问题:

  1. 如果用户传进来的是最大值/最小值/负数/空,会怎样?
  2. 如果两个用户同时操作同一个资源,会不会有竞态?
  3. 如果类型转换/算术运算溢出,后续逻辑能否兜底?

提醒:边界验证不是一次性工作。你改了代码的任意一部分,就需要重新审视所有涉及边界的地方。我曾经因为加了日志打印,顺手改了一个变量类型,结果偷偷引入了一个整数截断漏洞。测试都没测出来,上线后被攻击了。

好了,这四种基础漏洞类型就讲到这里。后面的章节我们会逐个深入,给出具体的测试方法和防护代码。你先把这个四象限图印在脑子里,以后看到任何边界代码,就能快速归类和排查。