4. 格式化字符串漏洞:漏洞原理、任意地址读写、利用技巧与防护
格式化字符串漏洞,在MCU固件里其实比大家想象的要常见。我早年做某款工业控制器的固件审计时,就撞上过一个隐藏了三年的格式化字符串漏洞——日志打印函数直接把用户输入的WiFi名称丢给了printf。嗯,今天我们就把它彻底讲透。
4.1 漏洞原理:printf是怎么被“策反”的
先看一个最基础的例子:
// 安全写法
printf("%s", user_input);
// 漏洞写法
printf(user_input);
为什么第二行有问题?
printf的签名是 int printf(const char *format, ...)。当你只传一个参数时,这个参数被当作格式化字符串解析。如果用户输入了 %x %x %x %x,printf就会从栈上“偷”数据——它以为后面的参数还在,其实已经越界了。
核心本质:格式化字符串函数(printf/sprintf/snprintf)对参数个数和类型不做校验。攻击者通过控制格式化字符串,就能读取或写入栈上的任意内存。
在MCU上,栈空间通常只有几KB。我见过一个案例,攻击者用 %x 连续读取了200多个栈值,直接把固件的堆栈布局摸得一清二楚。为什么会这样?因为printf内部维护了一个指针,每遇到一个 % 就往后取一个参数——你给多少个 %,它就取多少个,直到栈溢出或崩溃。
4.2 任意地址读取:把内存当字典翻
任意地址读,靠的是 %s 和 %x 的组合。
基本思路:
- 先用
%x或%p泄露栈上的值,找到我们输入字符串在栈上的偏移位置。 - 把目标地址(比如某个关键变量的地址)写入输入字符串的开头。
- 用
%s配合偏移量,让printf把那个地址当作字符串指针来解引用。
举个例子:
// 假设输入缓冲区在栈上的偏移是第7个参数
// 目标:读取 0x20001000 处的4字节
// 构造payload
uint8_t payload[] = {
0x00, 0x10, 0x00, 0x20, // 目标地址(小端序)
'%', '7', '$', 's' // 读取第7个参数指向的字符串
};
printf看到 %7$s,就会把第7个参数当作 char*,然后从 0x20001000 开始读,直到遇到 \0。我在项目中用这招读取过另一个任务的栈数据——当时是为了调试一个诡异的死锁问题,结果发现是某个全局变量被意外覆盖了。
小技巧:在MCU上,%p 比 %x 更可靠。因为 %p 会输出指针的完整宽度(32位MCU输出8位十六进制),而 %x 默认只输出4位,容易漏掉高位。
4.3 任意地址写入:用%n改写命运
任意地址写,靠的是 %n。
%n 不输出任何字符,而是把已经输出的字符数写入到一个 int* 指针指向的地址。
攻击流程:
- 确定目标地址(比如函数指针表、返回地址、关键配置寄存器)。
- 把目标地址放在payload开头。
- 用
%Xc控制已输出的字符数,使其等于你想写入的值。 - 用
%N$n把该值写入第N个参数指向的地址。
看一个实际payload:
// 目标:向 0x20001000 写入 0x1234
// 假设输入在栈上偏移为第7个参数
// 构造payload
uint8_t payload[] = {
0x00, 0x10, 0x00, 0x20, // 目标地址
'%', '4', '6', '6', '0', 'c', // 先输出4660个字符(0x1234 = 4660)
'%', '7', '$', 'n' // 写入第7个参数指向的地址
};
这里有个坑:%n 写入的是 int 类型(4字节)。如果你想写入 short 或 byte,可以用 %hn(写入2字节)或 %hhn(写入1字节)。
我曾经踩过的坑:在Cortex-M3上,%n 写入的目标地址必须对齐到4字节,否则会触发HardFault。我调试了整整一个下午才发现是地址对齐的问题。所以,如果你要写非对齐地址,请用 %hhn 逐字节写入。
4.4 利用技巧:MCU环境下的特殊打法
MCU和PC上的格式化字符串利用,有几个关键区别:
| 对比项 | PC/Linux | MCU(ARM Cortex-M) |
|---|---|---|
| 栈布局 | 参数在栈上连续排列 | 参数可能通过寄存器传递(AAPCS) |
| 地址空间 | 虚拟地址,有ASLR | 物理地址,通常无ASLR |
| 可利用目标 | GOT表、返回地址 | 中断向量表、函数指针、关键寄存器 |
| 写入精度 | 通常用%n写4字节 | 推荐用%hhn逐字节写,避免对齐问题 |
MCU上的几个实用技巧:
- 利用中断向量表:改写某个中断向量,指向攻击代码。MCU的向量表通常在
0x00000000或0x20000000附近,地址固定。 - 改写RTOS任务控制块:如果固件跑FreeRTOS,可以改写某个任务的栈指针或入口函数,实现任务劫持。
- 利用UART输出:很多MCU的printf实现会把
%s输出到UART。你可以用任意地址读来dump整个Flash或RAM。
我个人的习惯:在审计固件时,我会先搜索所有 printf、sprintf、snprintf 的调用点,检查第一个参数是否来自外部输入。如果发现类似 printf(uart_rx_buffer) 的写法,基本可以判定为高危漏洞。
4.5 防护措施:从源头堵死
防护其实不复杂,但需要养成习惯:
- 永远使用格式化字符串:不要写
printf(user_input),要写printf("%s", user_input)。这是最根本的防护。 - 启用编译器警告:GCC的
-Wformat-security和-Wformat=2能检测出大部分非字面量的格式化字符串。 - 静态分析:用PC-lint、Coverity或开源工具(如Flawfinder)扫描代码库。
- 运行时检测:在MCU上,可以hook printf函数族,检查格式化字符串是否来自可信区域。
下面是一个简单的hook示例:
// 简单的printf hook,检测格式化字符串是否在Flash中
int safe_printf(const char *format, ...) {
// 检查format指针是否在Flash地址范围
if ((uint32_t)format < FLASH_BASE ||
(uint32_t)format > (FLASH_BASE + FLASH_SIZE)) {
// 格式化字符串来自RAM,可能是攻击
error_handler();
return -1;
}
va_list args;
va_start(args, format);
int ret = vprintf(format, args);
va_end(args);
return ret;
}
注意:上面的hook只是一个示例,不能完全防御。攻击者仍然可以通过 %n 写入,或者利用已经存在于Flash中的格式化字符串。更完善的方案是禁用 %n,或者使用 printf_chk 这类安全变体。
4.6 知识体系总览
下面这张图总结了格式化字符串漏洞的核心逻辑:
格式化字符串漏洞,说白了就是一个“信任问题”——你信任了用户的输入可以作为格式化字符串。在MCU固件中,这个漏洞的利用门槛其实比PC低,因为地址固定、没有ASLR、栈布局更容易预测。但防护手段也很直接:永远、永远、永远不要直接把外部输入丢给printf。养成这个习惯,就能堵住90%的问题。