☰
整数溢出:从二进制真相到工程防御,彻底消除隐蔽数值炸弹
2026/10/3 1:36:33 网站建设 项目流程

1. 从一次线上事故说起:一张PNG图片拖垮了整个图片服务

这事发生在去年年中,我们某个基于C++的图像处理服务突然在凌晨收到大量告警,CPU飙升,紧接着开始批量拒请求,日志里刷满了SIGSEGV的崩溃堆栈。一开始运维以为是内存不够触发了OOM,加了内存和副本数之后,问题依旧间歇性复现,而且只集中在某些特定尺寸的上传图片上。这让值班的人很头疼——图片处理服务崩溃的排查链路实在太长,解码、缩放、滤波、编码,哪个环节都可能翻车。

后来把core dump拉下来,用gdb回溯调用栈,发现崩溃点居然落在了一个看起来完全无害的分配逻辑上:new unsigned char[width * height * channels]。三四个小时的排查,最后定位到的问题就是整数溢出。宽高来自用户上传的PNG头信息,攻击者构造了一个畸形的图片尺寸,长和宽数值相乘之后超过了unsigned int能表达的最大范围,分配出来的buffer远小于实际写入的数据量,内存被写穿,进程直接段错误。

这个事故让我意识到一个问题:很多人觉得整数溢出是老掉牙的八股知识,考试背一背就完了,实际上在真实工程里,它仍然能制造非常隐蔽、非常严重的故障。尤其当你的代码涉及外部输入解析、大小计算、索引运算、内存分配时,整数溢出不是"会不会发生"的问题,而是"什么时候发生"的问题。所以我决定把无符号整数、带符号整数、以及整数溢出这三件事彻底讲清楚,道理讲透,案例给够,方案给全,一次性解决这个被低估的高频隐患。

这篇文章适合谁看?写C/C++、Rust、Go这类系统级代码的工程师,写Java/PHP/JavaScript这类业务代码但偶尔需要处理数字边界的后端开发,以及做安全测试、代码审计的朋友。无论你基础在哪个层次,下面的内容都值得从头到尾过一遍。

2. 二进制位上的真相:无符号与带符号到底差在哪

整数溢出不是某个语言特有的怪癖,而是二进制运算的物理宿命。要彻底搞懂它,必须先理解无符号整数和带符号整数在内存里分别是怎么被表达和运算的。

2.1 无符号整数:纯粹的二进制自然数

无符号整数的存储逻辑最直接:每一位二进制数从低位到高位依次代表2的0次方、2的1次方、2的2次方……一直到最高位。以uint8_t为例,8个bit能表达的数值范围就是0到255(即2^8 - 1),换算公式是:

最大值 = 2^n - 1 其中 n 表示位数

所以:

  • uint8_t:0 到 255
  • uint16_t:0 到 65535
  • uint32_t:0 到 4294967295
  • uint64_t:0 到 18446744073709551615

无符号数的好处理解起来没有门槛。但它的运算有一个非常关键的特性:它永远不产生负数。当你计算255 + 1的时候,二进制结果是:

11111111 (255) + 00000001 (1) ---------- 1 00000000 (进位1被丢弃,结果变成0)

CPU的算术逻辑单元(ALU)在加法运算时会产生一个进位标志(Carry Flag),但在无符号整数的语义里,这个进位位不会写回结果寄存器,溢出后的值直接回绕。取模运算的视角更本质:无符号整数加法实际上就是模 2^n 的加法定理,255 + 1在模256的意义下当然等于 0。

2.2 带符号整数:补码不是绕弯子,是高明的设计

带符号整数的存储比无符号复杂一些。早期计算机用过原码(最高位当符号位)、反码(负数按位取反),但最终产业界统一采用补码表示,原因很实际:补码让加减法不需要区分正负号,同一套加法电路通吃一切。

补码的核心规则:

正数:原码即补码,最高位为0 负数:其绝对值对应的无符号数按位取反,再加1

以int8_t为例,-1的补码这样求:

1的二进制 = 00000001 按位取反 = 11111110 再加1 = 11111111

所以-1在内存中的位模式就是11111111,跟uint8_t的255完全一样。同一串bit,你按无符号读是255,按带符号读是-1,这就是为什么C/C++里类型强转后数值会"变"——变的不是内存里的bit,而是你对这串bit的解释方式。

带符号整数能表达的范围不是对称的:

  • int8_t:-128 到 127(最小值不是-127,是因为补码天然多出一个负数位模式)
  • int16_t:-32768 到 32767
  • int32_t:-2147483648 到 2147483647
  • int64_t:-9223372036854775808 到 9223372036854775807

2.3 溢出瞬间的位模式变化,用边界表看得最清楚

很多新手看不懂溢出是 "值变了" 还是 "内存坏了"。实际上内存里的bit没有任何变化,变的只是运算结果在截断之后对应的数值。下面这张表把关键边界情况都列出来:

运算二进制过程(32位截断视角)无符号结果带符号结果
2147483647 + 10x7FFFFFFF + 1 = 0x800000002147483648-2147483648
4294967295 + 10xFFFFFFFF + 1 = 0x0000000000
-2147483648 - 10x80000000 - 1 = 0x7FFFFFFF21474836472147483647
unsigned(-1) >> 1右移1位高位补02147483647不适用

看到没有?加法器本身并不知道你把它当无符号用还是有符号用,它就在那老老实实地把bit相加。同一个0xFFFFFFFF + 1的结果0x00000000,无符号视角是"回绕到0",带符号视角是"-1 + 1 = 0",本质没有任何区别。

提示:所有溢出的本质都是"结果放不进固定的位宽",而不是"计算机算错了"。计算机在这个位宽内永远是正确的,错的是程序没有考虑结果会超出表示范围。

3. 溢出在不同语言里的行为差异,比你想的复杂得多

同样是MAX + 1,C/C++、Java、Python、PHP、JavaScript、Rust、Go的表态各不相同。搞混了这些行为,轻则跑出诡异的结果,重则被攻击者利用。

3.1 C/C++:带符号溢出是未定义行为,无符号溢出是定义良好的回绕

C/C++在这方面是两面派。无符号整数的溢出行为由标准明确定义——按模2^n回绕,编译器不允许假设它不会发生。但带符号整数溢出是未定义行为(Undefined Behavior),编译器可以作出任何它认为"合理"的假设。

这个区别非常要命。看这段代码:

int foo(int x) { if (x > 0 && x + 1 <= 0) { return 1; // 理论上溢出检查 } return 0; }

编译器看到x > 0 && x + 1 <= 0,基于"带符号溢出不发生"的假设,可以直接判定这个条件永远为假,把整个if块优化掉。你的溢出检查代码在-O2优化级别下可能被删得干干净净。这类编译器优化导致安全检查失效的案例,在C/C++安全史上一抓一大把。

所以C/C++的实操铁律是:任何可能接近边界的带符号运算,都要提前用不触发溢出的方式判断。比如判断a + b是否溢出,不要写a + b > c,而是写成:

if (a > INT_MAX - b) { // a + b 会溢出 }

3.2 Java:固定位宽,溢出静默回绕,不报错

Java的整数溢出是明确定义的——按补码回绕。int是32位固定长度,long是64位固定长度,加起来溢出后直接截断,不抛异常,不警告。

int max = Integer.MAX_VALUE; // 2147483647 System.out.println(max + 1); // 输出 -2147483648

这个行为很隐蔽,因为代码"正常"运行,没有崩溃,没有异常,但结果完全是错的。Java 8之后官方提供了Math.addExact、Math.subtractExact、Math.multiplyExact等系列方法,溢出时抛ArithmeticException,相当于给业务代码加了一道保险。

3.3 Python和JavaScript:动态转型的背后也有边界

Python的int是任意精度的,理论上不会溢出,这是Python做数值计算舒服的原因之一。但如果你用Python写C扩展、用struct模块打包二进制数据、或者用numpy的固定类型数组,溢出问题依然存在。numpy.uint8(255) + 1的结果依然是0,并不因为外层是Python就魔法消失。

JavaScript的情况更微妙。JS的Number是双精度浮点(IEEE 754),能精确表示的整数范围只有-2^53到2^53(即Number.MAX_SAFE_INTEGER)。超出这个范围,整数表示就开始丢精度:

console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992,看着正常 console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992,还是这个值!

也就是说,2^53 + 1和2^53 + 2在JS里是同一个数。好在ES2020引入了BigInt,明确追加n后缀即可使用任意精度整数。但很多遗留代码和第三方库依然用Number做ID和金额运算,这类bug在电商系统和分布式ID生成器里尤其常见。

3.4 PHP和弱类型:整数溢出会变成"字符串骗局"

PHP的弱类型比较(==而非===)让整数溢出问题有了花样百出的攻击姿势。PHP的整数类型在64位平台是64位有符号,溢出时不会崩溃,而是悄悄转成float,精度立刻下降。

最经典的案例是从前那个著名的"magic hash"问题。PHP的字符串比较==中,如果字符串看起来像一个科学计数法数字(比如"0e1234567890"),PHP会把两边都转成数值再比较。当哈希函数生成的哈希值恰好以0e开头时,它等于0 * 10^n = 0,就可以被直接匹配掉。这里虽然严格说不算加法溢出,但本质是"超出精度的数值被弱类型比较错误处理"的同类问题,它提醒我们:动态语言不是没有溢出问题,只是把整数溢出换了一种形式出现——精度丢失。

$passwordHash = "0e123456789012345678901234567890"; if ($passwordHash == hash("md5", $input)) { // 可能被绕过认证 }

正确做法是永远用===做哈希和关键字符串比较,并且对所有需要精确计算的数字用字符串存储、用bcmath或gmp扩展处理。

3.5 Rust和Go:把溢出检查变成工程实践

Rust在调试模式下默认开启溢出检查,溢出会导致panic;发布模式下默认关闭,按回绕处理,但提供了wrapping_add、saturating_add、checked_add、overflowing_add等显式API,逼你明确选择溢出时的语义。

Go的情况则比较反直觉:整数溢出不会panic,而是静默回绕。虽然低版本的Go编译器在某些情况下会插入检查,但并不是语言级保证。好在这种行为是确定且可预测的,配合math.MaxInt32等常量做边界判断,就可以把风险管住。

各语言溢出行为对照:

语言带符号溢出行为无符号溢出行为推荐的保护手段
C/C++未定义行为定义良好,回绕编译期开关+静态分析+人工审查
Java静默回绕不区分Math.addExact等
Python任意精度,不溢出(原生int)不溢出(原生int)numpy等多关注固定位宽场景
JavaScript/Node超2^53丢精度不区分BigInt
PHP转float丢精度不区分字符串存储,bcmath/gmp
Rust默认panic,可显式覆盖同左wrapping/checked族API
Go静默回绕静默回绕手动边界检查

4. 从"计算错误"到"安全漏洞":整数溢出的攻击路径拆解

单纯的数值算错已经够烦人了,但更危险的是,整数溢出往往是一连串更严重漏洞的起点。攻击者拿到溢出点,通常能链式组装出远程代码执行、认证绕过、金额篡改等真实威胁。下面把这几天最常见的攻击路径掰开揉碎讲清楚。

4.1 路径一:溢出撑爆内存分配,导致堆缓冲区溢出

回到开头我那个图片服务的事故。攻击者上传一张PNG,图片宽和高分别设置成接近sqrt(UINT_MAX)的值,也就是大约65535再稍微大一点。处理代码里计算分配大小时:

size_t buf_size = width * height * channels; // width和height来自文件头 unsigned char *buf = new unsigned char[buf_size];

当width = 65536, height = 65536, channels = 3时,理论上需要3 * 4GB = 12GB内存,但这个乘积在uint32_t里先溢出了:

65536 * 65536 = 4294967296 4294967296 % 4294967296 = 0 0 * 3 = 0

系统分配了0字节的buffer,或者分配了极小的一块。后续解码逻辑往buffer里写入像素数据,直接写穿堆内存。这种就属于"先溢出分配大小,再越界写入"的经典漏洞链,攻击者精心构造数据后可以逐步改写堆上的对象,最终控制程序执行流。

防御要点就一条:计算分配大小前先做边界校验,理论上需要的字节数必须小于某个合理上限,再做乘法。

if (width == 0 || height == 0 || channels == 0) { // 非法输入 } if (width > MAX_DIMENSION || height > MAX_DIMENSION) { // 尺寸超限 } if (width * height > MAX_PIXEL_COUNT) { // 像素总量超限 } size_t buf_size = static_cast<size_t>(width) * height * channels;

4.2 路径二:错误的数量计算击穿业务风控

这类漏洞不涉及内存破坏,但危害直接落在钱上。电商系统里计算总价、折扣、积分时,整数溢出可能导致少付、多拿甚至白嫖。

假设一个电商系统用32位整数存储商品单价和数量,订单总价用int计算:

long totalPrice = price * quantity; // 这里price是int,quantity是int

当price = 100000, quantity = 100000时,乘积应该是10000000000,这个值远超int的最大值2147483647,溢出后变成了-2147483648 + 若干,也就是一个负数或者一个很小的正数。如果后端只做了totalPrice > 0的校验,这个负数订单可以直接提交成功。用户的实付金额是负数,系统甚至要给他退款。

我从实际经验里总结出的铁律:所有涉及金额的乘法,先升级到64位再算,算完再做溢出检查。不要指望"价格和数量不会那么大"——攻击者最擅长的就是构造你没想到的边界值。

4.3 路径三:数组索引和长度校验被绕过

整数溢出在数组越界场景里也是一把钥匙。看这段C代码:

int read_from_buffer(uint32_t offset, uint32_t len, char *buf, uint32_t buf_size) { if (offset + len > buf_size) { return -1; // 禁止越界读取 } memcpy(out, buf + offset, len); return 0; }

当offset = 0xFFFFFFFF, len = 2时,offset + len在uint32_t里等于0x00000001,小于buf_size,校验通过。但实际memcpy拷贝2字节,读取地址却是buf + 0xFFFFFFFF,直接访问到几乎整个地址空间的末尾。这就是经典的"整数溢出绕过边界检查"模式。

修法是不要用加法校验,改用减法或除法这种不会溢出的比较形式:

if (offset > buf_size || len > buf_size - offset) { return -1; }

这个模式在文件解析、网络协议解析、加密库中反复出现,几乎每个人的代码审计清单里都该有一条:检索所有a + b > c这类的边界判断,看a和b是不是外部可控。

4.4 路径四:隐式转换让类型降级悄悄发生

还有一种防不胜防的情况——隐式类型转换把安全的大类型悄悄降级成小类型。最典型的例子发生在C/C++的混合运算和函数参数传递中:

size_t full_size = 0x1FFFFFFFF; // 超出32位范围的值 uint32_t chunk_size = full_size; // 隐式截断,变成0xFFFFFFFF

这种情况在结构体序列化、网络字节序转换、协议解析代码里太常见了。一个64位字段被塞进32位结构体槽位,高位直接被丢弃,攻击者精心构造的64位值就这样"绕过"了上层的大小限制。

另一个容易踩的坑是符号性转换。无符号数和有符号数比大小、混运算时,C/C++有一套隐式转换规则(通常无符号类型胜出),导致本应为负数的比较结果被解释成巨大的正数:

int a = -1; unsigned int b = 1; if (a < b) { // 在C/C++里,这个分支不会进入!因为a被转成无符号,变成4294967295 }

这类bug经常出现在循环条件、内存拷贝大小的判断上,是面试八股必考但实际写代码时最容易无意识踩进去的坑。建议工程上开启-Wsign-compare警告,且对所有外部输入做显式类型转换,把符号性写清楚。

4.5 真实世界的影响面:不是危言耸听

如果觉得上面的路径还不够恐怖,可以翻翻近十年的安全公告:不少知名漏洞(比如部分浏览器引擎、音频视频解码库、操作系统内核里的严重漏洞)根因都是整数溢出。它的特点就是"隐蔽、普遍、链式伤害大"。攻击者通常不需要直接利用溢出点本身,只需要把它作为第一步:溢出改变边界检查结构→越界读写→控制程序数据→执行任意代码。有些线上漏洞闭环了整个攻击链,影响面非常大。

正因为如此,整数溢出渗透在几乎所有处理二进制输入的系统里:图片解码器、音频视频播放器、网络协议栈、数据库、虚拟化层、区块链节点解析模块……可以说,只要代码在解析外部数据,就必须把整数溢出当成头号审查对象。

5. 工程里的防御矩阵:从编译选项到代码习惯的层层设防

前面把原理和危害都讲透了,这节给一套可以在团队里直接落地的防御方案。我自己的习惯是四层防御:编译器/运行时的兜底、静态工具的扫描、代码规范的红线、以及测试用例的边界覆盖。每一层都有局限性,但组合起来能把风险压到很低。

5.1 第一层:善用编译器和运行时工具

不同语言有自己的工具链。C/C++领域,GCC和Clang都提供了一系列与溢出相关的选项:

编译选项作用适用场景
-fwrapv让带符号整数溢出行为确定为回绕(类似无符号)编译旧代码,消除未定义行为随机性
-ftrapv带符号溢出时触发运行时陷阱(程序中止)快速暴露溢出的调试场景
-fsanitize=undefinedUBSan,编译期插入全面的UB检查,溢出会打印诊断信息测试阶段必开
-fsanitize=addressASan,检测堆/栈越界、Use-After-Free测试阶段必开
-Wsign-compare有符号/无符号比较时警告常规编译必开
-Wtype-limits检测明显无效的范围比较常规编译必开

实际使用中,我会在CI的测试构建里固定开启-fsanitize=undefined,address配合-fno-sanitize-recover,让任何一次UB都直接让测试失败,而不是让它悄悄溜过去。这样能在问题进入生产之前就拦下一大批。

Java侧用Math.addExact系列替代直接运算即可。Rust开发者尽量用checked_add组合成自己的算术工具库。Go的math/bits包里也有Add64、Mul64这类返回溢出标志的函数,可以封装一层。

5.2 第二层:静态分析工具扫全场

编译器的sanitizer需要真实输入触发。静态分析工具则可以直接对代码做全量扫描。这一层建议至少跑两套:

  • C/C++:Clang-Tidy的bugprone-integer-overflow检查、Cppcheck
  • Java:SpotBugs的INT_*系列规则(专门查整数溢出)
  • Python:Bandit对numpy等固定位宽操作的提醒
  • 通用:CodeQL 可以自定义查询所有a + b > c模式,非常值得配置一条规则

CodeQL查询整数溢出边界检查的伪规则很简单,就是搜索所有左侧表达式包含加法的比较条件。这类规则一旦配上,团队里的新人再写出不安全的边界判断,代码评审阶段就会被拦下来。

5.3 第三层:代码规范里的红线清单

工具只能辅助,真正决定代码质量的是人的习惯。我在团队里推过一份《数值计算安全红线》,核心就这么几条:

  1. 所有外部输入的数字,进入计算前必须有类型和范围的白名单校验。别相信调用方传进来的参数是"合理的"。

  2. 边界判断禁止用加法,改用减法。offset + len > size是禁止写法,必须写成offset > size || len > size - offset。

  3. 涉及乘法,先验证因子范围,再用大类型承载结果。32位平台上两个int相乘,至少要有一个操作数先转成long long或int64_t再乘。

  4. 涉及金额、数量、ID的计算,一律禁止用浮点。浮点精度问题会导致类似溢出但形式更隐蔽的资金损失。

  5. 任何网络协议、文件格式的解析器,字段长度使用前必须做"字段值是否在合理范围内"的检查。不要直接信任header里的长度。

  6. C/C++代码全部开启-Wsign-compare和-Wconversion,所有隐式转换的warning都当成error处理。

5.4 第四层:边界测试疯狂补位

最后也是很多团队最不重视的一层——测试覆盖。针对整数溢出,我建议每个包/模块在单测里固定加入这样一组边界测试用例:

  • MAX + 1(最大值加一)
  • MIN - 1(最小值减一)
  • MAX * 2(最大值翻倍)
  • 两个大数的和/积接近边界的场景
  • 0和-1的特殊组合
  • 无符号最大值0xFFFFFFFF和1相加
  • 有符号最小值-128/-2147483648参与运算

用这些用例做参数化测试,一旦某个函数或模块对这些输入出现错误结果或崩溃,基本可以断定有溢出隐患。测试里最值得自动化的是"输入范围枚举":把合法范围的边缘、非法范围的边缘、刚好跨过类型边界的值全部塞进去跑一遍。

我在这块还养成了一个小习惯:凡是解析外部输入的库,测试时一定额外跑一轮fuzzing。AFL、libFuzzer、Jazzer这些工具能自动生成随机的畸形输入,把靠人脑不容易想到的边界组合全部塞给程序。图片处理服务当年如果跑了fuzz,那个PNG导致的崩溃早就被揪出来了。

5.5 历史代码的专项整改方法

存量代码不可能一夜之间全部改完,按优先级排队处理会更现实:

  1. 第一优先级:解析外部输入(文件头、网络报文、用户请求参数)的代码,全部过一遍,标记所有算术运算。
  2. 第二优先级:内存分配大小、数组下标、循环长度相关的计算,逐个确认是否可能溢出。
  3. 第三优先级:金额、计数器、排名这类业务数值,升级存储类型或引入饱和运算语义。
  4. 第四优先级:日志打印、统计信息等低危场景,溢出影响小,可以后置。

按这个顺序推进,小团队两周内就能把最危险的溢出点全部覆盖完。

6. 写在最后的几条实在话

整数溢出这个题目,说实话我从大学期末考试第一次接触,到后来在工作中靠它排查过一个又一个诡异线上故障,隔几年认识就更深一层。现在回头想,最值得分享的不是某种具体技巧,而是对待数值运算的敬畏感。

一是在任何系统里写代码,先想边界,再写逻辑。最常见的bug不是算法错了,而是"值刚好比预期大了一点"。多问一句"这个数最大值能到多少",能避免无数个深夜。

二是不要迷信某一种语言"安全"。任何依赖固定位宽的数字运算都有它的边界,动态语言转了一圈之后也会有精度问题,静态语言则有编译器优化带来的隐式风险。理解背后的二进制语义,比背某条具体规则可靠得多。

三是把防御做进流水线。从我过去几年的经验看,编译器sanitizer、静态扫描、边界测试、fuzz这四件套组合起来,能拦住九成以上的整数溢出问题,剩下的才是需要人工脑力仔细推敲的极端场景。

最后分享一个实用小技巧:在代码评审里,只要看到有人在比较两个数字的大小时用了加法表达式,比如a + b > c,就可以要求他改成减法或乘法校验。这个习惯一旦养成,团队代码里整数溢出相关的bug会肉眼可见地减少。整数溢出的知识不难,难的是让它成为一种写代码的本能反应。久而久之你会形成一种直觉——看到数字计算,眼睛会自动瞄向它的类型边界,这才是这篇文章最想帮你养成的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询