资讯动态

整数溢出全解析:从回绕原理到防御实践

发布时间:2026/10/14 7:26:36 来源:尧图企业网站定制
整数溢出这个话题我在排查问题的时候碰到过不止一次。印象最深的一次是某个统计系统前端同学报过来一个“订单金额变成负数”的bug查了半天数据库、缓存、接口最后定位到一行求和代码一个累加器用的还是32位有符号整数数据量一上来直接溢出回绕成了负数。那个瞬间我意识到越基础的东西越容易在关键时刻给你上一课。这篇就专门聊聊整数溢出它到底是怎么发生的为什么所有语言都躲不开或者说有人能躲开以及真遇到的时候怎么排查、怎么防御。写这篇文章的目标读者是正在写业务代码的开发者、写底层和协议栈的工程师还有对软件安全感兴趣的同学。不扯高深理论尽量用能直接落地的例子说话。1. 整数溢出到底是什么1.1 从“计数器的回绕”说起想理解整数溢出可以先想一个最简单的场景老式汽车上的机械里程表数字都是滚轮跑到99999的时候再往前开一公里它会变成00000。这个“跳回零”的动作就是溢出。计算机里的整数运算也是一样的道理只不过它是二进制的而且受变量类型的内存宽度限制。一个32位无符号整数能表示的范围是0到4294967295。当它已经是4294967295再加1结果不是4294967296而是0。这个现象在计算机术语里叫“回绕”wraparound。如果是32位有符号整数范围是-2147483648到2147483647最大值加1会直接跳到最小值-2147483648。这个跳跃更隐蔽因为结果看起来是个“合法”的负数不容易被察觉。很多新手第一次遇到整数溢出都会觉得是自己逻辑错了其实不是。CPU本身没有“错了”它只是按照二进制加法的规则执行。问题出在我们的算法和类型设计没有考虑数据范围会不会超出类型的边界。1.2 有符号数和无符号数的底层表示要深入理解溢出必须聊聊补码。计算机里表示有符号整数普遍用补码形式。补码的好处是加法和减法可以用同一套电路实现不用区分符号位单独处理。举一个直观的例子假设用4位二进制表示整数无符号数范围是0~15二进制从0000到1111。有符号数范围是-8~7其中0000到0111表示0到71000表示-81111表示-1。注意-1的补码是1111不是1001那些“符号位加原码”的直觉表示。正因为补码的存在-1加1会变成0000结果是0这很自然。但7加1呢0111加0001等于1000在有符号解释下结果是-8。这就是有符号整数溢出回绕的典型表现。理解了补码你就能理解为什么很多安全文档里说“有符号溢出是未定义行为无符号溢出是回绕”。C语言标准里有符号整数溢出属于未定义行为undefined behavior编译器可以优化成任何它认为合理的结果。而无符号整数溢出则明确是模运算回绕。这个区别在优化场景下非常关键有些编译器会把不会溢出的假设用于优化如果程序实际溢出了可能产生非常反直觉的行为。1.3 整数溢出的常见形态上溢、下溢与截断整数溢出不只有“加爆了”这一种。实际工程中我总结下来有三种常见形态第一是上溢。加法、乘法把数值推到类型上限之上结果回绕成一个小值或负值。典型场景是累加统计、计数器、数组索引计算。第二是下溢。减法或除法把数值推到类型下限之下结果回绕成一个很大的正数或正负数交错。典型场景是计算剩余容量、倒计时、余额扣减。比如一个无符号整数从0减去1会变成4294967295看起来像某个巨大数字。第三是截断。把一个较宽类型的值赋给较窄类型只保留低位高位被丢掉。典型场景是64位返回值被强转成32位、把整型截断成短整型、解析协议字段时用了错误的宽度。截断造成的溢出往往最隐蔽因为赋值语句表面看起来人畜无害。我见过一个客户端与服务器通信的bug服务端返回一个时间戳类型是64位整型客户端代码为了兼容老协议把它赋值给一个32位字段。服务器时间戳稍微大一点客户端拿到就变成负数导致所有排序逻辑错乱。这就是截断类问题排查起来非常考验经验。2. 为什么必须认真对待整数溢出2.1 业务逻辑层面的失控很多人觉得整数溢出是教科书概念现实中很少遇到。这个想法非常危险。只要程序里出现数值累加、长度计算、余额操作、索引换算就可能中招。举例一个很常见的业务场景某个积分系统里用户签到积分累加积分字段用的是32位有符号整数。当用户每天签到积分涨到2147483647之后下一次累加积分数值变成负数。用户不仅没有获得积分反而一夜回到解放前积分变成负两千多万。客户投诉、财务对账出错、运营数据混乱全都来了。还有一个常见场景限流计数。某接口需要统计每分钟的调用次数用了一个32位无符号计数器上限4294967295。正常情况下不可能达到但如果某个自动化流程出现了死循环计数器绕回0限流逻辑立刻失效紧接着就是资源耗尽。这种问题看起来不像“溢出”更像“计数归零”极难从日志里定位。2.2 安全层面的利用方式在安全领域整数溢出是很多漏洞的源头。它本身不一定导致严重问题但它可以成为绕过检查的跳板。经典的利用方式是“整数溢出绕过长度检查”。比如某个函数接收用户传入的长度字段代码写的是先判断长度是否小于某个最大值然后再根据这个长度去分配内存或复制数据。如果长度字段在加法或乘法中溢出检查时是一个小值实际使用时被回绕成一个巨大值然后引发缓冲区溢出。攻击者虽然不能直接利用整数溢出本身但可以利用它击穿后续的内存安全防线。这里需要特别说明整数溢出在现代语言里越来越难被直接利用比如Java在数组越界时会抛出异常Python直接不支持固定宽度整数但这不代表问题消失。在C/C开发的系统里、在协议解析里、在嵌入式设备里、在数据库内核里整数溢出的威胁依然存在。CWE-190“整数溢出或回绕”常年排在安全弱点榜单前列这不是危言耸听。还有一个很多人忽略的方向使用AI编程辅助生成代码时自动生成的类型处理往往不够严谨。AI模型容易在“看起来合理”的地方随手用一个int然后写一个依赖范围假设的逻辑。这种代码如果没有经过严格的代码评审和测试很容易埋下整数溢出的隐患。2.3 受影响范围从嵌入式到云端整数溢出的影响范围比很多人想象的要广。嵌入式领域传感器数据、计时器、校准参数动不动就是无符号16位或32位运算。设备长时间运行计数器绕回可能造成设备状态显示错误、控制逻辑误触发。物联网领域设备固件升级时需要对固件包大小做校验如果固件大小字段是个32位整数攻击者构造一个超大文件让长度计算溢出就能绕过完整性检查。云端服务领域分布式系统中到处是计数器、配额、分页偏移量。分页查询的场景很典型offset从0开始每页取50条页数特别多之后offset用32位存就要溢出。很多接口的这个bug会在量级上来之后集中爆发。底层安全领域各种解析器处理的是用户可控的二进制数据长度、索引、偏移量无处不在任何一个整数溢出都可能演变成内存破坏。3. 典型场景与实战复现3.1 C/C 中最典型的溢出现场C语言里整数溢出之所以危险是因为它贴近硬件且标准给了很多“自由度”。下面这段代码非常典型很多早期的图像处理、文件解析代码里都出现过。#include stdio.h #include string.h #include stdlib.h void process_data(unsigned int len) { unsigned int total len 8; char *buf malloc(total); if (buf NULL) { return; } // 假设后续会从外部读取len字节到buf8的位置 printf(allocated %u bytes\n, total); free(buf); } int main() { process_data(0xFFFFFFFFu); return 0; }这个例子里如果len传的是4294967295那么len 8会回绕成7。malloc(7)分配了一个很小的缓冲区但后续如果按len的语义复制数据就会发生缓冲区溢出。这段代码看起来很简单但真实项目里到处都是类似的套路先计算“总长度 头部长度 数据长度”然后分配内存复制数据。这里的整数溢出就是典型的“长度计算绕过”。C场景里还常见另一个坑容器大小和索引。vector的size()返回的是size_t在64位系统上是64位无符号整数。但如果你在计算索引时用了32位中间值把size_t强转成int就可能在高容量场景下出现截断问题。#include vector #include cstdint #include iostream int main() { std::vectoruint8_t data; data.resize(3000000000u); // 约30亿字节 int index data.size() * 2; // size是size_t乘以2可能超出int范围 std::cout index: index std::endl; return 0; }这段代码在64位机器上data.size() * 2的结果是6000000000赋值给int时会发生截断结果可能是负数。如果你的代码后面用这个index去索引data就会出问题。当然真实场景不会这么直白但模式是一样的类型宽度不匹配大值被截断。3.2 Java与整数溢出的隐蔽陷阱很多Java开发者觉得Java是“安全语言”数组越界会被检查整数溢出似乎没那么重要。但Java的int和long溢出依然是静默回绕不会抛异常。下面这个例子是网上流传很广的陷阱public class OverflowDemo { public static void main(String[] args) { int a Integer.MAX_VALUE; int b 1; int c a b; System.out.println(c); // -2147483648 } }这个代码打印结果是负数而且不会报任何错误。在真实业务中这种问题通常出现在金额计算、评分累加、线程池参数、数据批次大小等地方。还有一个更隐蔽的例子Math.abs(Integer.MIN_VALUE) 返回的依然是Integer.MIN_VALUE因为绝对值超出int范围发生回绕。这个坑我之前在一个排序逻辑里踩过想取某个差异值的绝对值结果取出来一个负数直接导致排序方向反转。Java中避免溢出的一个技巧是使用Math.addExact、Math.multiplyExact这类方法它们在溢出时会抛出ArithmeticException。如果只是想知道结果有没有溢出可以用Math.addUnsigned或BigInteger。经验是凡是从外部传入的数值参与运算或者数值可能经过多次累加都应该考虑使用Exact系列方法或者用long替代int。3.3 为什么Python不用太担心以及什么时候仍需担心Python的int是任意精度整数理论上不会溢出。这让很多从Python入门编程的人对整数溢出非常陌生。我第一次带新人的时候经常需要解释“为什么C里int加int会出负数”对方的第一反应都是“这不可能吧”。Python确实避开了经典整数溢出但它依然有几个边界场景要留意第一个是与其他语言交互。Python通过socket接收二进制数据时如果使用struct.unpack解析可以指定格式解析成32位无符号或有符号整数。如果你在Python里直接对这些解析后的值做运算Python本身不溢出但一旦你把结果传给C扩展或者pack成二进制数据就必须确保数值落在目标类型范围内。第二个是隐式转换到C类型。使用numpy时numpy数组的元素类型是固定的。如果你创建了一个int32数组然后给某个元素赋值一个超过2147483647的值numpy不会自动扩展类型而是会把数值截断或回绕。这是一个非常常见的坑尤其是在数据处理流水线里。import numpy as np arr np.array([1, 2, 3], dtypenp.int32) arr[0] 2147483648 print(arr[0]) # -2147483648发生了回绕第三个是外部函数接口。使用ctypes或CFFI调用C库时传入的参数会被转换为C类型。如果你传的值超出范围可能被截断。这种情况虽然少见但一旦发生排查起来非常痛苦。Python的灵活让很多人忘了背后还有一个固定宽度的世界。在做跨语言系统时一定要在边界处做显式范围校验不能依赖Python的自动扩展去“兜底”。3.4 智能合约中的溢出也不能忽视如果提整数溢出智能合约肯定绕不开。以太坊上早期著名的合约漏洞事件本质就是下溢/溢出问题某个无符号整数在做减法时小于0回绕成了巨大数值导致余额异常放大。虽然这里不方便展开讲具体历史事件但这个教训值得每一个写合约的人记住。Solidity 0.8.0之前默认情况下整数溢出不会报错开发者需要依赖SafeMath库做检查。0.8.0之后默认启用了溢出检查但它只对普通算术运算有效对位运算、类型转换、负数取绝对值等场景仍需要谨慎。假设有一段类似下面的逻辑function withdraw(uint256 amount) public { balances[msg.sender] - amount; payable(msg.sender).transfer(amount); }如果balances[msg.sender]小于amount减法的结果会变成巨大的数字用户的余额不降反升。解决办法就是先校验余额是否大于等于提款金额或者在算术上使用checked模式。Solc 0.8.0之后的版本默认checked但编写者还是要留意 unchecked 块里的运算。4. 排查技巧与防御实践4.1 先聊聊如何快速定位可疑运算排查整数溢出第一步不是上工具而是用“怀疑一切”的态度看代码。我自己的排查顺序是这样的先看数据来源。用户输入、外部接口字段、配置文件、数据库字段这些外部数据进入系统时有没有经过范围校验如果进来就是一个int后面又参与加法或乘法就要警惕。再看类型选择。所有参与算术运算的变量它们的类型宽度够不够如果是32位int最大只能到21亿。如果业务数据可能超过这个数就要换64位或者使用大整数类型。再看算术运算的位置。累加器、计数器、长度计算、索引计算、金额计算是溢出的高发区。尤其是循环里做累加的地方循环次数不可控时累加结果很容易超过预期。最后看类型转换。有没有从宽类型赋值给窄类型有没有把无符号数转换成有符号数有没有把大整数转换成小整数所有强转都值得多看几眼。这个排查顺序本质上是“从边界到内部”先用数据流视角扫描一遍再聚焦到具体的算术表达式。4.2 防御手段输入校验、类型提升、安全函数防御整数溢出业界其实已经有成熟套路核心是四层防线。第一层是输入校验也是最便宜有效的防线。外部输入进入系统时就校验它是否在合法范围内。比如长度字段不能超过一个合理的上限金额字段不能为负计数器的增量不能大于设定阈值。很多时候只要入口校验做扎实后面的溢出问题根本不会发生。第二层是类型提升。在算术运算前主动把变量提升到更宽的类型。比如两个int做乘法先转成long再做乘法结果再检查是否超出int范围。类型提升不能根除溢出但它能扩大“安全区”把高风险运算的边界往后推。第三层是使用安全函数或安全库。语言标准库里提供了很多现成的检查方法Java有Math.addExact系列C20有std::add_overflow系列Go有math.MaxInt和显式检查习惯Rust在debug模式下默认检查溢出。用这些安全函数至少可以减少低级错误。第四层是约定与评审。团队内部约定敏感运算必须写注释说明范围假设code review时把“有没有溢出风险”作为一个固定检查点。这层防御最有弹性也最容易被忽略。这里还要提一句编译器优化可能让原本“看起来会回绕”的代码变成完全不同的行为。C/C的有符号溢出未定义行为编译器可能会针对它做积极优化。不要依赖“回绕”去做判断有符号运算要么保证不溢出要么用显式的检查逻辑。4.3 动态检测与静态分析工具怎么选工具方面动手排查时可以配合使用。动态检测工具里最常见的是ASan和UBSan。ASanAddressSanitizer主要用于检测内存错误但它也能在内存访问异常时暴露出一些由整数溢出引发的问题。UBSanUndefinedBehaviorSanitizer是专门检测未定义行为的工具它能捕获有符号整数溢出、数组越界、除零等。使用方式很简单在编译时加上-fsanitizeundefined即可。gcc -fsanitizeundefined -g -o demo demo.c ./demo如果程序里存在有符号整数溢出运行时会在溢出发生的那个位置打印一条诊断信息。这个工具在测试阶段非常有用强烈建议在CI流程里加入带UBSan的测试任务。静态分析工具方面C/C可以用Coverity、Clang Static AnalyzerJava生态里可以用SpotBugsPython里可以用Bandit。Web应用可以用一些依赖扫描工具辅助。不过静态分析工具误报率不低关键还是人工理解业务逻辑里数值范围的真实约束。4.4 代码评审中的检查清单代码评审是一个很好用的防线但需要一个清单不要全靠经验。我一般会检查以下内容外部输入有没有范围上限有没有在被校验之前就参与运算减法运算中减数是否恒大于被减数被减数是否有下限乘法运算中因子是否可能让结果超过类型上限循环累加的变量循环次数上限和每次增量相乘是否可能溢出所有强转操作源类型和目标类型的取值范围是否匹配有没有用无符号整数语义做有符号运算的地方注释里是否写明了变量取值范围和溢出假设如果没写明就要求补上。这个清单不一定完整但至少可以让代码评审时不至于漫无目的。带上清单去评审质量和效率都会好很多。5. 常见问题速查与避坑心得5.1 排查场景速查表我在排查过程中整理过一个速查表遇到类似问题可以直接对照排查方向。现象可能原因优先排查点求和结果变成负数有符号整数上溢回绕累加变量类型、循环次数、累加值范围余额扣减后反而增加无符号整数下溢回绕减法前是否有余额校验、是否使用有符号类型分配的内存小于预期长度计算中发生上溢头部长度数据长度的加法、乘法是否溢出数组下标变成负数64位转32位截断类型转换处、中间变量类型绝对值计算异常Math.abs对MIN_VALUE回绕是否对Integer.MIN_VALUE取绝对值计数器清零导致限流失效无符号整数回绕计数器类型、处理频率、日志里是否出现归零分页偏移量错乱偏移量溢出分页参数类型、累加方式、数据库offset上限表格里的场景不算全面但基本都是我在实际项目里碰到过、或者在同事的排查案例里见过的。你工作中遇到类似现象时可以先用这张表做个粗筛。5.2 我在实操中积累的几个习惯最后分享几个我这些年养成的习惯。算不上什么高深技巧但很实用。第一个习惯是“默认用long不够再上BigInteger”。在写Java时除非确定数据量很小否则我不会轻易用int做业务字段。減少一次类型思考就可能少一个溢出bug。虽然long也会溢出但它的上限是9.22×10^18日常业务几乎碰不到。同理C里如果只是临时计算优先用int64_t而不是int。第二个习惯是“减法前先比大小”。所有涉及减法的逻辑我都会先想一遍被减数会不会小于减数如果会业务语义是什么是无符号回绕还是应该报错代码里写成显式if判断比依赖语言行为要安全得多。第三个习惯是“所有强转都要写注释”。任何从宽类型转窄类型的地方我都会写清楚“这里为什么不会溢出”。如果写不出注释说明我还没想明白这种情况下就不会把代码提交出去。这个习惯帮我挡了很多潜在问题。第四个习惯是“给自己留一个测试水位”。在设计累加类功能时我会在测试环境模拟到数据量上限附近用边界值测试绕回行为。2147483647、4294967295、-2147483648、0这些边界值我都会跑一轮。不要觉得这个很麻烦真等到生产环境出现问题再做成本高十倍都不止。5.3 警惕AI辅助编码带来的“新型溢出风险”还有一点想单独提一下。现在很多人会用AI辅助写代码AI生成代码的能力越来越强但它对变量类型的把握并不总是可靠。我见过不少AI生成的代码为了图省事把所有整数都声明为int或Integer然后直接做累加或长度计算。这恰恰是整数溢出的高风险写法。所以我的建议是AI辅助生成的代码经过人手审查时要格外注意数值范围。不要因为代码“看起来逻辑完整”就放过。变量声明当下是安全的不代表数据量上来之后依然安全。尤其是涉及外部输入、循环累加、长度计算这些位置的代码必须做一次独立的范围推演。这种推演不用很复杂只需要问三个问题这个值最大能到多少参与运算后最大能到多少当前的类型装得下吗如果每次审查都过这三问至少能过滤掉一大部分隐患。6. 最后的落地建议写这篇文章的过程中我一直在想整数溢出为什么这么“简单”却又这么容易被忽略。答案可能在于它不像空指针那样一碰就崩也不像死循环那样肉眼可见。它的结果往往是“看起来正常但数值不对”在日志里甚至很难留下痕迹。所以我想说的最后一点是不要等出了问题再去关注它。结合日常开发把整数溢出检查变成编码习惯的一部分。写加法时扫一眼类型写减法时确认一下下限写强转时问自己一句“会不会截断”写循环累加时想一下上限。这些动作每次只花几秒钟但省下来的排查时间可能是几小时甚至几天。如果在看这篇文章时你手头正好有一段涉及数值计算的代码不妨现在就打开它按上面的思路过一遍。也许你会发现某行你写过很多次的代码正在等待一个边界值来触发它的问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑