89、漏洞逆向分析(三):整数溢出、格式化字符串漏洞
好,咱们继续往下聊。前两讲我们把栈溢出和堆溢出撸了一遍,今天要聊的两个漏洞类型——整数溢出和格式化字符串漏洞,说实话,在真实漏洞里出现的频率比很多人想象的要高得多。
我个人习惯把整数溢出叫做「隐形的杀手」。为什么?因为它不像栈溢出那样直接崩给你看,也不像格式化字符串那样有明显的 %s 让你一眼识破。它往往藏在看似正常的逻辑里,等你触发的那一刻,已经晚了。
一、整数溢出:边界上的魔鬼
先说说整数溢出到底是个啥。说白了,就是整数在运算时超出了它能表示的范围,结果发生了「回绕」。
举个例子,一个 32 位无符号整数的最大值是 0xFFFFFFFF,也就是 4294967295。如果你给它加 1,它会变成 0,而不是 0x100000000。这就是溢出。
嗯,这里要注意:有符号整数的溢出在 C/C++ 标准里是未定义行为,编译器可以自由发挥。我见过一个项目,因为编译器优化把溢出检查代码直接优化掉了,结果线上出了严重事故。
1.1 整数溢出的三种常见场景
我在逆向分析中总结了三类最常见的整数溢出场景:
| 类型 | 说明 | 典型后果 |
|---|---|---|
| 宽度溢出 | 将大宽度整数赋值给小宽度变量 | 截断导致值变小,绕过安全检查 |
| 算术溢出 | 加减乘除运算导致超出范围 | 分配过小缓冲区,后续写入越界 |
| 符号转换溢出 | 有符号与无符号混用 | 负数被解释为极大正数 |
我曾经在分析一个网络协议栈时遇到过第三种情况。一个长度字段被声明为 int,但实际使用时被隐式转换为 size_t。攻击者传入一个负数,结果 memcpy 的第三个参数变成了 0xFFFFFFFF... 你想想看,那会发生什么?
1.2 逆向分析中的识别技巧
在 IDA Pro 或 Ghidra 里,怎么快速定位整数溢出?我一般会关注以下几个模式:
- malloc/calloc 前的乘法运算:比如
malloc(a * b),如果 a 和 b 可控,这就是高危点 - 循环条件中的减法:比如
while (len-- > 0),如果 len 是 0,减 1 变成 -1(无符号下是极大值) - 类型转换指令:在汇编中看到
movzx(零扩展)或movsx(符号扩展)时,要留意源操作数的宽度
imul 指令(有符号乘法)或 mul 指令(无符号乘法),然后向上追溯操作数来源。如果操作数来自用户输入,十有八九有问题。
1.3 实战案例:一个经典的整数溢出链
来看一段伪代码,这是我简化后的真实案例:
int copy_data(char *input, unsigned int size) {
char *buf;
unsigned int alloc_size = size + 1; // 加1给结尾'\0'
if (alloc_size > 1024) {
return -1; // 安全检查
}
buf = malloc(alloc_size);
if (!buf) return -1;
memcpy(buf, input, size); // 这里出事了
buf[size] = '\0';
return 0;
}
看出问题了吗?如果 size 是 0xFFFFFFFF,那么 alloc_size = 0xFFFFFFFF + 1 = 0。安全检查通过(0 < 1024),malloc(0) 返回一个很小的堆块,然后 memcpy 往里面写 4GB 数据... 嗯,堆溢出就这么发生了。
二、格式化字符串漏洞:被低估的威胁
聊完整数溢出,咱们来说说格式化字符串漏洞。很多人觉得这漏洞「老掉牙」了,现在新代码里应该很少见。说实话,我一开始也这么想,直到去年我还在一个嵌入式设备的 Web 管理界面里挖到了一个。
格式化字符串漏洞的本质很简单:攻击者控制了格式化字符串的内容。比如:
printf(user_input); // 错误写法
printf("%s", user_input); // 正确写法
如果 user_input 是 "%x %x %x %x",那 printf 就会从栈上读取数据并打印出来。这就是信息泄露。如果 user_input 是 "%n",那 printf 就会往某个地址写入数据。这就是任意写。
2.1 格式化字符串的「武器库」
我整理了一张表,方便你快速查阅:
| 格式符 | 作用 | 利用价值 |
|---|---|---|
| %x / %p | 以十六进制打印栈上数据 | 泄露栈地址、堆地址、代码地址 |
| %s | 将栈上值作为指针,打印字符串 | 泄露任意地址内容(需控制指针) |
| %n | 将已输出字符数写入栈上指针指向的地址 | 任意地址写入(高危!) |
| %hn / %hhn | 写入 2 字节 / 1 字节 | 精确控制写入粒度 |
你想想看,一个 %n 就能让攻击者往任意地址写任意值。这比栈溢出还狠——栈溢出至少还要找返回地址,格式化字符串直接给你一个「任意写」的接口。
2.2 逆向分析中的定位方法
在二进制里怎么找格式化字符串漏洞?我一般按这个流程来:
- 搜索
printf、sprintf、fprintf、snprintf等函数调用 - 检查第一个参数(格式化字符串)的来源:是硬编码字符串,还是来自用户输入?
- 如果是用户输入,检查是否经过了转义或过滤:比如有没有把
%替换成%% - 如果没过滤,那就是漏洞
Alt + I 打开「搜索立即数」功能,搜索 0x7325(即 "%s" 的 little-endian 表示)。不过更直接的方法是写一个 IDAPython 脚本,遍历所有 call printf 指令,检查第一个参数是否来自 esp+4 或 rdi 且不是 .rodata 段地址。
2.3 利用思路:从泄露到控制
格式化字符串漏洞的利用一般分两步走:
第一步:信息泄露。 用 %p 或 %x 读取栈上的值,找到栈地址、libc 地址、PIE 基址等。我习惯用 %N$p 这种直接定位到第 N 个参数,省得一个个数。
第二步:任意写。 用 %n 往目标地址写入控制数据。比如覆盖 GOT 表中的函数指针,或者覆盖返回地址。
举个例子,假设我们要覆盖 exit@GOT 为 system 的地址:
// 假设 exit@GOT 地址为 0x0804a010
// 我们要写入 system 地址 0xb7e6b060
// 构造 payload:
// 先写低 2 字节:0xb060 = 45152
// 再写高 2 字节:0xb7e6 = 47078
payload = "\x10\xa0\x04\x08" // exit@GOT 地址
payload += "\x12\xa0\x04\x08" // exit@GOT+2 地址
payload += "%45144c%10$hn" // 写入 0xb060
payload += "%2626c%11$hn" // 写入 0xb7e6
嗯,这里要注意:%45144c 的意思是「打印 45144 个字符」,加上前面已经输出的 8 个字节地址,总共 45152 个字符,正好是 0xb060。然后 %10$hn 把这个值写入第 10 个参数指向的地址(即 exit@GOT)。
%7$p 才是栈上的第一个参数。这个细节搞错了,泄露出来的数据全是错的。
三、知识体系总览
说了这么多,我画了一张图来总结本章的核心逻辑:
四、总结与实战建议
好了,今天的内容就到这里。总结一下:
- 整数溢出:关注
malloc前的乘法、循环条件中的减法、有符号/无符号混用。在逆向时,多留意imul、mul、movzx、movsx这些指令。 - 格式化字符串:关注
printf系列函数的第一个参数来源。如果是用户输入且未过滤,那就是漏洞。利用时注意 32 位和 64 位的参数传递差异。
我个人建议,在实战中可以把这两个漏洞类型结合起来看。比如整数溢出导致分配了过小的缓冲区,然后往里面写数据时触发了格式化字符串漏洞——这种组合拳在真实漏洞中并不少见。
最后,送你一句话:逆向分析不是找漏洞,而是理解程序的行为。当你理解了程序「应该」怎么做,你自然就知道它「可能」怎么做错了。
公众号:蓝海资料掘金营,微信deep3321