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(符号扩展)时,要留意源操作数的宽度
小技巧:在 IDA 中,可以搜索 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 数据... 嗯,堆溢出就这么发生了。

避坑指南:我曾经在审计一个开源项目时,发现类似的漏洞被修复的方式是「把 size 类型改成 int」。这其实没解决根本问题——负数传入后,安全检查依然可能被绕过。正确的做法是:先检查 size 是否在合理范围内,再做算术运算

二、格式化字符串漏洞:被低估的威胁

聊完整数溢出,咱们来说说格式化字符串漏洞。很多人觉得这漏洞「老掉牙」了,现在新代码里应该很少见。说实话,我一开始也这么想,直到去年我还在一个嵌入式设备的 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 逆向分析中的定位方法

在二进制里怎么找格式化字符串漏洞?我一般按这个流程来:

  1. 搜索 printfsprintffprintfsnprintf 等函数调用
  2. 检查第一个参数(格式化字符串)的来源:是硬编码字符串,还是来自用户输入?
  3. 如果是用户输入,检查是否经过了转义或过滤:比如有没有把 % 替换成 %%
  4. 如果没过滤,那就是漏洞
小技巧:在 IDA 中,可以按 Alt + I 打开「搜索立即数」功能,搜索 0x7325(即 "%s" 的 little-endian 表示)。不过更直接的方法是写一个 IDAPython 脚本,遍历所有 call printf 指令,检查第一个参数是否来自 esp+4rdi 且不是 .rodata 段地址。

2.3 利用思路:从泄露到控制

格式化字符串漏洞的利用一般分两步走:

第一步:信息泄露。%p%x 读取栈上的值,找到栈地址、libc 地址、PIE 基址等。我习惯用 %N$p 这种直接定位到第 N 个参数,省得一个个数。

第二步:任意写。%n 往目标地址写入控制数据。比如覆盖 GOT 表中的函数指针,或者覆盖返回地址。

举个例子,假设我们要覆盖 exit@GOTsystem 的地址:

// 假设 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)。

避坑指南:我曾经在 64 位程序上栽过跟头。64 位下前 6 个参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),第 7 个才开始在栈上。所以 %7$p 才是栈上的第一个参数。这个细节搞错了,泄露出来的数据全是错的。

三、知识体系总览

说了这么多,我画了一张图来总结本章的核心逻辑:

整数溢出 & 格式化字符串漏洞知识体系 整数溢出漏洞 宽度溢出 | 算术溢出 | 符号转换溢出 malloc前乘法 → 堆溢出 循环条件减法 → 死循环/越界 格式化字符串漏洞 %x/%p 信息泄露 | %n 任意写 泄露栈地址 → 绕过 ASLR 覆盖 GOT → 劫持控制流 共同点:源于对用户输入的边界/格式缺乏校验 逆向分析关键:追踪数据流 + 识别危险函数调用

四、总结与实战建议

好了,今天的内容就到这里。总结一下:

  • 整数溢出:关注 malloc 前的乘法、循环条件中的减法、有符号/无符号混用。在逆向时,多留意 imulmulmovzxmovsx 这些指令。
  • 格式化字符串:关注 printf 系列函数的第一个参数来源。如果是用户输入且未过滤,那就是漏洞。利用时注意 32 位和 64 位的参数传递差异。

我个人建议,在实战中可以把这两个漏洞类型结合起来看。比如整数溢出导致分配了过小的缓冲区,然后往里面写数据时触发了格式化字符串漏洞——这种组合拳在真实漏洞中并不少见。

最后,送你一句话:逆向分析不是找漏洞,而是理解程序的行为。当你理解了程序「应该」怎么做,你自然就知道它「可能」怎么做错了。


公众号:蓝海资料掘金营,微信deep3321