整数溢出与回绕:一个老生常谈却总在犯的错
说实话,整数溢出这个问题,我入行头三年就踩过不下五次坑。每次复盘时都觉得“这么简单怎么还能错”,但下一次代码评审时,又能在别人的代码里发现同样的毛病。今天咱们就把这个“老朋友”彻底聊透。
什么是整数溢出?说白了就是“装不下了”
计算机里的整数,就像一个个固定大小的箱子。你往一个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;
}
我在项目中遇到过类似的情况。一个网络协议栈的序列号字段,设计时没考虑溢出,结果在高并发下序列号回绕,导致数据包被错误地丢弃。排查了整整两天,最后发现是整数溢出的锅。
无符号整数溢出:回绕(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_t、uint64_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
知识体系总览
下面这张图总结了整数溢出与回绕的核心知识点,以及它们之间的关联。我建议你把它保存下来,写代码时对照着看。
避坑指南
最后,分享几个我亲身踩过的坑,希望能帮你少走弯路。
- 我曾经在写网络协议解析时,用
int存储报文长度字段。结果一个恶意报文构造了0x7FFFFFFF + 1的长度,直接让解析器崩溃。后来我改用uint32_t并加了溢出检查。 - 我曾经在嵌入式设备上写了一个循环,用
unsigned char做计数器。循环256次后计数器回绕到0,导致死循环。设备死机,只能拔电源重启。 - 我曾经在代码评审中看到有人写
malloc(n * sizeof(T)),没有做任何溢出检查。我问他为什么,他说“n不会那么大”。结果三个月后,这个漏洞被安全团队挖出来了。
整数溢出看似简单,但它的危害往往隐藏在复杂的调用链中。我个人的习惯是:只要涉及外部输入的长度、数量、索引,一律做溢出检查。宁可多写几行代码,也不要留一个潜在的漏洞。