内存保护机制: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配置的核心步骤,其实就这几步:

  1. 使能MPU:设置MPU_CTRL寄存器,使能PRIVDEFENA(特权默认映射)
  2. 定义区域:每个区域由基址、大小、权限、子区域组成
  3. 设置属性:可读/写/执行、缓存策略、共享属性
  4. 使能区域:设置区域有效位,并重新使能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();
注意:配置MPU时,一定要先禁能MPU再修改区域配置,否则可能触发HardFault。我曾经因为忘了加__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来说,需要权衡
小技巧:如果MCU资源紧张,可以只对关键函数(如处理网络数据、解析命令的函数)开启Canary,用__attribute__((no_stack_protector))排除非关键函数。

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 综合配置:一个完整的内存保护方案

光有单个机制不够,得组合起来。我画了一张图,展示这些机制如何协同工作:

MCU内存保护体系架构 应用层(用户代码) Canary栈保护 函数入口/出口检查 ASLR地址随机化 栈/堆基址随机偏移 MPU内存保护单元 Flash: 只读可执行 | SRAM: 读写不可执行 | 外设: 只读 硬件层(Cortex-M内核 + 内存控制器) 说明:攻击者需要同时绕过Canary、ASLR和MPU三层保护,才能成功利用漏洞

你看,这三层保护是叠加的。攻击者想攻破,得同时绕过Canary、猜对ASLR的随机偏移、还要突破MPU的权限控制。难度呈指数级上升。

4.7 实战避坑指南

最后,我总结几个实战中容易踩的坑:

  • Canary值泄露:如果固件有调试接口(如SWD/JTAG),攻击者可以直接读出Canary值。量产固件一定要关闭调试接口
  • MPU区域重叠:多个MPU区域重叠时,优先级高的区域(编号小的)生效。配置时一定要检查有没有意外覆盖
  • ASLR与RTOS的兼容性:有些RTOS的任务栈地址是静态分配的,强行随机化可能导致任务切换异常。需要修改RTOS内核代码
  • 性能影响:MPU每次内存访问都会检查权限,大约增加2-3个时钟周期。对实时性要求高的中断服务函数,建议放在不受MPU保护的区域
重要提醒:内存保护不是银弹。它只能提高攻击门槛,不能完全杜绝攻击。配合安全启动、加密存储、安全更新等其他机制,才能构建真正的纵深防御体系。