怎么更好的编写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-Non-Secure Switch

从系统复位开始,我们来看看系统都经历了哪些操作:

  1. 系统首先从Secure向量表中获取Secure栈指针地址和复位地址
  2. 系统执行初始化程序,包括配置SAU,然后跳转到C库的__main函数
  3. 系统继续初始化,包含全局变量初始化,bss清零等等,然后跳转到main函数
  4. 继续调用nonsecure_init()函数跳转到Non-Secure状态
  5. 在Non-Secure状态,系统初始化Non-Secure的环境,比如全局变量,bss等等然后跳转main
  6. 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的指针进行检查。


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

相关文章

发表新评论