(此图由GorgonMeducer借助GPT4进行一系列关键词调校后生成)
【说在前面的话】
在近几年的嵌入式社区中,流传着不少关于面相Cortex-M的Bootloader科普文章,借助这些文章,一些较为经典的代码片断和技巧得到了广泛的传播。
在从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开发所需的“热知识”:
从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。总结来说,Bootloader在跳转到App之前需要做两件事:
一般来说,做到上述两点,就可以实现App将Bootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。
如何在App中加入“跳床(trumpline)”值得专门再写一篇独立的文章,不是本文所需关注的重点,请允许我暂且略过。
这里,“清理房间”的步骤与Bootloader具体“弄脏了什么”(或者说使用了什么资源)有关;而“模拟处理器硬件的一些复位行为”就较为简单和具体:即,从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);
5. 通过函数指针完成从Bootloader到App的跳转:
Jump_To_Application = (pFunction)ResetVector;
Jump_To_Application();
其实,无论具体的代码如何,只要实现步骤与上述类似,就存在一个隐藏较深的漏洞,而漏洞的“触发与否”则完全“看脸”——简单来说:只要你是按照上述方法来实现从Bootloader到App的跳转的,那么就一定存在问题——而“似乎可以正常工作”就只是你运气较好,或者“由此引发的问题暂时未能引发注意”罢了。
在你试图争辩“老子代码已经量产了也没有什么不妥”之前,我们先来看看漏洞的原理是什么——在知其所以然后,如何评估风险就是你们自己的事情了。
【C语言基础设施是什么】
在前面的一篇文章《大白话说嵌入式安全(1)》中我们曾经提到过:嵌入式系统的信息安全(Security)建立在基础设施安全(Safety)的基础之上。由于“确保信息安全的很多机制”本质上是一套建立在“基础设施能够正常工作”这一前提之上的规则和逻辑,因此很多针对信息安全的攻击往往会绕开信息安全的“马奇诺防线”,转而攻击基础设施。
芯片数字逻辑的基础设施是时钟源、供电、总线时序、复位时序等等,因此,针对硬件基础设施的攻击通常也就是针对时钟源、电源、总线时序和复位时序的攻击。
此时,好奇的小伙伴会产生疑问:固件一般由C语言进行编写,那么C语言所依赖的基础设施又是什么呢?
对C语言编译器来说,栈的作用是无可替代的:
可以说,离开了栈C语言寸步难行。因此对很多芯片来说,复位后为了执行用户使用C语言编译的代码,第一个步骤就是要实现栈的初始化。
作为一个有趣的“冷知识”,Cortex-M在宣传中一直强调自己“支持完全使用C语言进行开发”,这让很多人“丈二和尚摸不着头脑”甚至觉得“非常可笑”——因为这年月连51都支持用户使用C语言进行开发了,你这里说的“Cortex-M支持使用C语言进行开发”有什么意义呢?
其实门道就在这里:
这种从复位一开始就完全不需要汇编介入的友好环境才是Cortex-M声称自己“支持完全使用C语言进行开发”的真实意义和底气。从这一角度出发,只要某个芯片架构复位后必须要通过软件来初始化栈顶指针,就不符合“从出生的那一刻就可以使用C语言”的基本要求。
【C语言编译器的约定】
栈对C语言来说如此重要,以至于编译器一直有一条默认的约定,即:
栈必须完全交由C语言编译器进行管理(或者用户对栈的操作必须符合对应平台所提供的调用规约,比如Arm的AAPCS规约)。
简而言之,如果你“偷偷摸摸”的修改了栈顶指针,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了吧?!
【问题的分析】
从原理上说,开篇那个典型的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
注意这里,StackAddr、ResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将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);
后面的 __set_MSP(StackAddr) 所对应的汇编代码 MSR MSR,r1 居然插入到了ResetVector赋值语句的中间?!
“C语言编译器这么自由的么?”
“在我使用sp之前把栈顶指针更新了?!”
先别激动,还记得我们和C语言编译器之间的约定么?C语言编译器默认我们在任何时候都不应该修改栈顶指针。因此在他看来,
“你 MSR 指令操作的是r1,关我sp和r0啥事”?
“我就算随意更改顺序应该对你一毛钱影响都没有!(因为我不关心、也没法知道用户线汇编语句的具体效果,因此我只关心涉事的通用寄存器是否存在冲突)”
上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果:
精彩的还在后面:
Jump_To_Application = (pFunction)ResetVector;
Jump_To_Application();
对应的翻译是:
00001e b002 ADD sp,sp,#8
000020 4700 BX r0
通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从Bootloader到APP的跳转——也许你会松一口气——好像局部变量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还是地址空间绕回)就很有可能产生灾难性的后果。
【宏观分析】
就事论事的讲,单从汇编分析来看,上述代码所产生的风险似乎是可控的,甚至某些人会觉得可以“忽略不计”。但最可怕的也就在这里,原因如下:
(此图由GorgonMeducer借助GPT4进行一系列关键词调校、配上台词后获得)
【解决方案】
既然我们知道不能对上述缺陷代码抱有侥幸心理,该如何妥善解决呢?
第一个思路:既然问题是由栈导致的,那么直接让编译器用通用寄存器来保存关键局部变量不就行了?修改代码为:
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都要全数消灭的团队来说,上述方案多半也是不可容忍的。
第三个思路:完全用汇编来处理从Bootloader到App的最后步骤。对此我只想说:稳定可靠,正解。只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。
【说在后面的话】
在使用栈的情况下,on-fly 的修改栈顶指针就好比在飞行途中更换引擎——不是不行,只是要求有亿点点高。
我在微信群中帮读者分析各类Bootloader的见鬼故障时,经常在大费周章的一通分析和调试后,发现问题的罪魁祸首就是跳转代码。可怕的是,几乎每个故障的具体现象都各不相同,表现出的随机性也常常让人怀疑是不是硬件本身存在问题,亦或是产品工作现场的电磁环境较为恶劣。最要命的当数那种“偶尔出现”而复现条件颇为玄学的情形,甚至在办公室环境下完全无法重现的也大有人在。
同样的问题出的多了,我几乎在每次帮人调试Bootloader时都会习惯性的先要求检查跳转代码——虽然不会每次都能猜个正着,但也有个恐怖的十之七八。这也许是某种幸存者偏差吧——毕竟大部分普通问题大家自己总能解决,到我这里的多半就是“驱鬼”了。
见得多了,我突然发现,出问题的代码大多使用函数指针来实现跳转——而用局部变量来保存函数指针又成了大家自然而然的选择。加之此前很多文章都曾大规模科普上述技巧,甚至是直接包含一些存在缺陷的Bootloader范例代码,实际受影响的范围真是“细思恐极”。
特此撰文,为您解惑。