Arm TrustZone brings lightweight compartmentalisation to the M-Profile with Armv8-M security extensions. Compartmentalisation is a technique that separates code from important data or permissions, improving security by limiting the capabilities of risky or exploitable code. This allows vulnerable data and code to be perfectly segregated from less trusted code. Writing good Secure code is a little tricky. For example, there have been several security bugs that were caused by missing pointer checks.
In a previous blog post (Useful tips for developing secure software on Armv8-M) we presented some tips for writing Secure code. In this post we will dive a lot deeper, explaining how to write Secure code properly. As a prerequisite, we will be looking into bringing up Secure (and especially Non-secure) code as well.
In the Armv8-M architecture, Secure and Non-secure (NS) are two states the processor core can be in. Main memory is also divided into Secure and Non-secure regions of memory. Code in Secure memory shall be known as Secure code, while code in Non-secure memory will be called Non-secure code. Besides being located in different areas of memory, there is no difference between Secure and Non-secure code. The tricky bit is in switching between running Secure code and Non-secure code, and vice versa. Switching between security states is what the first half of this blog is about.
As a running example, we'll use the following bit of C code. It computes the sum of the values in an array.
int sum(int *p, size_t s) { int ret = 0; for (size_t i = 0; i < s; i++) { ret += p[i]; } return ret; }
There is no reason why this function should run as Secure code, but to illustrate what's going on we'll do so anyway. If that was all, it would be enough to place the compiled instructions in a Secure region of memory. However, if this function should be called from Non-secure code, then we do have to tell the compiler what is going on. This is accomplished by marking the sec_sum function with the cmse_nonsecure_entry function attribute, indicating that this is a Secure interface boundary. For clarity, we prefix the function name with “sec_”. Inadvertently, the Non-secure code can now also read all of Secure memory. Please see the second half of this post on how to fix that.
int __attribute__((cmse_nonsecure_entry)) sec_sum(int *p, size_t s) { ... }
It is now possible to call sec_sum from Non-secure code. At compilation time, it is not even necessary to be aware that sec_sum is going to run in a different security state from the code that is calling it. The next bit of code shows how sec_sum can be called from main() in Non-secure code.
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; }
Great, that's a call from Non-secure code to Secure code, but how do we get into Non-secure state in the first place? The processor core will boot up in the Secure state. Switching to the Non-secure state for the first time is a little more involved. Here is the main() function for Secure code, which will be explained below.
#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; }
We'll go through this line by line. The first line defines the type for an Non-secure function that is called from Secure code, with the cmse_nonsecure_call attribute. The nonsecure_init() function will be called from main() to switch to the NS state.
On line 7, we set the vector table address for Non-secure. The vector table contains the addresses of exception handlers (these should be different for Secure and Non-secure state). Setting the address of the Non-secure vector table is optional, since the default address can be configured in hardware. The address is device-specific and a scatter file should place the Non-secure vector table exactly at this address.
On line 10, we use the first entry in the vector table to set the initial Non-secure stack pointer. (Yes, Non-secure and Secure code must use different stacks, located in Non-secure memory and Secure memory, respectively.)
Line 12 takes the second entry from the vector table, which contains the address of the ResetHandler. Casting this address to the function pointer type as defined on line 4 causes a call through this pointer to be treated as an NS call from Secure code. On line 13, finally, the call is made and the core switches to a Non-secure state.
There you go. In this section, we've looked at how to make transitions from Secure to Non-secure code and back!
At the start of the previous section, we briefly talked about memory regions. We'll go into a bit more depth here.
Memory regions can be marked as Secure, "Secure and NS-callable", or Non-Secure. Code in a Secure memory region can only be executed when the core is in the Secure state. Equally, in the Non-secure state only Non-secure code can run. The Secure and NS-callable memory regions are the exception. Code in this region can be executed in both security states and should only be used for switching from NS to Secure. A HardFault occurs when the memory region that is being executed and the security state of the core do not match, so that Secure code is only run in Secure state, and NS code is only run in a Non-secure state.
Switching between security states happens through a few special instructions. Moving from Secure to NS state is relatively simple: with a special branch instruction (BXNS or BLXNS) Secure code can branch to any location in NS memory. Prior to the branch, Secure code is responsible for clearing information from the registers.
Switching from NS to Secure is more complicated because we want to prevent NS code from branching to any address in Secure memory. This is where the "Secure and NS-callable" memory regions come in. This is the only type of region of Secure memory that Non-Secure code is allowed to branch into. An SG instruction (which switches from NS to Secure state) followed by a branch in this part of memory is the way to call into a Secure function. For functions that are marked as Non-Secure entry points, the linker will generate a "veneer" consisting of exactly those SG and branch instructions to Secure memory. However, it is up to us to place all these pieces of code in the right location and with the right permissions.
We configure memory regions by programming the SAU, or security attribution unit. Using scatter files or linker scripts, we put Secure code and data into a memory region that will be marked as Secure; Non-Secure code and data go into Non-Secure memory; and the Non-secure-to-Secure veneers are placed in a Non-secure callable region. During system initialization, these regions are configured into the SAU. CMSIS provides macros to set up these regions properly.
If we consider all code so far, notice that there are two main() functions: one as Secure code, one as Non-secure code. When trying to link these (from different source files), our trusty linker will no doubt complain about multiple definitions of the same symbol. Hmm. And what about those two calls to printf() from different security states? Should they refer to the same function (in either Secure or Non-Secure code) or are there two different printf()s? What would happen if NS code calls a Secure printf() or Secure code calls a NS printf()? As was explained in the previous section, calling a function from the wrong security state results in a HardFault. It is clear, then, that there should be two different printf() functions. But how do we tell the linker which version of a function is meant, and how can we include a function twice?
Actually, we don't. The solution here is to link Secure and Non-Secure code into two separate images and load them separately. Each image will (auto-magically) be linked with a copy of the C library. This creates a new problem: the Secure image won't have definitions of Non-Secure symbols and vice versa. For the Secure image this is not a problem as it does not refer to Non-Secure symbols at all! (Remember the explicit address to the NS vector table in nonsecure_init().) The linker provides a solution to the inverse problem. With the option
--import_cmse_lib_out=<import library> (for Arm Compiler 6; gcc has the option --out-impllib=<import library>) it generates an object file with empty definitions of the Secure interface symbols at the right address, which can then be linked with the NS image. Note also that the linker scripts (or scatter files) need to define where the SG veneers are placed in the Secure image.
Startup and Secure calls
Confused yet? Let's walk through a run now that everything is set-up.
Starting at reset, we go through the following sequence of events.
This concludes a walk-through of some of the parts involved in setting up Secure and Non-secure code. For clarity, some details have been left out in this post. Documentation on TrustZone, ARM C Language extensions and more can be found here.
Let's take another look at the Secure function 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; }
Call this function from Non-Secure code, pass in a pointer and a size, and you'll get the sum of the array. There is a glaring issue, though. What if one passes a pointer to a location in Secure memory as p and a size of 1? The function sec_sum() will happily return the value of *p. Oh, look; that's how you can read out all of Secure memory! The core will allow this access because Secure state is allowed to access Secure memory.
To prevent information leaks, the programmer has to check pointers passed to Secure functions before dereferencing them. The functions cmse_check_address_range() and cmse_check_pointed_object() are there to do just that. Under the hood, these are converted to TTA instructions, which test the SAU setting for the memory region.
The check below is inserted at line 4. Note that the size for cmse_check_address_range() is measured in bytes, while s counts the number of ints in p, so s is multiplied by sizeof(int).
... p = cmse_check_address_range(p, s * sizeof(int), CMSE_NONSECURE); if (!p) return -1; ...
To illustrate another issue, let's look at a slight variation of sec_sum which passes the size of the array through a pointer.
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; }
Besides making the interface a bit silly and unusual, sec_sum_silly() has another problem. NS memory is volatile from the viewpoint of Secure code, since an NS interrupt handler could change the value while Secure is expecting it to stay unchanged. If the value of *s was changed after the call to cmse_check_address_range(), the function could end up reading from any location in Secure memory.
To solve this problem the code is modified such that s is dereferenced only once.
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; }
Note that *s is declared as volatile. The compiler needs to be aware that s points to volatile memory. This prevents s_saved from being optimised away due to, for example, contention on the registers. This could lead to multiple loads of *s which would lead to a security issue if the *s value is checked, but a different value is used during the for-loop.
It is clear that the developer needs to take care when accepting pointers to memory in a Secure API. Pointers need to be checked before they are dereferenced to prevent leaking or modifying of sensitive information. On top of that, the same memory location in an NS memory region must not be read more than once. For more details on writing Secure code see the Secure software guidelines for Armv8‑M based platforms Version 1.0 | Secure Software Guidelines..
In this blog we highlighted a few intricacies that you need to watch out for when writing Secure code. We explain how to set up Secure and Non-secure images and show why you need to be careful to when passing pointers to Secure code. This post should help you get started writing TrustZone code for the Arm Cortex-M23 and Cortex-M33 processors.
Broken link: "For more details on writing Secure code see the Secure software guidelines for ARMv8‑M based platforms Version 1.0 | Secure Software Guidelines – ARM Developer "
Should be this one I guess: https://developer.arm.com/products/architecture/cpu-architecture/m-profile/docs/100720/0100/secure-software-guidelines
Thank you for flagging this BrunoV I have now now fixed the link.