怎么更好的编写ARMv8-M TrustZone安全代码
本文翻译自intricacies of writing ARMv8-M Secure code
ARM现在已经通过ARMv8-M安全扩展将TrustZone引入到了M系列处理器,TrustZone是一种将重要的代码和数据同普通代码和数据相隔离的一种技术。这种技术可以限制危险或者可利用代码的权限。也可以保证重要的数据和代码与一般代码分离。所以这种技术也依赖Secure代码的可靠性,要想写出可靠的Secure代码,还是有一些地方需要着重注意的。比如很多安全漏洞都是因为没有做必要的指针检查。
在之前的一篇博文Useful tips for developing secure software on ARMv8-M中,我们简单介绍了编写Secure的一些规则。而这篇博文我们将会更深入的探讨如果正确的编写Secure代码。首先我们先来看看如何开始编写Secure和Non-Secure代码。
如何运行Secure和Non-Secure代码
ARMv8-M架构中,Secure和Non-Secure是处理器的两种不同运行状态。而处理器的地址空间也会被划分为Secure和Non-Secure区域。位于Secure地址空间的代码成为Secure代码,相应的位于Non-Secure空间的代码称为Non-Secure代码。除了代码存放位置有区别意外,两种代码没有其它的区别。ARMv8-M架构中,关键的是Secure和Non-Secure状态之间的转换。我们来看看具体的转换过程。
首先,我们来看一个简单的C程序。如下的函数主要完成一个计算数组元素和的功能。
int sum(int *p, size_t s) {
int ret = 0;
for (size_t i = 0; i < s; i++) {
ret += p[i];
}
return ret;
}
这个函数实际比较简单,也不牵扯Secure相关操作,理论上没有必要将这个函数归入secure代码,不过这里为了演示原理,还是将其配置为Secure代码。如果只是作为Secure的子函数,那么直接Link到Secure空间就可以了。但是,如果这个函数还要支持Non-Secure调用,那么我们需要指导编译器如何编译这段代码。这个时候可以通过给这个函数添加cmse_nonsecure_entry
属性来告诉编译器,这是一个Non-Secure调用函数。一般情况下,为了区分这些函数,我们建议给这种调用函数添加sec_
前缀。于是,我们得到了下面的函数定义,这个时候,如果函数实现没有变化,我们实际上已经不经意间将所有Secure空间暴露给了Non-Secure代码,也就是说这个代码是有安全问题的。
int __attribute__((cmse_nonsecure_entry))
sec_sum(int *p, size_t s) {
...
}
现在我们来看看怎么在Non-Secure状态下调用sec_sum
函数。实际上,Non-Secure在使用函数的时候,可以不关心函数是否为Secure函数,比如在Non-Secure代码的Main函数调用sec_sum
函数:
extern int sec_sum(int *, size_t);
int main() { // non-secure main
int p[256];
// ... initialise the array
printf("%d\n", sec_sum(p, 256));
return 0;
}
看起来是不是非常简单?不过,我们怎么才能进到Non-Secure状态呢?一般来说,处理器在启动的时候默认会进到Secure状态,最开始从Secure状态切换到Non-Secure状态时候稍微有点复杂,下面是Secure代码的Main函数,我们来看看状态是如何切换的:
#include <stdio.h>
#include "CMSDK_ARMv8MML.h"
typedef int __attribute__((cmse_nonsecure_call)) nsfunc(void);
int nonsecure_init() {
SCB_NS->VTOR=0x10000000;
uint32_t *vtor = (uint32_t *) 0x10000000;
__TZ_set_MSP_NS(vtor[0]);
nsfunc *ns_reset = (nsfunc*)(vtor[1]);
ns_reset();
}
int main() {
nonsecure_init();
printf(“ERROR:Should not return here!\n”);
return 0;
}
我们来分析一下上面的这段代码。代码首先定义了nsfunc
的函数类型,这种函数类型有个一cmse_nonsecure_call
属性,main
函数通过nonsecure_init
实现到Non-Secure状态的跳转。
在第7行,我们会设置Non-Secure的中断向量表基址。中断向量表依然包含所有中断跳转的地址信息(注意Secure中断处理函数和Non-Secure中断处理函数不同)。对于Non-Secure中断基址的配置并不是必需的,因为这部操作有些硬件已经实现,而地址的设定也根据不同器件不尽相同。
在第10行,我们将中断向量表的第一个元素值作为Non-Secure堆栈的初始值,因为Secure和Non-Secure的堆栈是分开的,分别位于Secure空间和Non-Secure空间。
第12行,我们会取出中断向量表的第二个元素值,它一般是一个复位函数入口。我们将这个值强制转换为带有cmse_nonsecure_call
属性的函数并拷贝到本地变量。最终在13行,我们调用这个入口函数,实现了到Non-Secure状态的切换。
好了,接下来我们再来看看实际代码运行过程中两种状态的转换。
内存分区
在前面部分,我们曾简单提起过内存的分区,现在我们来详细看看这部分内容。
ARMv8-M架构下,内存主要分为Secure、Non-Secure和Secure Non-Secure Callable区域。显然代码位于Secure时候只能在CPU位于Secure状态执行,相应的Non-Secure状态只能执行Non-Secure代码。唯一例外的是Secure Non-Secure Callable
区域,这部分区域的代码在Non-Secure和Secure状态下都可以调用,但是只能用于从Non-Secure状态到Secure的切换。如果CPU的状态和当前执行代码区域属性不匹配,系统会产生HardFault
,所以Secure代码只能在Secure状态执行,Non-Secure代码也只能在Non-Secure状态执行。
CPU状态的切换是通过一些特殊指令实现的。比如从Secure到Non-Secure状态切换就比较简单:通过特殊的跳转指令(BXNS或者BLXNS),Secure代码可以跳转到NS空间的任意地址,当然在跳转之前Secure代码要负责清除相关寄存器中的数据。
而从Non-Secure状态切换到Secure状态则会有些复杂,因为我们要防止NS代码跳转到Secure空间的任意地址。正因为如此,我们引入了一个Secure Non-Secure Callable
区域,这个区域是Non-Secure跳转到Secure状态的唯一途径。Non-Secure跳转到Secure状态时,首先会执行一个SG指令(该指令实现CPU状态切换),然后是一个跳转指令。对于含有cmse_nonsecure_entry
属性的函数,编译器会自动生成相应的跳板函数(含有SG指令和跳转指令)。好了,是时候把我们的代码按照他们的Secure属性放到对应的位置了。
对于地址空间的划分,我们是通过配置SAU(Secure Attribution Unit)实现的,通过定义Linker脚本,我们可以将Secure代码放到Secure位置,Non-Secure代码放到Non-Secure位置,跳转的跳板函数放到Secure Non-Secure Callable位置。在系统初始化的时候,这些位置定义通过SAU划分。CMSIS已经集成了相关配置的宏定义。
多个镜像
现在我们来回顾一下我们的代码,注意到目前我们会有两个main
函数,一个是用于Secure代码的,一个是用于Non-Secure代码的。如果一起编译的话,编译器在link的时候是没法区分这些函数的。除了main
函数意外,我们还会碰到一样的printf
等等,那么他们会引用一个函数还是分别实现两个printf
呢?如果Secure调用Non-Secure的printf,或者Non-Secure调用Secure的printf会发生什么呢?正如我们之前讲到的,错误的调用会触发HardFault
。所以,我们应该有两套printf,但是我们怎么让去让编译器区分这两套函数呢?
实际上,我们并不需要这么做。解决方案是,我们会针对Secure和Non-Secure代码生成两个不同的镜像,两个镜像也是分别单独下载的。而每个镜像中都会有一个C库。这样做又会有另外一个问题:Secure代码将不能引用Non-Secure代码的符号(函数),反之Non-Secure代码也无法获取Secure代码的调用地址。对于Secure代码,这个可能没啥问题,因为它可能并不需要知道和调用Non-Secure函数(注意,前面用到的中断向量表的基址是一个确定的地址)。对于这个问题,编译器通过引入一个额外的参数--import_cmse_lib_out=<import library>
(ARM Compiler 6中是--out-impllib=<import library>
),加了这个参数之后,编译器会产生一个含有正确调用地址的目标文件,这个文件的地址可以用于Non-Secure固件的Link操作。注意,Secure固件要正确定义SG函数的Link地址。
系统启动和Secure调用
是不是还是有些云里雾里?我们再来理一遍这个过程。
从系统复位开始,我们来看看系统都经历了哪些操作:
- 系统首先从Secure向量表中获取Secure栈指针地址和复位地址
- 系统执行初始化程序,包括配置SAU,然后跳转到C库的__main函数
- 系统继续初始化,包含全局变量初始化,bss清零等等,然后跳转到main函数
- 继续调用
nonsecure_init()
函数跳转到Non-Secure状态 - 在Non-Secure状态,系统初始化Non-Secure的环境,比如全局变量,bss等等然后跳转main
- Main函数调用
sec_sum()
,函数经过SG跳转,临时进入Secure模式执行函数,然后回到Non-Secure执行Non-Secure的printf函数并输出结果。
这里也只是简单回顾了以下整个代码执行流程,详细的内容可能还要看相关的文档。
写出更安全的代码
好了,现在我们再来看一眼我们的sec_sum
函数
int __attribute__((cmse_nonsecure_entry))
sec_sum(int *p, size_t s) {
int ret = 0;
for (size_t i = 0; i < s; i++) {
ret += p[i];
}
return ret;
}
Non-Secure代码调用这个函数,传入一个指针和大小,最终得到一个结果。看起来很简单,但实际上这段代码有很明显的安全问题。如果传入的指针参数指向的是一个Secure地址,然后大小设置为1会发生什么?从代码上看,函数会直接返回这个地址对应的数据。啊哈,现在你可以通过这个函数读取Secure空间任意地址的内容了,系统并不会屏蔽这种操作,因为你的操作都是合法的,在Secure状态下访问Secure地址内容,嗯,完全没有问题!
为了避免出现这种问题,在编写代码的时候,我们要对传入的指针进行必要的检查。下面函数中的cmse_check_address_range()
和cmse_check_pointed_object()
就是用来检查参数是否合理的,在这两个函数内部,函数通过TTA指令检查参数在SAU中的配置情况。
比如我们可以在前面代码中加入如下的检查代码,注意cmse_check_address_range()
对应的度量单位是Byte,所以我们要将s乘以sizeof(int)
.
p = cmse_check_address_range(p, s * sizeof(int), CMSE_NONSECURE);
if (!p) return -1;
为了演示另外一个问题,我们来对sec_sum
做一个简单的修改,我们把传入的数组大小参数换为指针:
int __attribute__((cmse_nonsecure_entry))
sec_sum_silly(int *p, size_t *s) {
int ret = 0;
s = cmse_check_pointed_object(s, CMSE_NONSECURE);
if (!s) return -1;
p = cmse_check_address_range(p, *s * sizeof(int), CMSE_NONSECURE);
if (!p) return -1;
for (size_t i = 0; i < *s; i++) {
ret += p[i];
}
return ret;
}
虽然换成指针看起来傻傻的,并且是一种非常规用法,sec_sum_silly()
却演示了另外一个问题。在Secure代码中,传入的*s
类型会被认为是volatile
类型,所以Non-Secure的中断依然可以修改*s
的值,如果*s
的值在调用cmse_check_address_range()
函数之后发生改变,那么这个函数又可以读任意的Secure空间的内容。
为了避免这个问题,我们只需要做一个简单的修改
int __attribute__((cmse_nonsecure_entry))
sec_sum_silly(int *p, volatile size_t *s) {
int ret = 0;
s = cmse_check_pointed_object(s, CMSE_NONSECURE);
if (!s) return -1;
size_t s_saved = *s;
p = cmse_check_address_range(p, s_saved * sizeof(int), CMSE_NONSECURE);
if (!p) return -1;
for (size_t i = 0; i < s_saved; i++) {
ret += p[i];
}
return ret;
}
虽然*s
会被认为是volatile
,我们通过引入一个s_saved
变量,这样就可以将后续引用换为Secure下的变量,从而保证后续不会引用s
指针。
所以,开发Secure代码的时候一定要对传入的参数进行详尽的检查,从而避免各种信息泄露,另外,如果要读取Non-Secure空间的数据,那么要保证只需要读取一次即可,详细内容继续参考Secure software guidelines for ARMv8‑M based platforms Version 1.0 | Secure Software Guidelines
总结
这篇文章主要介绍了一些编写Secure代码需要注意的一些问题。另外介绍了如何定义Secure和Non-Secure固件,说明了为什么要对传入Secure的指针进行检查。
最后更新于 2019-11-07 02:53:25 并被添加「ARM TrustZone GCC」标签,已有 6527 位童鞋阅读过。
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。