傻孩子(GorgonMeducer) · 2023年12月07日

这个隐藏的Bootloader漏洞究竟有多少人中招?

image.png
(此图由GorgonMeducer借助GPT4进行一系列关键词调校后生成)

【说在前面的话】

在近几年的嵌入式社区中,流传着不少关于面相Cortex-MBootloader科普文章,借助这些文章,一些较为经典的代码片断和技巧得到了广泛的传播。

在从Bootloader跳转到用户APP的过程中,使用函数指针而非传统的汇编代码则成了一个家喻户晓的小技巧。相信类似下面 JumpToApp() 函数,你一定不会感到陌生:


typedef  void (*pFunction)(void);

void JumpToApp(uint32_t addr)
{
  pFunction Jump_To_Application;

  __IO uint32_t StackAddr;
  __IO uint32_t ResetVector;
  __IO uint32_t JumpMask;

  JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);

  if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ��
  {
    StackAddr = *(__IO uint32_t*)addr;
    ResetVector = *(__IO uint32_t *)(addr + 4);

    __set_MSP(StackAddr); 
    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 
  }
}

为了读懂这段代码,需要一些从事Cortex-M开发所需的“热知识”:

  • 向量表是一个由 32bit 数据构成的数组
  • 数组的第一个元素是 uintptr_t 类型的指针,保存着复位后主栈顶指针(MSP)的初始值。
  • 从数组第二个元素开始,保存的是 (void ( * )(void)) 类型的异常处理程序地址(BIT0固定为1,表示异常处理程序使用Thumb指令集进行编码)
  • 数组的第二个元素保存的是复位异常处理程序的地址(Reset_Handler

从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。总结来说,Bootloader在跳转到App之前需要做两件事:

  1. 清理房间——仿佛Bootloader从未执行过一样
  2. 模拟处理器的硬件的一些复位行为——假装芯片从复位开始就直接从APP开始执行

一般来说,做到上述两点,就可以实现AppBootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。

如何在App中加入“跳床(trumpline)”值得专门再写一篇独立的文章,不是本文所需关注的重点,请允许我暂且略过。

这里,“清理房间”的步骤与Bootloader具体“弄脏了什么”(或者说使用了什么资源)有关;而“模拟处理器硬件的一些复位行为”就较为简单和具体:即,从Bootloader跳转到App前的最后两个步骤为:

  1. 从APP的向量表中读取MSP的初始值并以此来初始化MSP寄存器;
  2. 从APP的向量表中读取Reset_Handler的值,并跳转到其中去执行——完成从Bootloader到APP的权利交接。

结合前面的例子代码,值得我们关注的部分是:

  1. 使用自定义的函数指针类型 pFunction 定义一个局部变量
pFunction Jump_To_Application;

2. 根据向量表的首地址 addr 读取第一个元素——作为MSP的初始值暂时保存在局部变量 StackAddr 中:

StackAddr = *(__IO uint32_t*)addr;

3. 根据向量表的首地址 addr 读取第二个元素——将Reset_Handler的首地址保存到局部变量 ResetVector 中:

ResetVector = *(__IO uint32_t *)(addr + 4);

4. 设置栈顶指针MSP寄存器:

__set_MSP(StackAddr);
  1. 通过函数指针完成从BootloaderApp的跳转:
    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application();

其实,无论具体的代码如何,只要实现步骤与上述类似,就存在一个隐藏较深的漏洞,而漏洞的“触发与否”则完全“看脸”——简单来说:只要你是按照上述方法来实现从Bootloader到App的跳转的,那么就一定存在问题——而“似乎可以正常工作”就只是你运气较好,或者“由此引发的问题暂时未能引发注意”罢了。

在你试图争辩“老子代码已经量产了也没有什么不妥”之前,我们先来看看漏洞的原理是什么——在知其所以然后,如何评估风险就是你们自己的事情了。

【C语言基础设施是什么】

在前面的一篇文章《大白话说嵌入式安全(1)》中我们曾经提到过:嵌入式系统的信息安全(Security)建立在基础设施安全(Safety)的基础之上。由于“确保信息安全的很多机制”本质上是一套建立在“基础设施能够正常工作”这一前提之上的规则和逻辑,因此很多针对信息安全的攻击往往会绕开信息安全的“马奇诺防线”,转而攻击基础设施。

芯片数字逻辑的基础设施是时钟源、供电、总线时序、复位时序等等,因此,针对硬件基础设施的攻击通常也就是针对时钟源、电源、总线时序和复位时序的攻击。

此时,好奇的小伙伴会产生疑问:固件一般由C语言进行编写,那么C语言所依赖的基础设施又是什么呢?

image.png

对C语言编译器来说,栈的作用是无可替代的:

  • 函数调用
  • 函数间的参数传递
  • 分配局部变量
  • 暂时保存通用寄存器中的内容
  • ……

可以说,离开了栈C语言寸步难行。因此对很多芯片来说,复位后为了执行用户使用C语言编译的代码,第一个步骤就是要实现栈的初始化

作为一个有趣的“冷知识”,Cortex-M在宣传中一直强调自己“支持完全使用C语言进行开发”,这让很多人“丈二和尚摸不着头脑”甚至觉得“非常可笑”——因为这年月连51都支持用户使用C语言进行开发了,你这里说的“Cortex-M支持使用C语言进行开发”有什么意义呢?

其实门道就在这里:

  • 由于Cortex-M处理器会在复位时由硬件完成对C语言基础设施(也就是栈顶指针MSP)的初始化,因此无论是理论上还是实践中,从复位异常处理程序Reset_Handler开始用户就可以完全可以使用C语言进行开发了,而整个启动代码(startup)也可以全然不涉及任何汇编;
  • 由于Cortex-M的向量表是一个完全由 32位整数(uintptr_t)构成的数组——保存的都是地址而非具体代码,可以使用C语言的数据结构直接进行描述——因此也完全不需要汇编语言的介入。

这种从复位一开始就完全不需要汇编介入的友好环境才是Cortex-M声称自己“支持完全使用C语言进行开发”的真实意义和底气。从这一角度出发,只要某个芯片架构复位后必须要通过软件来初始化栈顶指针,就不符合“从出生的那一刻就可以使用C语言”的基本要求。

image.png

【C语言编译器的约定】

栈对C语言来说如此重要,以至于编译器一直有一条默认的约定,即:

栈必须完全交由C语言编译器进行管理(或者用户对栈的操作必须符合对应平台所提供的调用规约,比如ArmAAPCS规约)。

简而言之,如果你“偷偷摸摸”的修改了栈顶指针,C语言编译器是会“假装”完全不知道的,而此时所产生的后果C语言编译器会默认自己完全不用负责。 回头再看这段代码:

    StackAddr = *(__IO uint32_t*)addr;
    ResetVector = *(__IO uint32_t *)(addr + 4);

    __set_MSP(StackAddr); 

    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application();

虽然我们觉得自己“正大光明”的使用了 __set_MSP() 来修改了栈顶指针,但它实际上是一段C语言编译器并不理解其具体功能的在线汇编——在编译器看来,无论是谁提供的 __set_MSP(),只要是在线汇编,这就算是用户代码——是编译器管不到的地带。

/**
  \brief   Set Priority Mask
  \details Assigns the given value to the Priority Mask Register.
  \param [in]    priMask  Priority Mask
 */
__STATIC_FORCEINLINE void __set_PRIMASK(uint32_t priMask)
{
  __ASM volatile ("MSR primask, %0" : : "r" (priMask) : "memory");
}

或者说:C语言编译器一般情况下会默认你“无论如何都不会修改栈顶指针”——它不仅管不着,也不想管

从这点来看,上述代码的确打破了这份约定。即便如此,很多小伙伴会心理倔强的认为:我就这么改了,怎么DE了吧?!

image.png

【问题的分析】

从原理上说,开篇那个典型的Bootloader跳转代码所存在的问题已经昭然若揭:

typedef  void (*pFunction)(void);

void JumpToApp(uint32_t addr)
{
  pFunction Jump_To_Application;

  __IO uint32_t StackAddr;
  __IO uint32_t ResetVector;
  __IO uint32_t JumpMask;

  JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);

  if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ��
  {
    StackAddr = *(__IO uint32_t*)addr;
    ResetVector = *(__IO uint32_t *)(addr + 4);

    __set_MSP(StackAddr); 
    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 
   }
}

我们不妨结合上述代码反汇编的结果进行深入解析:

        AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2
                  JumpToApp PROC
000000  b082              SUB      sp,sp,#8
000002  4909              LDR      r1,|L2.40|
000004  9100              STR      r1,[sp,#0]
000006  6802              LDR      r2,[r0,#0]
000008  400a              ANDS     r2,r2,r1
00000a  2101              MOVS     r1,#1
00000c  0749              LSLS     r1,r1,#29
00000e  428a              CMP      r2,r1
000010  d107              BNE      |L2.34|
000012  6801              LDR      r1,[r0,#0]
000014  9100              STR      r1,[sp,#0]
000016  6840              LDR      r0,[r0,#4]
000018  f3818808          MSR      MSP,r1
00001c  9001              STR      r0,[sp,#4]
00001e  b002              ADD      sp,sp,#8
000020  4700              BX       r0
                  |L2.34|
000022  b002              ADD      sp,sp,#8
000024  4770              BX       lr
                          ENDP

000026  0000              DCW      0x0000
                  |L2.40|
                          DCD      0x2fff0000

注意这里,StackAddrResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将SP指针向栈底挪动8个字节就是这个意思:

000000  b082              SUB      sp,sp,#8

虽然 JumpMask 也是局部变量,但编译器根据自己判断认为它“命不久矣”,因此直接将它分配到了通用寄存器r2中,并配合r1和sp完成了后续运算。这里:

   __IO uint32_t JumpMask;

  JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);

if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ��
  {
      ...
  }

对应:

000002  4909              LDR      r1,|L2.40|
000004  9100              STR      r1,[sp,#0]
000006  6802              LDR      r2,[r0,#0]
000008  400a              ANDS     r2,r2,r1
00000a  2101              MOVS     r1,#1
00000c  0749              LSLS     r1,r1,#29
00000e  428a              CMP      r2,r1
000010  d107              BNE      |L2.34|
...
|L2.34|
000022  b002              ADD      sp,sp,#8
000024  4770              BX       lr
ENDP

000026  0000              DCW      0x0000
|L2.40|
DCD      0x2fff0000

考虑到JumpMask的内容与本文无关,不妨暂且跳过。

接下来就是重头戏了:

编译器按照用户的指示读取栈顶指针MSP的初始值,并保存在StackAddr中:

StackAddr = *(__IO uint32_t*)addr;

对应的汇编是:

000012  6801              LDR      r1,[r0,#0]
000014  9100              STR      r1,[sp,#0]

根据Arm的AAPCS调用规约,编译器在调用函数时会使用R0~R3来传递前4个符合条件的参数(这里的条件可以简单理解为每个参数的宽度要小于等于32bit)。根据函数原型

void JumpToApp(uint32_t addr);

可知,r0 中保存的就是形参 addr 的值。所以第一句汇编的意思就是:根据 (addr + 0)作为地址读取一个uint32_t型的数据保存到r1中。

第二句汇编中,栈顶指针sp此时实际上指向局部变量 StackAddr,因此其含义就是将通用寄存器r1中的值保存到局部变量 StackAddr 中。

对于局部变量 ResetVector 的读取操作,编译器的处理如出一辙:

ResetVector = *(__IO uint32_t *)(addr + 4);

对应:


000016  6840              LDR      r0,[r0,#4]
00001c  9001              STR      r0,[sp,#4]

其实就是从 (addr + 4) 的位置读取 32bit 整数,然后保存到r0里,并随即保存到sp所指向的局部变量 ResetVector 中。到这里,细心地小伙伴会立即跳起来说“不对啊,原文不是这样的!”。是的,这也是最有趣的地方。实际的汇编原文如下:


000016  6840              LDR      r0,[r0,#4]
000018  f3818808          MSR      MSP,r1
00001c  9001              STR      r0,[sp,#4]

作为提醒,它对应的C代码如下:

    ResetVector = *(__IO uint32_t *)(addr + 4);
    __set_MSP(StackAddr);

image.png

后面的 __set_MSP(StackAddr) 所对应的汇编代码 MSR MSR,r1 居然插入到了ResetVector赋值语句的中间?!

“C语言编译器这么自由的么?”
“在我使用sp之前把栈顶指针更新了?!”

image.png
先别激动,还记得我们和C语言编译器之间的约定么?C语言编译器默认我们在任何时候都不应该修改栈顶指针。因此在他看来,

“你 MSR 指令操作的是r1,关我sp和r0啥事”?
“我就算随意更改顺序应该对你一毛钱影响都没有!(因为我不关心、也没法知道用户线汇编语句的具体效果,因此我只关心涉事的通用寄存器是否存在冲突)”

上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果:

  • 由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+4)实际上已经超出栈的合法范围了。这一操作与其说是会覆盖栈后续的存储空间,倒不如说风险主要体现在BusFault上——因为相当一部分人习惯将栈放到SRAM的最末尾,而MSP+4直接超出SRAM的有效范围
  • 我们以为的ResetVector其实已经不在原本C编译器所安排的地址上了。

精彩的还在后面:

    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 

对应的翻译是:


00001e  b002              ADD      sp,sp,#8
000020  4700              BX       r0

通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从BootloaderAPP的跳转——也许你会松一口气——好像局部变量ResetVector的错位也没引起严重的后果嘛。

看似如此,但真正吓人的是C语言编译器随后对局部变量的释放:

00001e  b002              ADD      sp,sp,#8

它与一开始局部变量的分配形成呼应:

000000  b082              SUB      sp,sp,#8
...
00001e  b002              ADD      sp,sp,#8

好借好还,再借不难。但此sp非彼sp了呀!

这里由于JumpToApp没有加上__NO_RETURN的修饰,因此C编译器并不知道这个函数是有去无回的,因此仍然会像往常一样在函数退出时释放局部变量。

就像刚才分析的那样:由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+8)实际上已经超出栈存储空间的合法范围了。考虑到相当一部分人习惯将栈放到SRAM的最末尾,而MSP+8直接超出SRAM的有效范围,即便刚跳转到APP的时候还不会有事,但凡APP用了任何压栈操作,(无论是BusFault还是地址空间绕回)就很有可能产生灾难性的后果。

【宏观分析】

就事论事的讲,单从汇编分析来看,上述代码所产生的风险似乎是可控的,甚至某些人会觉得可以“忽略不计”。但最可怕的也就在这里,原因如下:

  • 从原理上说,将关键信息保存在依赖栈的局部变量中,然后在编译器不知情的情况下替换了栈所在的位置,此后只要产生对相关局部变量的访问就有可能出现“刻舟求剑”的数据错误。这种问题是“系统性的”、“原理性的”。

image.png
(此图由GorgonMeducer借助GPT4进行一系列关键词调校、配上台词后获得)

  • 不同编译器、同一编译器的不同版本、同一版本的不同优化选项都有可能对同一段C语言代码产生不同的编译结果,因此哪怕我们经过上述分析得出某一段汇编代码似乎不会产生特别严重的后果,在严谨的工程实践上,这也只能算做是“侥幸”,是埋下了一颗不知道什么时候以什么方式引爆的定时炸弹。
  • 根据用户Bootloader代码在修改 MSP 前后对局部变量的使用情况不同、考虑到用户APP行为的不确定性、由上述缺陷代码所产生的Bootloader与APP之间配合问题的组合多种多样、由于涉及到用户栈顶指针位置的不确定性以及新的栈存储器空间中内容的随机性,最终体现出来的现象也是完全随机的。用人话说就是,经常性的“活见鬼”

image.png

【解决方案】

既然我们知道不能对上述缺陷代码抱有侥幸心理,该如何妥善解决呢?

第一个思路:既然问题是由栈导致的,那么直接让编译器用通用寄存器来保存关键局部变量不就行了?修改代码为:

typedef  void (*pFunction)(void);

void JumpToApp(uint32_t addr)
{
  pFunction Jump_To_Application;

  register uint32_t StackAddr;
  register uint32_t ResetVector;
  register uint32_t JumpMask;

  JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);

  if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ��
  {
    StackAddr = *(__IO uint32_t*)addr;
    ResetVector = *(__IO uint32_t *)(addr + 4);

    __set_MSP(StackAddr); 
    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 
  }
}

相同编译环境下得出的结果为:


    AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2
                  JumpToApp PROC

000002  6801              LDR      r1,[r0,#0]
000004  4011              ANDS     r1,r1,r2
000006  2201              MOVS     r2,#1
000008  0752              LSLS     r2,r2,#29
00000a  4291              CMP      r1,r2
00000c  d104              BNE      |L2.24|

00000e  6801              LDR      r1,[r0,#0]
000010  6840              LDR      r0,[r0,#4]
000012  f3818808          MSR      MSP,r1

000016  4700              BX       r0
                  |L2.24|
000018  4770              BX       lr
                          ENDP

00001a  0000              DCW      0x0000
                  |L2.28|
                          DCD      0x2fff0000

可见,上述汇编中半个 sp 的影子都没看到,问题算是得到了解决。然而,需要注意的是 register 关键字对编译器来说只是一个“建议”,它听不听你的还不一定。加之上述例子代码本身相当简单,涉及到的局部变量数量有限,因此问题似乎得到了解决。倘若编译器发现你大量使用 register 关键字导致实际可用的通用寄存器数量入不敷出,大概率还是会用栈来进行过渡的——此时,哪些局部变量用栈,哪些用通用寄存器就完全看编译器的心情了。进一步的,不同编译器、不同版本、不同优化选项又会带来大量不可控的变数。因此就算使用 register 修饰关键局部变量的方法可以救一时之疾(“只怪老板催我催得紧,莫怪我走后洪水滔天”),也算不得妥当

第二个思路:既然问题出在局部变量上,我用静态(或者全局)变量不就可以了?修改源代码为:


#include "cmsis_compiler.h"

typedef  void (*pFunction)(void);

__NO_RETURN
void JumpToApp(uint32_t addr)
{
  pFunction Jump_To_Application;

  static uint32_t StackAddr;
  static uint32_t ResetVector;
  register uint32_t JumpMask;

  JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);

  if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ��
  {
    StackAddr = *(__IO uint32_t*)addr;
    ResetVector = *(__IO uint32_t *)(addr + 4);

    __set_MSP(StackAddr); 
    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 
  }
}

这种方法看似稳如老狗,实际效果可能也不差,但还是存在隐患,因为它“没有完全杜绝编译器会使用栈的情况”,只要我们还会通过 __set_MSP() 在C语言编译器不知道的情况下更新栈顶指针,风险自始至终都是存在的。对某些连warning都要全数消灭的团队来说,上述方案多半也是不可容忍的。

第三个思路:完全用汇编来处理从BootloaderApp的最后步骤。对此我只想说:稳定可靠,正解。只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。

image.png

【说在后面的话】

在使用栈的情况下,on-fly 的修改栈顶指针就好比在飞行途中更换引擎——不是不行,只是要求有亿点点高。

我在微信群中帮读者分析各类Bootloader的见鬼故障时,经常在大费周章的一通分析和调试后,发现问题的罪魁祸首就是跳转代码。可怕的是,几乎每个故障的具体现象都各不相同,表现出的随机性也常常让人怀疑是不是硬件本身存在问题,亦或是产品工作现场的电磁环境较为恶劣。最要命的当数那种“偶尔出现”而复现条件颇为玄学的情形,甚至在办公室环境下完全无法重现的也大有人在。

同样的问题出的多了,我几乎在每次帮人调试Bootloader时都会习惯性的先要求检查跳转代码——虽然不会每次都能猜个正着,但也有个恐怖的十之七八。这也许是某种幸存者偏差吧——毕竟大部分普通问题大家自己总能解决,到我这里的多半就是“驱鬼”了。

见得多了,我突然发现,出问题的代码大多使用函数指针来实现跳转——而用局部变量来保存函数指针又成了大家自然而然的选择。加之此前很多文章都曾大规模科普上述技巧,甚至是直接包含一些存在缺陷的Bootloader范例代码,实际受影响的范围真是“细思恐极”。

特此撰文,为您解惑。

原文:裸机思维
作者:GorgonMeducer 傻孩子

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
1480
内容数
118
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息