news 2026/10/15 11:09:00

整数溢出攻防指南:从负数兑换到远程代码执行

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
整数溢出攻防指南:从负数兑换到远程代码执行

有一次我审一个积分兑换系统的代码,业务方反馈说有人用负数积分换走了商品,系统结算时还倒贴了钱。查到最后,根因出在三行C代码上:一个int做减法时溢出了——这就是整数溢出。它大概是程序里最会“装无辜”的一类漏洞,表面看只是算错了数,实际上能一路演变成转账、提权、远程代码执行。这篇文章想用“好好说话”的方式,把整数溢出的原理、危害、复现和防御一次讲透,适合刚开始接触安全的开发者、平时处理金额和长度的业务程序员,以及准备入门二进制安全的朋友。

1. 一次“不该成功”的负数兑换:先从二进制讲起

1.1 二进制的尺子:为什么 int 只能量到 2147483647

先解决一个基础问题:计算机里的整数为什么有上限?因为每个变量在内存里占的位数是固定的,比如int在主流平台上占 32 位,也就是 32 个二进制位。每一位只有 0 或 1 两种状态,32 个位能表示的状态总数是 2 的 32 次方,也就是 4,294,967,296 种。

如果这个数是无符号整数(unsigned int),那 4,294,967,296 种状态全部用来表示非负数,范围是 0 到 4,294,967,295。如果是有符号整数(int),需要拿一半状态来表示负数,于是范围变成 -2,147,483,648 到 2,147,483,647。这就是很多人背过的“int 最大是 21 亿”的由来。

这就像一把尺子,刻度是固定刻好的,你量一个比尺子还长的东西,尺子不会自己变长,只会把多出来的部分弄丢。我常跟同事打比方:整数溢出本质上就是“水桶装不下”。一个五升的水桶,你非要倒六升水进去,溢出来的那一升就是计算机丢掉的数据。区别在于现实中的水溢出来你看得见,而计算机里丢掉的是高位,而且它通常不会报错,只会静悄悄地继续执行。

1.2 回绕、截断与未定义行为:溢出的三种“演出方式”

整数溢出在不同的语言、不同的类型组合下,表现完全不一样,最核心的三种形态是回绕、截断和未定义行为。

回绕(wraparound)是最好理解的一种。无符号整数运算结果超出范围时,会对 2 的 N 次方取模。比如unsigned char是 8 位,范围 0 到 255,那么255 + 1的结果就是 0。这种感觉很像汽车里程表:跑到 99999 公里再走 1 公里,显示变成 00000,它不会停下来告诉你“满了”。

截断(truncation)发生在把一个大类型塞进一个小类型的时候。比如把一个uint32_t类型的 300 赋给uint8_t,300 的二进制是0000000100101100,而uint8_t只有低 8 位,结果变成00101100,也就是 44。高位被直接砍掉了,连个招呼都不打。

最麻烦的是第三种,未定义行为(UB,Undefined Behavior)。在 C 和 C++ 里,有符号整数溢出属于 UB,意味着语言标准说“我不保证会发生什么”。编译器可以认为 UB 永远不会出现,然后基于这个假设做激进优化。这一条是无数安全漏洞的温床,也是下面要重点展开的内容。

2. 溢出为什么是漏洞:三条最常见的利用链路

2.1 链条一:越界写,溢出只是内存破坏的前传

整数溢出单独存在通常不致命,但它往往是一连串漏洞的“前传”。最经典的模式是这样的:程序计算一块内存的总大小时,先做加法或乘法,结果溢出变小了,于是分配了一块不够用的缓冲区,后续往里拷数据时直接把内存写穿。

举个例子,一个网络协议解析函数收到一段数据,报文里有个字段叫payload_len,表示负载长度。代码在计算总长度时写成了total_len = payload_len + HEADER_SIZE。如果payload_len是 0xFFFFFFFF,HEADER_SIZE是 8,那么相加后回绕成一个很小的数,比如 7。程序按 7 个字节去分配内存,但后面的memcpy却按payload_len这个很大的值去拷贝,堆缓冲区直接越界写。

这类问题在真实漏洞里非常多见。从攻击者的视角看,我需要做的就是传一个精心构造的长字段,让长度计算出错;从防御者的视角看,审查代码时必须把所有“外部输入值参与算术运算”的地方标红。不要觉得“传 0xFFFFFFFF 太夸张了”,攻击者构造数据时什么值都能给你传进来。

2.2 链条二:金额与权限校验,逻辑错误才是重灾区

第二种利用链路不碰内存,直接打业务逻辑。回到开头的积分兑换例子:假设余额是一个无符号整数,代码判断余额是否够时写的是if (balance >= cost) { balance = balance - cost; }。这里有个隐蔽的坑:如果cost大于balance,两个无符号整数相减的结果不是负数,而是一个巨大的正数。这个巨大正数可能再次参与别的判断,或者被当作新的余额写回数据库,造成“余额越扣越多”的笑话。

更常见的是加法溢出。一个 32 位有符号整数存金额,用户账户不断充值或领取奖励,累加到超过 21 亿后突然变成负数。我曾见过某电商平台的优惠券系统出现“账户余额变成负数,但系统认为负数也是有效余额”的情况,顺着查下去,就是价格计算时把“单价乘以数量”的结果存在int里,数量被参数控制,乘积溢出了。

这类漏洞的特点是:不崩溃、不报错,但逻辑已经完全错乱。普通的代码评审很难发现,因为业务代码的审查者通常关心“功能对不对”,很少会问“这个整数类型够不够大、会不会溢出”。

2.3 链条三:长度与索引计算,悄悄改变程序的“尺寸感”

第三种链路藏在数组下标和循环条件里。比如size_t(无符号)和int(有符号)混用,一个负数会被隐式转换成巨大的无符号数,导致循环条件判断错误。

最典型的翻车代码长这样:

int len = recv(fd, buf, sizeof(buf), 0); for (int i = 0; i < len - 1; i++) { process(buf[i]); }

如果recv返回 0(对端关闭连接),len - 1等于 -1。在 C 语言的隐式转换规则下,i < len - 1这行里len - 1被转成无符号数,变成SIZE_MAX,循环会一直跑下去,把缓冲区后面不可预知的内存都读一遍。

这种问题表面上跟“整数溢出”关系不大,但本质都是数值范围超出了类型边界。安全圈里常说“不要把外部可控的值直接当成数组下标或长度使用”,指的就是这类场景。

3. 现实中的翻车现场:三类最容易中招的代码

3.1 游戏与抽奖系统:数值膨胀的狂欢

游戏里是整数溢出的重灾区。攻击力、金币、经验值、道具数量,很多老游戏都用 32 位整数存储。某个道具合成系统的逻辑是“合成 N 次后数值累加”,当玩家通过反复操作把攻击力刷到 2,147,483,647 以上时,攻击力突然变成负数,整个战斗数值体系瞬间崩塌。

更讽刺的是,有些游戏还会因为这个负数值“看起来很小”而通过物品校验,玩家可以携带负数攻击力的武器去打会造成治疗效果的怪,配合其他机制直接刷资源。这类 bug 在玩家社区里传得飞快,厂商只能紧急停机修数值。如果数值系统被攻击者控制,本质上就是业务层面的整数溢出利用。

防御思路也比较直接:数值相关的字段能用 64 位就上 64 位,涉及货币、经验这种高频累加的字段,最好另加溢出检测。别觉得“我们游戏数值不可能刷到 21 亿”,玩家为了利益什么都干得出来。

3.2 协议解析与报文长度:不起眼字段的致命伤

网络协议和文件格式解析是整数溢出的另一个高发区。协议头里的长度字段往往只有 16 位,最大 65535;偏移字段可能是 32 位;加上后续的校验和、数量字段,各种类型一混,溢出就来了。

我见过一个自定义压缩包解析模块,代码先把“宽度 × 高度 × 每像素字节数”算出来作为缓冲区大小。如果图片宽度和高度都是攻击者可控的,这个乘法就可能溢出,分配出一个很小的缓冲区,后续解压数据往里写时越界。修复方式看起来也很简单——用 64 位中间值计算,或者先判断宽度和高度分别不能超过某个阈值,但现实中这类代码写了多少年就错了多少年。

审查协议解析代码时,我习惯把所有从数据包里读出来的数值都当作“攻击者可控量”,逐个检查它们参与了哪些运算、被存进了什么类型、最后用在了哪里。数值字段和“长度”“偏移”“索引”出现在同一个表达式里,就需要格外警惕。

3.3 循环计数与内存分配:老代码里的定时炸弹

第三类是看起来呆了很多年的老代码。一些底层库、核心服务,早年用int记录对象数量、用int做内存大小参数,那时数据规模不大,没人觉得会溢出。可后来系统规模变了,单个请求能携带的数据量从几 KB 涨到几百 MB,当年“永远不可能达到”的边界就被跨过了。

最有代表性的场景是malloc(count * size)。当count特别大时,count * size可能回绕成一个小值,实际分配的内存不够用,后续写入越界。C 标准库里专门提供了calloc(count, size),其中一个重要原因就是calloc会在内部检查乘法是否溢出。即使如此,也不要盲信某个接口,最好自己在调用点做一次“用除法回查”的检查。

这类代码的可怕之处在于:它平时跑得非常正常,谁能想到一个分配内存的操作会变成漏洞?但攻击者可以构造畸形输入,把count参数撑爆,触发那条几乎没人测试过的路径。

4. 动手复现:几段代码读懂溢出的完整音色

4.1 C语言里的“安静回绕”与“未定义行为”

理论说多了容易飘,直接上代码。下面这段非常短的 C 程序,把回绕和 UB 的区别展示得明明白白:

#include <stdio.h> #include <stdint.h> #include <limits.h> int main(void) { unsigned char a = 255; unsigned char b = a + 1; printf("unsigned char overflow: %u\n", (unsigned)b); int c = INT_MAX; int d = c + 1; printf("int overflow: %d\n", d); return 0; }

在常见的 GCC 环境下,这段代码会输出unsigned char overflow: 0和int overflow: -2147483648。第一行是标准定义好的回绕;第二行则只是“这台机器上恰好这么表现”,从 C 标准角度看它是未定义行为,不保证在任何编译器、任何优化级别下都这样。

更有意思的是下面这个例子。很多人会写一个“判断 x+1 是否溢出”的函数:

int is_overflow(int x) { if (x + 1 < x) { return 1; } return 0; }

逻辑看起来没毛病:如果x + 1比x还小,那一定是溢出了。但在开启优化的 GCC 下,编译器会直接把整个函数优化成return 0,因为它默认有符号加法不会溢出,于是把x + 1 < x判定为恒假。这个例子是为了记住:在 C/C++ 里不要试图用“运算结果是否反常”来检测有符号溢出,要用下面的方法。

4.2 类型截断:从 uint32_t 到 uint8_t 的距离差多远

截断问题用一段代码也能说清楚:

#include <stdio.h> #include <stdint.h> int main(void) { uint32_t length = 300; uint8_t truncated = (uint8_t)length; printf("length: %u, truncated: %u\n", length, truncated); return 0; }

输出结果是length: 300, truncated: 44。这 44 就是 300 的低 8 位,高位 256 被静默丢弃了。如果后面拿truncated当缓冲区长度,而实际数据长度是 300,那就白白丢掉了一大截数据,轻则解析失败,重则越界访问。

还有一种隐蔽的截断发生在表达式里。C 语言有“整数提升”规则,uint8_t和uint8_t相加时会先提升成int,所以不会回绕;但如果把结果赋回uint8_t,回绕或截断就在“赋值”这一步发生。很多人盯着运算过程检查半天,忘了看目标变量的类型。

这里推荐编译时把-Wconversion和-Wsign-conversion打开,让编译器帮你把隐式类型转换的坑标出来。虽然警告很多,但逐个看一遍的收益极大。

4.3 Java、Python与智能合约里的溢出长什么样

C/C++ 之外,不同语言的溢出语义差别很大,值得单独说。

Java 的int溢出是明确定义的回绕,不会触发异常。但 Java 从 8 开始提供了一组显式的溢出检测方法:Math.addExact、Math.subtractExact、Math.multiplyExact,溢出时抛ArithmeticException。比如:

int safeAdd = Math.addExact(a, b);

如果a和b相加溢出,这行代码会直接抛异常。在金额、长度这类关键计算上,建议强行使用这些方法,别在乎那一点点性能。

Python 的普通整数可以无限增长,没有溢出概念,这是很多从 C 转过来的程序员觉得省心的地方。但陷阱藏在第三方库里,比如numpy的数组元素类型如果是int32,同样会溢出,而且默认只是静默回绕。

还有一个不能漏掉的场景是智能合约领域的老版本 Solidity。0.8.0 之前,整数运算默认不检查溢出,余额减到负数会下溢成一个巨大的正数,由此造成过很多起资金被盗事件。0.8.0 之后默认检查溢出,还可以用unchecked { }块显式关闭检查来省 gas。看完这一段你应该能感觉到:整数溢出不是某个语言的专属问题,而是所有固定精度数值系统共同的内建风险。

5. 防御的完整打法:编译器开关、编码范式与审计验证

5.1 编译器与运行时防线:打开开关比记牢规则更有效

与其靠人记规则,不如让工具挡在前面。我整理了一份个人长期在用的编译器防御清单:

工具或选项作用适用场景
-ftrapv有符号整数溢出时触发陷阱调试环境,让问题尽早暴露
-fwrapv将有符号溢出定义为回绕希望行为可预测的编译单元
-fsanitize=undefined运行时检测未定义行为测试阶段,强烈推荐
-Wconversion、-Wsign-conversion警告隐式类型转换常开,配合代码重构
-fno-strict-overflow关闭基于无溢出的激进优化对性能不敏感的场景

命令行示例:

gcc -fsanitize=undefined -g -O1 -o demo demo.c

UBSan 会在运行时打印“runtime error: signed integer overflow”之类的具体信息,比肉眼干瞪代码高效太多。CI 环境里建议单独跑一个带全套 sanitizer 的测试任务,覆盖正常路径之外的边界输入。

其他语言也有自己的运行时防线:Java 有Math.*Exact系列,Solidity 0.8 之后默认检查,Rust 在 debug 模式下溢出会 panic,release 模式下默认回绕,但可以在Cargo.toml里配置让 release 模式也做溢出检查。防御的第一原则是:不要把希望寄托在“开发时不犯错”上,把检查交给语言工具。

5.2 安全编码范式:比较、分配与边界检查的固定写法

工具之外,养成固定的编码范式比任何技巧都管用。下面这几种写法是我要求自己和团队里的新人必须遵守的。

不要写a + b < c,要写成a < c - b。前者在a + b溢出时判断结果不可信,后者先保证c - b本身在安全范围内。类似的,“长度+偏移不超过缓冲区”的判断也要避免先加后比较。

分配内存前用除法回查:

if (count != 0 && size > SIZE_MAX / count) { /* 溢出,拒绝 */ }

这个“用除法验乘法”的手法虽然简单,但能覆盖掉绝大多数malloc(count * size)类漏洞。如果用calloc(count, size),标准库自己做了一部分检查,但还是要看具体平台实现,不能完全依赖。

还有一个容易忽略的点:类型统一。在底层代码里,凡是表示“字节数”“元素个数”“索引”的变量,一律用size_t或明确的 64 位类型,不要int、long、uint32_t混用。混用越少,隐式转换带来的问题就越少。

5.3 审计与验证:用工具和用例让溢出无处可藏

编码规范做得再好,也挡不住一次手滑。所以我还建议在流程层面加两道验证。

第一道是静态分析。编译器的静态分析工具可以在不运行程序的情况下发现可疑的算术表达式,比如“外部输入的数值直接参与乘法”“无符号数被强制转成有符号数”。很多团队把静态分析跑在 CI 上,可以把一部分问题堵在上线之前。

第二道是模糊测试。把输入格式喂给一个接受任意字节串的入口,很快就能撞出长度字段、数量字段的异常组合。模糊测试入口的大致逻辑如下:

// 某个常见模糊测试框架的入口 int fuzz_entry(const uint8_t* data, size_t size) { if (size < 8) return 0; uint32_t len = read_u32(data); uint32_t total = len + 8; if (total < len) { /* 这里就是溢出点,让测试框架知道撞上了 */ } return 0; }

第三道是边界值用例。每个跟数字打交道的函数,都值得跑一遍以下输入:0、1、INT_MAX、INT_MAX+1、UINT64_MAX、SIZE_MAX。别嫌枯燥,线上翻车往往就翻在这些边界上。

6. 排查疑似整数溢出时,我沿用的固定套路

6.1 从数值现象倒推根因的思路

如果线上已经出了问题,比如出现负数余额、莫名越界、循环卡死,我会按一套固定顺序排查:

现象可能的根因验证手段
金额、计数变成负数有符号加法/累加溢出检查涉及的类型,跑 UBSan,补边界用例
分配的内存过小导致崩溃乘法或加法回绕用除法回查,检查malloc(count * size)
循环条件异常、死循环无符号数负溢出、有符号/无符号混用看隐式转换,打印中间变量类型
数组越界读写索引、长度先运算后校验代码审查重点检查“先算后用”的表达式

排查的第一步永远是问“这个值从哪里来”。如果它来自网络数据包、用户输入、数据库字段,那就要假设它可能被恶意控制。第二步是看运算顺序,有时候把加法换成减法、把乘法换成分步计算,问题就消失了。第三步是检查编译器优化后的行为,有符号溢出是 UB 的代码,在-O2和-O0下表现可能完全不同,这时候要用-fsanitize=undefined确认。

6.2 几个让我少踩坑的日常习惯

最后分享几个我在实际操作中养成的小习惯,它们帮我省了不知道多少排查时间。

每次写“两个数值相乘”之前,先在心里问一句:这两个数各自的最大值是多少,乘积会不会越界?如果输入可能被攻击者控制,这笔账一定要算清楚。

代码评审时,看到“外部传入的数值 - 内部常量”“外部传入的数值 + 偏移量”这类表达式,我会直接标成重点疑点。很多溢出漏洞就藏在看起来人畜无害的加减法里面。

尽量不要在比较运算里混合有符号和无符号数。编译器虽然会隐式转换,但转换规则反直觉,一旦混用,代码的可读性和安全性都会下降。实在绕不开,就显式写强制转换,并加注释说明为什么不越界。

最后一条,把边界值测试当作回归用例固定下来。我见过太多修复方案是“把相关类型改成 int64”,改完之后顺了,但下次有人重构时又换回 int,问题复发。只有持续跑的自动化用例,才能真正守住这条防线。

写到这里,我想起那套积分兑换系统最终改成了什么样子:金额全部换成 64 位整数,关键减法前先做范围检查,CI 里加了边界值用例,还跑了一遍 UBSan。那次之后,团队里对“好好说话”的理解也统一了——先把数值的边界说清楚,再让代码动手。这个原则,放到任何语言的任何项目里都不过时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 11:09:00

DeepSeek本地部署全攻略:从Ollama安装到Open WebUI图形界面

简介&#xff1a;这份资源是一份面向AI推理部署与深度学习开发者的DeepSeek模型本地化部署指南&#xff0c;适合具备一定软硬件基础、希望降低云服务成本并深入理解大模型底层机制的研究人员与工程师。内容围绕Ollama安装、按显存规模选择DeepSeek-R1参数量版本、Docker容器化与…

作者头像 李华
网站建设 2026/10/15 11:08:46

智能体操作真机:从感知决策到落地实践的技术拆解

1. 当智能体第一次摸到真机&#xff0c;它到底在摸什么很多人对"AI 智能体操作真机"这件事的第一反应是&#xff1a;不就是自动化脚本换了个壳吗&#xff1f;以前用 Appium、用 Playwright、用影刀&#xff0c;现在换成大模型来驱动&#xff0c;本质不还是点按钮、填…

作者头像 李华
网站建设 2026/10/15 11:08:45

AI智能体操作真机实战:ReAct模式、工具调用与安全边界全解析

1. AI 智能体操作真机这件事&#xff0c;到底走到哪一步了最近圈子里聊得最多的话题之一&#xff0c;就是 AI 智能体开始真正操作物理设备了。不是那种在沙盒里跑跑模拟、在网页上点两下按钮的“伪操作”&#xff0c;而是切切实实地控制机械臂、无人机、工业设备甚至手机真机。…

作者头像 李华
网站建设 2026/10/15 11:08:19

从千脑智能到AI工程:参考框架与预测编码的落地实践

1. 从《千脑智能》第8到10章说起&#xff1a;为什么一个搞AI的人要回头翻脑科学的书 我平时的工作节奏基本是白天调模型、晚上看论文&#xff0c;偶尔穿插一些工程落地的事情。大概两年前开始&#xff0c;我养成了一个习惯&#xff1a;每读一本跟AI相关的书&#xff0c;都会顺手…

作者头像 李华
网站建设 2026/10/15 11:07:35

vimrc 参考配置文件:把编辑器 settings 改到 TaoToken 的 AI 补全工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华