多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

整数溢出:从二进制真相到工程防御,彻底消除隐蔽数值炸弹

整数溢出:从二进制真相到工程防御,彻底消除隐蔽数值炸弹 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_t0 到 255uint16_t0 到 65535uint32_t0 到 4294967295uint64_t0 到 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 到 32767int32_t-2147483648 到 2147483647int64_t-9223372036854775808 到 92233720368547758072.3 溢出瞬间的位模式变化用边界表看得最清楚很多新手看不懂溢出是 值变了 还是 内存坏了。实际上内存里的bit没有任何变化变的只是运算结果在截断之后对应的数值。下面这张表把关键边界情况都列出来运算二进制过程32位截断视角无符号结果带符号结果2147483647 10x7FFFFFFF 1 0x800000002147483648-21474836484294967295 10xFFFFFFFF 1 0x0000000000-2147483648 - 10x80000000 - 1 0x7FFFFFFF21474836472147483647unsigned(-1) 1右移1位高位补02147483647不适用看到没有加法器本身并不知道你把它当无符号用还是有符号用它就在那老老实实地把bit相加。同一个0xFFFFFFFF 1的结果0x00000000无符号视角是回绕到0带符号视角是-1 1 0本质没有任何区别。提示所有溢出的本质都是结果放不进固定的位宽而不是计算机算错了。计算机在这个位宽内永远是正确的错的是程序没有考虑结果会超出表示范围。3. 溢出在不同语言里的行为差异比你想的复杂得多同样是MAX 1C/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的字符串比较中如果字符串看起来像一个科学计数法数字比如0e1234567890PHP会把两边都转成数值再比较。当哈希函数生成的哈希值恰好以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不溢出原生intnumpy等多关注固定位宽场景JavaScript/Node超2^53丢精度不区分BigIntPHP转float丢精度不区分字符串存储bcmath/gmpRust默认panic可显式覆盖同左wrapping/checked族APIGo静默回绕静默回绕手动边界检查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_castsize_t(width) * height * channels;4.2 路径二错误的数量计算击穿业务风控这类漏洞不涉及内存破坏但危害直接落在钱上。电商系统里计算总价、折扣、积分时整数溢出可能导致少付、多拿甚至白嫖。假设一个电商系统用32位整数存储商品单价和数量订单总价用int计算long totalPrice price * quantity; // 这里price是intquantity是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带符号溢出时触发运行时陷阱程序中止快速暴露溢出的调试场景-fsanitizeundefinedUBSan编译期插入全面的UB检查溢出会打印诊断信息测试阶段必开-fsanitizeaddressASan检测堆/栈越界、Use-After-Free测试阶段必开-Wsign-compare有符号/无符号比较时警告常规编译必开-Wtype-limits检测明显无效的范围比较常规编译必开实际使用中我会在CI的测试构建里固定开启-fsanitizeundefined,address配合-fno-sanitize-recover让任何一次UB都直接让测试失败而不是让它悄悄溜过去。这样能在问题进入生产之前就拦下一大批。Java侧用Math.addExact系列替代直接运算即可。Rust开发者尽量用checked_add组合成自己的算术工具库。Go的math/bits包里也有Add64、Mul64这类返回溢出标志的函数可以封装一层。5.2 第二层静态分析工具扫全场编译器的sanitizer需要真实输入触发。静态分析工具则可以直接对代码做全量扫描。这一层建议至少跑两套C/CClang-Tidy的bugprone-integer-overflow检查、CppcheckJavaSpotBugs的INT_*系列规则专门查整数溢出PythonBandit对numpy等固定位宽操作的提醒通用CodeQL 可以自定义查询所有a b c模式非常值得配置一条规则CodeQL查询整数溢出边界检查的伪规则很简单就是搜索所有左侧表达式包含加法的比较条件。这类规则一旦配上团队里的新人再写出不安全的边界判断代码评审阶段就会被拦下来。5.3 第三层代码规范里的红线清单工具只能辅助真正决定代码质量的是人的习惯。我在团队里推过一份《数值计算安全红线》核心就这么几条所有外部输入的数字进入计算前必须有类型和范围的白名单校验。别相信调用方传进来的参数是合理的。边界判断禁止用加法改用减法。offset len size是禁止写法必须写成offset size || len size - offset。涉及乘法先验证因子范围再用大类型承载结果。32位平台上两个int相乘至少要有一个操作数先转成long long或int64_t再乘。涉及金额、数量、ID的计算一律禁止用浮点。浮点精度问题会导致类似溢出但形式更隐蔽的资金损失。任何网络协议、文件格式的解析器字段长度使用前必须做字段值是否在合理范围内的检查。不要直接信任header里的长度。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 历史代码的专项整改方法存量代码不可能一夜之间全部改完按优先级排队处理会更现实第一优先级解析外部输入文件头、网络报文、用户请求参数的代码全部过一遍标记所有算术运算。第二优先级内存分配大小、数组下标、循环长度相关的计算逐个确认是否可能溢出。第三优先级金额、计数器、排名这类业务数值升级存储类型或引入饱和运算语义。第四优先级日志打印、统计信息等低危场景溢出影响小可以后置。按这个顺序推进小团队两周内就能把最危险的溢出点全部覆盖完。6. 写在最后的几条实在话整数溢出这个题目说实话我从大学期末考试第一次接触到后来在工作中靠它排查过一个又一个诡异线上故障隔几年认识就更深一层。现在回头想最值得分享的不是某种具体技巧而是对待数值运算的敬畏感。一是在任何系统里写代码先想边界再写逻辑。最常见的bug不是算法错了而是值刚好比预期大了一点。多问一句这个数最大值能到多少能避免无数个深夜。二是不要迷信某一种语言安全。任何依赖固定位宽的数字运算都有它的边界动态语言转了一圈之后也会有精度问题静态语言则有编译器优化带来的隐式风险。理解背后的二进制语义比背某条具体规则可靠得多。三是把防御做进流水线。从我过去几年的经验看编译器sanitizer、静态扫描、边界测试、fuzz这四件套组合起来能拦住九成以上的整数溢出问题剩下的才是需要人工脑力仔细推敲的极端场景。最后分享一个实用小技巧在代码评审里只要看到有人在比较两个数字的大小时用了加法表达式比如a b c就可以要求他改成减法或乘法校验。这个习惯一旦养成团队代码里整数溢出相关的bug会肉眼可见地减少。整数溢出的知识不难难的是让它成为一种写代码的本能反应。久而久之你会形成一种直觉——看到数字计算眼睛会自动瞄向它的类型边界这才是这篇文章最想帮你养成的东西。
返回列表