整数溢出与回绕:一个老生常谈却总在犯的错

说实话,整数溢出这个问题,我入行头三年就踩过不下五次坑。每次复盘时都觉得“这么简单怎么还能错”,但下一次代码评审时,又能在别人的代码里发现同样的毛病。今天咱们就把这个“老朋友”彻底聊透。

什么是整数溢出?说白了就是“装不下了”

计算机里的整数,就像一个个固定大小的箱子。你往一个8位的箱子里塞256,它肯定装不下。装不下的后果,就是溢出。

我习惯把整数溢出分成两类:有符号溢出无符号溢出。它们的表现完全不同,但危害都很大。

有符号整数溢出:正数变负数,负数变正数

有符号整数的最高位是符号位。当数值超过最大值时,符号位会被“挤”成1,正数瞬间变成负数。你想想看,一个计数器从0往上加,突然变成负数,这逻辑还怎么跑?

#include <stdio.h>
#include <limits.h>

int main() {
    int a = INT_MAX;  // 2147483647
    printf("a = %d\n", a);
    a = a + 1;        // 溢出!
    printf("a + 1 = %d\n", a);  // 输出 -2147483648
    return 0;
}

我在项目中遇到过类似的情况。一个网络协议栈的序列号字段,设计时没考虑溢出,结果在高并发下序列号回绕,导致数据包被错误地丢弃。排查了整整两天,最后发现是整数溢出的锅。

⚠️ 警告:C语言标准中,有符号整数溢出是未定义行为。编译器可以优化掉你的溢出检查,甚至让整个程序行为不可预测。别问我怎么知道的——我曾经被GCC优化坑过一次,代码里明明写了溢出判断,结果编译器认为“溢出不会发生”,直接把判断语句优化掉了。

无符号整数溢出:回绕(Wraparound)

无符号整数没有符号位,溢出后不会变成负数,而是从0重新开始。这叫“回绕”。

#include <stdio.h>
#include <limits.h>

int main() {
    unsigned int b = UINT_MAX;  // 4294967295
    printf("b = %u\n", b);
    b = b + 1;                  // 回绕!
    printf("b + 1 = %u\n", b);  // 输出 0
    return 0;
}

无符号回绕在C标准中是定义良好的行为,但这不代表它安全。恰恰相反,很多漏洞利用的就是这个“定义良好”的特性。

截断:把大象塞进冰箱

截断是另一种形式的溢出。当你把一个宽整数赋值给窄整数时,高位被直接砍掉。比如把32位的值赋给16位的变量,高16位就丢了。

#include <stdio.h>

int main() {
    unsigned int big = 0x10000;  // 65536
    unsigned short small = big;  // 截断!
    printf("big = %u, small = %u\n", big, small);  // small = 0
    return 0;
}

嗯,这里要注意:截断是隐式类型转换的一部分,编译器通常不会报错,甚至连警告都不给。我见过最离谱的一个bug,是有人用uint16_t存储文件大小,结果文件超过64KB后,读取到的全是0。

整数溢出的危害:内存分配和循环中的“定时炸弹”

整数溢出最危险的地方,不在算术运算本身,而在它引发的连锁反应。我重点说两个场景。

场景一:内存分配

假设你要分配n * sizeof(type)字节的内存。如果n很大,乘法可能溢出,结果变成一个很小的值。然后你分配了一个小缓冲区,却往里面写大量数据——缓冲区溢出就来了。

#include <stdlib.h>
#include <string.h>

void vulnerable(size_t n) {
    // 如果 n 很大,n * sizeof(int) 可能溢出
    int *buf = (int *)malloc(n * sizeof(int));
    if (!buf) return;
    
    // 假设这里从网络读取 n 个 int 数据
    // 实际分配的缓冲区可能远小于 n * sizeof(int)
    // 写入时就会越界
    memset(buf, 0, n * sizeof(int));  // 危险!
    free(buf);
}

我在一次代码审计中遇到过类似的漏洞。一个图片处理库,计算像素数据缓冲区大小时用了width * height * bytes_per_pixel。攻击者构造了超大的width和height,让乘法溢出,结果分配了一个极小的缓冲区。后续的像素写入直接覆盖了堆上的其他数据,最终实现了远程代码执行。

💡 关键点:整数溢出本身不直接导致代码执行,但它能制造出缓冲区溢出、越界读写等更严重的漏洞。攻击者往往利用整数溢出作为“跳板”。

场景二:循环控制

循环条件中的整数溢出,可能导致死循环或提前退出。

#include <stdio.h>

void loop_vulnerable() {
    unsigned int i;
    // 这个循环永远不会结束!
    for (i = 0; i <= 10; i--) {
        printf("i = %u\n", i);
        if (i == 0) {
            // 这里想重置 i 为 10,但...
            i = 10;  // 实际上 i 会继续递减,回绕到 UINT_MAX
        }
    }
}

为什么会这样?因为i是无符号整数,i--i为0时会回绕到UINT_MAX,永远满足i <= 10。这种bug在嵌入式系统中特别常见,我调试过一个电机控制程序,就是因为循环溢出导致电机一直转停不下来。

安全检查方法:怎么防?

说了这么多危害,咱们得聊聊怎么防。我总结了几个实用方法。

方法一:使用安全算术函数

C标准库提供了带溢出检测的算术函数(C11起)。比如__builtin_add_overflow(GCC/Clang)或sadd_overflow(某些库)。

#include <stdio.h>
#include <stdbool.h>

// 使用 GCC 内置函数检测加法溢出
bool safe_add(int a, int b, int *result) {
    return __builtin_add_overflow(a, b, result);
}

int main() {
    int a = INT_MAX;
    int b = 1;
    int result;
    
    if (safe_add(a, b, &result)) {
        printf("溢出!无法计算\n");
    } else {
        printf("结果 = %d\n", result);
    }
    return 0;
}

方法二:分配前做乘法检查

内存分配时,先检查乘法是否溢出。我习惯用size_mul这类辅助函数。

#include <stdlib.h>
#include <limits.h>
#include <stdbool.h>

bool size_mul(size_t a, size_t b, size_t *result) {
    if (a == 0 || b == 0) {
        *result = 0;
        return true;
    }
    if (a > SIZE_MAX / b) {
        return false;  // 溢出
    }
    *result = a * b;
    return true;
}

void *safe_malloc_array(size_t count, size_t elem_size) {
    size_t total;
    if (!size_mul(count, elem_size, &total)) {
        return NULL;  // 溢出,拒绝分配
    }
    return malloc(total);
}
💡 小技巧:检查乘法溢出时,用SIZE_MAX / b的方式,比直接比较a * b > SIZE_MAX更安全。因为后者在计算a * b时就已经溢出了。

方法三:使用固定宽度类型

尽量使用stdint.h中定义的固定宽度类型,比如int32_tuint64_t。这样你能清楚地知道每个变量的取值范围,不容易踩坑。

#include <stdint.h>

// 明确知道取值范围
uint32_t counter = 0;  // 0 ~ 4294967295
int16_t temperature;   // -32768 ~ 32767

方法四:静态分析工具

人眼检查总会有遗漏。我推荐在CI流程中加入静态分析工具,比如Coverity、Clang Static Analyzer,或者开源的cppcheck。它们能自动检测出潜在的整数溢出路径。

# 使用 cppcheck 检测整数溢出
cppcheck --enable=all --suppress=missingIncludeSystem your_code.c

知识体系总览

下面这张图总结了整数溢出与回绕的核心知识点,以及它们之间的关联。我建议你把它保存下来,写代码时对照着看。

整数溢出与回绕知识体系 整数溢出 有符号溢出(UB) 无符号回绕(定义良好) 内存分配溢出 → 缓冲区溢出 循环控制溢出 → 死循环/提前退出 截断(窄化转换) 安全算术函数 分配前检查 固定宽度类型 静态分析工具

避坑指南

最后,分享几个我亲身踩过的坑,希望能帮你少走弯路。

  • 我曾经在写网络协议解析时,用int存储报文长度字段。结果一个恶意报文构造了0x7FFFFFFF + 1的长度,直接让解析器崩溃。后来我改用uint32_t并加了溢出检查。
  • 我曾经在嵌入式设备上写了一个循环,用unsigned char做计数器。循环256次后计数器回绕到0,导致死循环。设备死机,只能拔电源重启。
  • 我曾经在代码评审中看到有人写malloc(n * sizeof(T)),没有做任何溢出检查。我问他为什么,他说“n不会那么大”。结果三个月后,这个漏洞被安全团队挖出来了。

整数溢出看似简单,但它的危害往往隐藏在复杂的调用链中。我个人的习惯是:只要涉及外部输入的长度、数量、索引,一律做溢出检查。宁可多写几行代码,也不要留一个潜在的漏洞。

📌 总结:整数溢出不是“会不会发生”的问题,而是“什么时候发生”的问题。你写的每一行代码,都可能成为攻击者的突破口。把安全检查当成习惯,而不是事后补救。

公众号:蓝海资料掘金营,微信deep3321