深入解析整型溢出漏洞:原理、利用与修复
发布时间:2026/10/14 13:42:44 锦皓数字建站

“整型溢出漏洞”这个词搞安全的人几乎天天见。前阵子我接手一个棘手的线上问题某个内部项目里负责解析图片数据的服务偶发崩溃一天运气好就两三次日志信息少得可怜只留下一个模糊的段错误。负责的同事先是怀疑内存泄漏后怀疑并发冲突反复压测一周都没复现。我翻到解码流程里一行看起来人畜无害的加法一个从文件头读出来的长度字段和一个固定偏移量相加然后直接拿去当数组下标用。我当时心里就咯噔一下这路线太熟了——典型的整型溢出漏洞。很多人以为整型溢出是“上古时期”的漏洞现在内存又大、语言又高级早该绝迹。但事实恰恰相反在 C/C 写的解析器、协议栈、图像库甚至在业务系统的金额计算里我每年都能见到它的身影。它很少单独存在更多时候是作为攻击链的第一环先让长度算错绕过边界检查再把越界读写成可能。这篇内容我想用一个相对完整的链路把整型溢出的底层原理、高频触发点、实际利用过程、修复误区和落地防御都串起来。如果你正在学漏洞分析、做代码审计或者你是写底层服务、处理外部输入的后端开发这篇内容应该对你有用。1. 整型溢出的本质为什么计算机里的整数会“翻车”先说结论计算机里的整数不是数学意义上“无限大”的整数而是一串固定长度的二进制位。比如 32 位无符号整数取值范围是 0 到 4294967295超过上界的值不会继续增长而是从 0 重新绕回。这和汽车里程表的原理一样999999 公里之后再加 1表盘会跳到 000000。真正的总里程当然没变但表盘上显示的数字已经错了程序如果只盯着表盘读就会做出错误判断。1.1 一个看似无害的加法怎么让程序崩溃看下面这段代码在很多解析器里都能见到类似的写法uint8_t *parse_data(uint8_t *buf, size_t len) { uint8_t tmp[64]; size_t copy_len len 32; // 如果 len 接近 SIZE_MAXcopy_len 会回绕成一个很小的数 if (copy_len sizeof(tmp)) { return NULL; } memcpy(tmp, buf, copy_len); return tmp; }假设len是 0xFFFFFFFFFFFFFFE0那len 32在 64 位机器上的结果不是想像中的 0x10000000000000000而是 0。copy_len变成 00 sizeof(tmp)判断为假代码认为“数据长度没超过缓冲区”继续往下执行。如果实际输入缓冲区后面真的跟了一大堆数据memcpy 就会按错误的长度去拷贝瞬间越过栈上tmp的边界破坏栈帧里保存的返回地址。这就是一次很典型的整型溢出引发内存破坏的过程。这类问题最让人头疼的地方在于它不是每次触发只在输入恰好跨越位宽边界时才爆炸。正常业务数据永远填不满那个“临界值”所以代码可能上线两三年都没事直到某天有人传了一个精心构造的长度字段。1.2 整数在内存里的真实面目补码、无符号和有符号要真正理解整型溢出就不能只看十进制。现代计算机几乎都使用二进制补码来表示有符号整数。以 8 位为例无符号数unsigned char0 0b00000000255 0b11111111。有符号数signed char0 到 127 是 0b00000000 到 0b01111111负数从 -128 开始-128 0b10000000-1 0b11111111。最高位既是符号位也参与数值计算所以 -1 加上 1 会回到 0这本质上也是回绕。这里有个关键区别无符号整数的溢出行为在 C 标准里是明确规定的取模回绕而有符号整数的溢出在 C 标准里属于未定义行为Undefined Behavior。未定义行为意味着编译器可以做任何它认为“合理”的优化包括假设你的有符号变量永远不会溢出然后直接删掉一部分判断代码。这就引出很多隐蔽问题后文会专门讲。更坑的是隐式类型转换。C/C 里只要int和unsigned int混在一起运算int通常会被悄悄转成unsigned int。看这个经典例子int a -1; unsigned int b 1; if (a b) { // 你以为会进入这个分支因为 -1 1 } // 实际不会因为 a 被转成 0xFFFFFFFF0xFFFFFFFF 1 是假一旦程序里存在这种“有符号数被当成极大的无符号数”的判断攻击者就能利用负数绕过一堆校验。这类问题在解析畸形输入时尤其常见因为输入里的长度字段经常先被保存为int后续却和size_t做比较。1.3 截断位宽收缩比溢出更隐蔽除了回绕还有一种形式叫“截断”。把一个 64 位整数赋值给 32 位变量时只保留低 32 位高 32 位直接丢弃。例如0x100000001截断成 32 位后就是 1。攻击者如果控制一个 64 位的长度值程序却用 32 位变量接收后面的计算就会建立在一个完全错误的基础上。我见过一个很典型的漏洞模式文件格式头里用 64 位记录数据块总长度解析器把它读进uint64_t后来又赋值给int去参与循环。攻击者把高 32 位填一个很大的数低 32 位填 0经过截断后变量变成 0绕过长度检查实际数据却被按巨大长度读取最终导致越界访问。所以排查整型溢出时看到的不只是“加减法溢出了”还有“类型收缩”“有符号无符号互转”这些变种。本质上都是同一个问题程序信任了一个无法被安全表示的数值。2. 高频踩雷场景从协议解析到业务金额的计算陷阱整型溢出虽然属于底层机制问题但它不是只在 C 语言里出现也不是只存在于内存操作相关代码里。凡是有“外部输入数值参与算术运算再基于结果做决定”的地方都可能是重灾区。我按实际遇到的频率排个序。2.1 长度计算与内存分配先算后检查的顺序陷阱这是最危险、也最容易被攻击者盯上的一类。典型模式是“先计算总大小再做大小检查然后分配或拷贝”size_t header_len 16; size_t payload_len get_from_network(); // 攻击者可控 size_t total header_len payload_len; if (total 1024) { // 检查 total char *buf malloc(total); memcpy(buf, data, payload_len); // 拷贝时却用原始 payload_len }攻击者把payload_len设成0xFFFFFFFFFFFFFFF0加上 16 之后total回绕为 0。total 1024成立程序认为“总长度没超限”但后续 memcpy 按payload_len去拷贝实际拷贝量接近 2^64直接造成越界读写。这里的核心问题是检查的对象和使用的对象不是同一个“可信”的值。即使检查通过了拷贝长度依然基于攻击者原始输入。这种场景常见于网络协议解析、图片格式解码、音频文件处理、序列化反序列化库、压缩包解压等。只要一个二进制格式里有“长度字段”几乎就要高度警惕。2.2 协议解析和文件格式对外部输入长度字段的信任问题很多开发者觉得“长度字段先校验一次就行了”但问题在于“校验”本身也会溢出。举一个处理分块传输的简化例子// 伪代码 uint32_t chunk_len ntohl(packet-len); uint32_t offset packet-offset; if (offset chunk_len MAX_BUFFER) { // 溢出点 return ERROR; } read_to(buffer offset, chunk_len);如果offset很大offset chunk_len回绕成一个很小的数安全检查就会认为“数据没有越界”。实际上offset已经接近最大值再加一个 chunk_len 后早就超出MAX_BUFFER的范围了。正确写法应该是先做减法比较if (offset MAX_BUFFER || chunk_len MAX_BUFFER - offset)从数学上避免两个大数相加再谈判断。文件格式里的图像尺寸计算也很典型。假设一个 BMP 文件头里声明了宽度和高度解码器计算每行字节数row_size width * 3。如果 width 攻击者可控乘 3 之后发生回绕row_size 变小程序分配的缓冲区比实际需要的窄后面逐行填充像素时就会写出边界。这种乘法溢出在 32 位环境尤其常见因为int只有 32 位真实世界中 8000 万像素宽的文件就能触发。2.3 循环计数和数组索引边界逐次逼近时埋下的雷循环里也常见尤其当循环变量是无符号类型并且循环条件用了“小于等于”时for (uint32_t i 0; i n; i step) { arr[i] process(i); }如果n接近UINT32_MAXi在最后一次合法迭代时已经靠近上界下一次再加step就会回绕成 0 或者一个小数。此时i n依然成立循环继续执行但数组却已经没有第 0 个元素的空间了最终越界写。这类问题常见于像素索引遍历、坐标网格计算、缓冲区偏移寻址。更隐蔽的变种是“边界减一”。写for (i 0; i count - 1; i)时如果count为 0count - 1在无符号类型下变成UINT32_MAX循环体实际执行巨大次数直接崩溃或越界。这种坑在修复时特别容易踩因为新手往往只想到“count 正常为正”忘了外部可控时它完全可能是 0。2.4 不只是 C 语言业务层金额与智能合约中的溢出很多人有个误区觉得“我用 Java、Python 就安全了”。Java 的int和long同样会溢出Python 虽然不会对大整数回绕但在调用底层 C 库、numpy 等场景时整数边界问题依然存在。业务系统里金额、积分、库存、折扣率的计算只要用了基础类型乘法就可能出现负数或极小值。举个例子电商里算总价int total price * quantity; // price 和 quantity 都可能来自外部输入如果把price和quantity乘到超过Integer.MAX_VALUE结果会变成负数总额变成“负退款”下单反而要倒贴。这类问题不需要内存破坏直接造成直接经济损失危害同样严重。智能合约场景也是一个典型。区块链上用 uint 类型如果做减法时不检查“余额是否大于扣减额”余额为 0 时扣 1 会回绕成巨大的数相当于凭空增资。虽然现在有 SafeMath 这类库但很多新项目依然会犯基础错误。3. 完整走查一次漏洞实战从越界读写到内存破坏前面讲的都是原理和片段这里用一个模拟项目 X 做完整链路走查。项目 X 是一个简化版 RPC 服务接收“长度前缀 数据体”的报文。漏洞代码在报文解析函数里看起来也很“合理”但攻击者可以一步步把它变成一次稳定利用。3.1 构造崩溃输入偶然的段错误项目 X 的解析函数大致是这样#define HEADER_LEN 16 int process_message(uint8_t *buf, size_t bufsize) { if (bufsize 4) { return -1; } uint32_t payload_len *(uint32_t *)buf; // 攻击者完全控制 uint32_t total_len payload_len HEADER_LEN; // 可疑点 if (total_len bufsize) { // 希望防住越界 return -1; } uint8_t *payload malloc(payload_len 1); // 分配空间时再加 1 if (!payload) { return -1; } memcpy(payload, buf HEADER_LEN, payload_len); // 后续处理 payload return 0; }看着是不是很像常见的自我安慰式代码先读长度加上头部算总长检查总长是否超过缓冲区。但问题在于payload_len是uint32_t而bufsize是size_t在 64 位系统上size_t是 64 位比较时total_len会被提升为 64 位。攻击者让payload_len 0xFFFFFFF0total_len 0x100000000这并没有溢出但total_len bufsize检查能拦下吗如果bufsize是 0x1000那么确实会拦截。所以这个例子还不够过分。换个更刁钻的如果payload_len HEADER_LEN在 32 位空间里回绕效果是payload_len 0xFFFFFFF0total_len 0因为 0xFFFFFFF0 0x10 0x100000000截断为 32 位后是 0。此时total_len bufsize为假检查通过。注意这里total_len是 32 位相加结果按 32 位回绕。如果编译器按 64 位提升则不会绕回所以这取决于代码怎么写的。正是这种“看似相同但在不同位数下效果不同”的差异让排查变得异常困难。3.2 分析崩溃根因越界读还是越界写继续走查。假设payload_len 0xFFFFFFF0total_len回绕为 0检查通过后执行malloc(payload_len 1)。payload_len 1同样回绕为 0所以malloc(0)返回一个很小的堆块通常 16 字节或者更少。随后memcpy(payload, buf HEADER_LEN, payload_len)按 0xFFFFFFF0 字节拷贝从堆块开始疯狂写。这会产生两个问题越界读buf HEADER_LEN指向的临时缓冲区只有很小的实际数据memcpy 会读取缓冲区之外的内容造成信息泄露。越界写memcpy 把超大内容写到仅 1 字节的堆块里直接覆盖堆元数据和相邻堆对象。用 ASANAddress Sanitizer跑一遍输出里会看到“WRITE of size ... at ...”和“heap-buffer-overflow”的明确提示定位到 memcpy 这一行。这时整个崩溃链路就清楚了检查用的是回绕后的 total_len分配用的也是回绕后的长度但拷贝用的却是原始 payload_len。这就是“检查、分配、使用三个环节各算各的账”的典型教训。3.3 从内存破坏到控制流劫持利用思路与限制有了堆越界写离任意代码执行还有一段距离。攻击者需要让越界写入精准命中某个敏感对象比如堆上的一个函数指针、一个对象头里的长度字段、一条链表的节点指针。通常思路是“堆风水”先大量分配某种结构的对象让包含敏感字段的堆块正好排在目标块之后然后触发越界写用可控数据覆盖相邻对象里的函数指针。当程序后续调用该函数指针时流程就被劫持。越界读也有用可以先把堆上或栈上的泄露数据读出来绕过 ASLR得知当前进程中库函数的运行时地址再配合越界写稳定完成攻击。这就解释了为什么很多“只有几字节的越界写”也能被利用到极致。但实际利用有很多限制ASLR 让每次加载地址随机化需要先做信息泄露。堆布局不稳定需要反复尝试和校准。栈保护Stack Canary虽然挡不住堆上的利用但能挡住一部分栈溢出路径。现代编译器和系统还有 CFI、SafeStack 等机制增加控制流劫持难度。所以现在做这一类漏洞利用往往讲究“组合拳”一个整型溢出造成越界读泄露基址再一个越界写完成劫持。理解原理对防御至关重要如果你只堵住了越界写却漏了越界读攻击者依然可能拿到关键信息后续配合别的漏洞依然能得手。4. 修复的常见错觉为什么简单加一个判断往往不顶用很多开发者在修复整型溢出时第一反应是“那我加一个长度判断不就行了”听起来很有道理但实际操作中我见过太多修完依然被绕过的例子。这里集中讲几个高频翻车点。4.1 检查写在了错误的位置或者检查了错误的值“先加判断”没错但判断写在哪、用什么变量判断天差地别。最典型的问题是代码先做了运算和分配再补一个检查或者检查时用的是中间变量而后面执行时用的又是原始变量。攻击者构造的输入只要让“检查用变量”和“执行用变量”不一致就能轻松绕过。另一个典型错误是“补丁式修复”只修了一处但同一个长度值在代码里被传了三个函数每个函数内部各算一次。修好第一处第二处还是旧的加法逻辑等于白修。修复整型溢出必须系统排查把同一个数据流上的所有计算都过一遍。4.2 检查本身也溢出拿一个错的值去比较如果检查条件本身写成if (head_len payload_len MAX_SIZE)而head_len payload_len自己就会回绕那这个检查就是个筛子。正确的写法是避免“两个大数先相加再比较”改成if (payload_len SIZE_MAX - head_len) { return -1; } size_t total head_len payload_len;这种“先减后加”的模式可以保证在做加法时结果一定不会溢出。乘法的检查对应要复杂一些可以借助除法if (a ! 0 b MAX_SIZE / a) overflow更稳妥的做法是直接使用编译器提供的溢出检测内置函数例如 GCC/Clang 的__builtin_add_overflow、__builtin_mul_overflow返回值会告诉你是否溢出同时把计算结果写回。看一个安全修复前后的对比// 危险版本 uint32_t total_len payload_len HEADER_LEN; if (total_len MAX_SIZE) { ... } // 修复版本 if (payload_len MAX_SIZE - HEADER_LEN) { return -1; } size_t total_len payload_len HEADER_LEN;第二版在加法之前先判断payload_len是否大于“容器剩余空间”整个计算过程不可能溢出。4.3 依赖“溢出回绕”本身的检查会被编译器优化掉这是有符号数场景里最隐蔽的坑。有人为了判断溢出会写这样的代码int a ...; if (a 1 a) { // 认为发生了溢出 }在数学世界里a 1永远大于aa 1 a永远为假。而在有符号溢出是 UB 的前提下编译器完全有理由认为a 1不会溢出于是把整个 if 优化成恒假你的“溢出检测”就被无声删除了。即使在实际运行中a 1确实回绕成了负数判断也可能已经不生效。这类问题在开高优化级别O2/O3时最容易出现因为编译器会大胆地根据“无溢出假设”做剪枝。所以判断溢出不要自己靠加减法回绕来判断要么用无符号数的明确定义行为要么用编译器提供的溢出内建函数要么用语言标准库里的安全运算函数。C 里还有 SafeInt、checked 类型等第三方库可用不过引入依赖前要评估。4.4 静态分析工具的真实局限很多团队会依赖静态分析工具来扫漏洞比如程序分析工具、编译器警告等。它们对整型溢出有一定帮助但局限也很明显误报多工具无法完全理解业务语义经常把很多不会溢出的计算也报出来导致开发人员直接忽略。漏报多跨函数、跨模块的溢出检测能力很弱尤其是需要结合输入约束条件才能判断溢出的场景工具很难建立完整的“输入从哪来、经过什么变换、到哪去”的数据流。检测依赖特定库和构型很多工具对第三方库的建模不全库函数内部发生的溢出扫不到。所以我把静态分析定位成“辅助线索”真正可靠的是两层第一层是写代码时用安全模式计算第二层是运行时用 Sanitizer 和模糊测试把问题炸出来。5. 一套能落地的修复与验证体系前面讲了很多“为什么不能这么修”这里给一套我在实战中验证过有效的方法从代码规范、编译选项、测试方法到回归流程尽量完整。5.1 编码规范把“外部输入数值”当作不可信数据对待处理任何来自网络、文件、用户请求的数字都要默认“这个数可能是 0、负数、极大值”。建议团队定几条硬性规则长度、偏移量、计数一律使用无符号类型size_t/uint64_t避免无符号类型位宽差异带来的隐式转换。禁止直接使用int保存外部可控的长度值如果必须使用先在赋值处做范围检查并显式转换。加减乘运算前先做“剩余空间检查”或“溢出检测”禁止“先运算后检查”。给所有外部输入相关函数写清晰的前置条件注释标明参数允许范围。这些规则看起来死板但真的能拦截大多数低级错误。相比让每个开发临时判断“这里会不会溢出”不如让规定简单到不需要思考。5.2 编译器与运行时检测开足监控代码写完之后靠编译器和工具兜底。我常用的组合是gcc -fsanitizeaddress,undefined -fno-sanitize-recoverundefined -Wall -Wextra -Wconversion -Wsign-conversion其中-fsanitizeaddress检测堆/栈越界、释放后使用等内存问题。-fsanitizeundefined检测有符号溢出、数组下标越界等未定义行为。-fno-sanitize-recoverundefined一旦检测到问题立即停止而不是继续跑导致更多误导性日志。-Wconversion -Wsign-conversion把无符号/有符号隐式转换的警告开起来。第一次跑会刷屏但你会在刷屏中发现一堆真实问题。这套组合在开发和 CI 阶段运行通常能抓出大部分整型溢出。但要注意Sanitizer 只能抓“测试执行到的那条路径”所以一定要配合足够的边界值输入否则代码里 100 个溢出点测试只跑到 1 个等于没跑。5.3 边界值测试与模糊测试主动制造临界输入写单元测试时边界值不能只填 0 和 1还要填INT_MAX、INT_MAX 1、UINT32_MAX、SIZE_MAX、-1以有符号接收时、0x80000000负数最小表示。这些都是整型溢出的“引爆点”。对解析类组件重点推荐用模糊测试持续跑。思路很简单不断生成半随机的畸形字节流喂给解析器同时开着 ASAN/UBSAN一旦触发崩溃就自动收集样本。模糊测试对整型溢出特别有效因为边界值本身就是半随机输入里最容易命中的区域。我自己的经验是很多老项目用模糊测试跑几个小时就能翻出三四个从来没被发现过的长度计算问题其中至少一两个是整型溢出。5.4 建立可复现的回归用例发现一个溢出点不能只修完就完事。要把触发样本、构造思路、触发路径记录下来做成回归测试保证以后重构不会把同一个坑重新踩一遍。回归用例里我会固定包含以下内容触发样本二进制输入或构造参数期望结果修复后应被正确拒绝而不是崩溃运行环境Sanitizer 开启的调试构建触发点说明哪个函数、哪个偏移计算出了错好处是团队以后任何人改这块代码CI 一跑就知道边界行为是否被破坏。一个有回归用例的漏洞修复才算是真正闭环。6. 排查整型溢出时我盯着哪些“代码气味”最后分享一些排查时的实操经验。不是那种教科书式步骤而是我这些年踩坑踩出来的直觉。第一看到size_t和int直接参与运算或者看到malloc/memcpy的参数来自某个加减法表达式就该立刻提高警惕哪怕旁边写着看起来很完备的检查。因为这类代码往往会犯“检查后使用不一致”的错误只是平时没触发。第二盯住从二进制字节序读上来的长度字段。凡是ntohl/le32toh/fread读出来、然后又去做/*的字段都要问一句如果这个值是 0xFFFFFFFF我的检查还能拦住吗如果拦不住就是漏洞。第三用编译器警告通扫一遍转换问题。很多团队代码库里都有几千行风干老代码用-Wconversion编译一遍往往能发现大量“有符号转无符号”的隐蔽赋值。这些不一定是可利用漏洞但它们是弱信号值得逐个排查。第四调试时多用 UBSAN 的日志。runtime error: unsigned integer overflow只提示“这里发生了回绕”但你要顺着它往上追看这个回绕值是否被用于内存访问或权限判断。真正要修的是“使用了回绕值做决定”的地方而不只是把回绕本身消灭。如果回绕后没有任何危险操作那它只是一个无害的数值现象优先级可以降低但建议还是顺手修掉因为未来重构很可能让这个值进入危险函数。第五养成一个习惯修复某个具体溢出点后全局搜索同一个小函数里所有类似的计算模式。整型溢出很少孤立出现一个输入长度在代码里经常被加偏移、减头部、乘以元素大小、对齐到页大小每个环节都可能“各爆一次”。我见过不少团队修完一个点第二天同一个解析函数又在另一个偏移计算上被打穿。这几年做安全评估我最重要的体会是不要相信“这个长度不会被用到那么大”。攻击者最擅长的恰恰是把正常业务不会出现的边界值变成现实。与其花大力气等一个精妙的纯逻辑漏洞不如先静下心把所有长度计算、偏移加法、位数转换都过一遍——整型溢出漏洞就是这种“看起来很基础但漏一次就够你忙一个月”的口子。它普通但绝不廉价。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。