内存保护机制:MPU/MMU配置实战,栈溢出防护(Canary),ASLR与NX位在MCU上的实现
各位同学,咱们今天聊点硬核的。内存保护,说白了就是给固件装上「防盗门」和「监控摄像头」。我见过太多IoT设备,固件里连最基本的内存保护都没开,攻击者随便一个缓冲区溢出就能拿到shell。嗯,这章咱们就把这些保护机制一个一个掰开揉碎了讲清楚。
4.1 MPU与MMU:MCU上的内存隔离
先搞清楚一个概念:MPU和MMU到底有什么区别?
| 特性 | MPU(内存保护单元) | MMU(内存管理单元) |
|---|---|---|
| 适用场景 | MCU(Cortex-M系列) | MPU(Cortex-A系列,Linux) |
| 核心功能 | 访问权限控制 | 虚拟地址映射 + 权限控制 |
| 地址空间 | 物理地址直接访问 | 虚拟地址到物理地址转换 |
| 典型配置 | 8-16个区域 | 页表 + TLB |
| OS依赖 | 裸机/RTOS | Linux/Android |
我个人习惯,在裸机或RTOS环境下,MPU是首选。为什么?因为它轻量、实时性好,而且配置起来没那么复杂。你想想看,一个智能灯泡的固件,你给它上MMU,那不是杀鸡用牛刀吗?
4.2 MPU配置实战:以Cortex-M4为例
我在项目中遇到过最典型的情况:某款工业传感器,固件跑在FreeRTOS上,任务栈和全局变量混在一起,一个野指针就能把整个系统搞崩。后来我加了MPU保护,问题迎刃而解。
MPU配置的核心步骤,其实就这几步:
- 使能MPU:设置MPU_CTRL寄存器,使能PRIVDEFENA(特权默认映射)
- 定义区域:每个区域由基址、大小、权限、子区域组成
- 设置属性:可读/写/执行、缓存策略、共享属性
- 使能区域:设置区域有效位,并重新使能MPU
来看一段实际代码:
// 配置MPU区域0:Flash区域(只读可执行)
MPU->RNR = 0; // 区域编号0
MPU->RBAR = (FLASH_BASE & 0xFFFFFFE0) | 0x1F; // 基址 + 区域有效
MPU->RASR = (0x00 << 0) | // 禁止子区域
(0x03 << 1) | // 全访问权限(特权+用户)
(0x01 << 3) | // 可执行
(0x02 << 5) | // 大小:2^5 = 32KB
(0x01 << 16); // 使能区域
// 配置区域1:SRAM区域(读写不可执行)
MPU->RNR = 1;
MPU->RBAR = (SRAM_BASE & 0xFFFFFFE0) | 0x1F;
MPU->RASR = (0x00 << 0) |
(0x03 << 1) |
(0x00 << 3) | // 不可执行(XN位)
(0x03 << 5) | // 大小:2^3 = 8KB
(0x01 << 16);
// 使能MPU
MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
__DSB();
__ISB();
4.3 栈溢出防护:Canary机制
栈溢出,这是固件安全的老大难问题。攻击者通过覆盖返回地址,就能劫持程序控制流。Canary(金丝雀)机制,就是在栈帧里放一个「哨兵值」,函数返回前检查它有没有被篡改。
Canary的实现原理其实不复杂:
- 函数入口处,将一个随机值压入栈帧底部
- 函数返回前,检查该值是否被修改
- 如果被修改,触发异常处理(通常是系统复位)
在GCC中,开启Canary只需要一个编译选项:
// 编译时添加
-fstack-protector-strong
// 或者更严格的
-fstack-protector-all
但要注意,MCU上实现Canary有几个坑:
- 随机种子来源:MCU没有硬件随机数发生器怎么办?可以用ADC噪声、未初始化SRAM内容,或者外部熵源
- Canary值存储:不能放在栈里,否则攻击者能读到。我一般放在全局变量区,并加上MPU只读保护
- 性能开销:每个函数增加约4-8字节栈空间,对资源受限的MCU来说,需要权衡
4.4 ASLR在MCU上的实现
ASLR(地址空间布局随机化),说白了就是让每次启动时,代码、数据、栈的基地址都不一样。攻击者想用固定的ROP链?没门。
但MCU上实现ASLR,比PC上难得多。为什么?因为MCU通常没有MMU,地址是固定的物理地址。那怎么办?我分享几个实战经验:
- 栈基址随机化:在启动代码中,将栈指针SP设置为随机偏移后的地址
- 堆基址随机化:在malloc实现中,加入随机偏移量
- 函数地址随机化:这个比较难,需要链接器支持位置无关代码(PIC)
来看一个栈随机化的实现片段:
// 启动代码中随机化栈地址
void __attribute__((naked)) Reset_Handler(void)
{
// 从ADC或未初始化内存获取随机值
uint32_t random = get_random_seed();
// 计算随机偏移(对齐到8字节)
uint32_t offset = (random & 0x3FF) << 3; // 最大2KB偏移
// 设置栈指针
__asm volatile(
"ldr r0, =_estack\n"
"sub r0, r0, %0\n"
"bic r0, r0, #7\n"
"mov sp, r0\n"
:
: "r"(offset)
: "r0"
);
// 继续正常启动流程
main();
}
核心要点:ASLR在MCU上的效果取决于随机熵源的质量。如果随机数生成器被预测,ASLR就形同虚设。我建议至少使用两个独立熵源混合,比如ADC噪声 + 内部温度传感器读数。
4.5 NX位:让数据区不可执行
NX(Never eXecute)位,在ARM Cortex-M上叫XN(eXecute Never)。它的作用很简单:标记某块内存区域不允许执行代码。这样攻击者即使把shellcode写进了数据区,也没法执行。
在Cortex-M3/M4上,MPU的RASR寄存器里就有XN位。配置方法前面已经展示过了。但要注意,Cortex-M0/M0+没有MPU,怎么办?
嗯,这里有个替代方案:利用系统控制块(SCB)的CCR寄存器中的STKALIGN位和DIV_0_TRP位,虽然不能完全实现NX,但可以增加攻击难度。不过说实话,对于M0系列,我更推荐用软件方法:
- 在函数指针调用前,检查目标地址是否在Flash范围内
- 使用位带(Bit-banding)区域标记关键数据
- 配合看门狗,检测异常执行流
4.6 综合配置:一个完整的内存保护方案
光有单个机制不够,得组合起来。我画了一张图,展示这些机制如何协同工作:
你看,这三层保护是叠加的。攻击者想攻破,得同时绕过Canary、猜对ASLR的随机偏移、还要突破MPU的权限控制。难度呈指数级上升。
4.7 实战避坑指南
最后,我总结几个实战中容易踩的坑:
- Canary值泄露:如果固件有调试接口(如SWD/JTAG),攻击者可以直接读出Canary值。量产固件一定要关闭调试接口
- MPU区域重叠:多个MPU区域重叠时,优先级高的区域(编号小的)生效。配置时一定要检查有没有意外覆盖
- ASLR与RTOS的兼容性:有些RTOS的任务栈地址是静态分配的,强行随机化可能导致任务切换异常。需要修改RTOS内核代码
- 性能影响:MPU每次内存访问都会检查权限,大约增加2-3个时钟周期。对实时性要求高的中断服务函数,建议放在不受MPU保护的区域