website/content/cheri.md

25 KiB

+++ title = "CHERI" date = 2022-11-19 +++

preamble

CHERI is an acronym for Capability Hardware Enhanced RISC Instructions. it is a security-focussed project aimed at improving memory protection at the hardware level. the project is complex and it has many potential applications.

in this article I will go into some basics to give an understanding behind some changes that CHERI makes to how programs execute and are written. this will be focussed almost entirely in C, as this is where my experience lies - it is also where some of the effects of CHERI are most easily felt.this article is going to be a very simplistic introduction to CHERI, and I'm going to attempt to explain the basics behind everything I cover. a basic understanding of C will be necessary.

note: the Morello platform is an evaluation board produced by Arm to provide a physical implementation of CHERI extending the Arm AArch64 ISA. I previously worked on this platform at Arm, porting the musl C library to Morello. implementations for CHERI that are worth looking into from a more open perspective are the MIPS (chapter 4) and RISC-V (chapter 5) ones. Morello is the only implementation that exists in a true hard core format, afaik - but this is obviously hard to obtain so you'll just be playing around with emulators/models anyway.

memory safety bugs

to understand how CHERI tries to fix some simple issues, we'll first look at some simplified examples of issues that arise when we aren't using a CHERI-based architecture.

a simple memory safety bug

let's take a look at this C code:

{{ code(path="cheri/membug.c", syntax="c", linenos=true) }}

and try running the compiled output of said program:

$ ./membug
enter your name: jack
hello jack
my_perfect_string: what a beautiful string

works on my machine boss! code review +1, and merged...

...until our good friend Hubert Blaine Wolfeschlegelsteinhausenbergerdorff Sr. comes along. he emails me a strange error he's running into:

$ ./membug
enter your name: Hubert Blaine Wolfeschlegelsteinhausenbergerdorff Sr.
hello Hubert Blaine Wolfeschlegelsteinhausenbergerdorff Sr.
my_perfect_string: hausenbergerdorff Sr.

note: if you compile and run this on your machine, you may not get the same output. that's because we're invoking undefined behaviour here, so the compiler can kind of do whatever it wants. I'll always provide the output that demonstrates what I'm trying to show when giving examples like this. for what it's worth, I'm running clang 10.0.0-4ubuntu1 with target x86_64-pc-linux-gnu. compilation options, the Makefile, and such are available code subdirectory of this article's source.

that's not supposed to happen! his name has spilled over into our my_perfect_string[] array! turns out our issue is that when we use fgets(char *str, int count, FILE *stream), we've set the second parameter (size) to 1000 - but our user_name[32] array can only fit 32 characters (and the last of these should be a null terminator, so 31 usable characters).

fgets() fills up user_name, but it hasn't finished with the name yet! it doesn't care (or know) that user_name is full, it's just going to keep going until it finishes our user input, or reads 999 characters from standard input. thus it keeps mindlessly writing, overwriting the section memory we've used to store our precious perfect string (which happens to be immediately after user_name).

note: fgets() has a cousin, gets(char *s), which is particularly poor with regards to memory safety (due to lack of size parameter), and has largely been moved away from in modern C:

LSB deprecates gets(). POSIX.1-2008 marks gets() obsolescent. ISO C11 removes the specification of gets() from the C language, and since version 2.16, glibc header files don't expose the function declaration if the _ISOC11_SOURCE feature test macro is defined.

let's take a look at the stack in GDB to see how this happens:

(gdb) b memdebug.c:7 
(gdb) run 
Breakpoint 1, main () at membug.c:7
7           printf("enter your name: "); 
(gdb) n
8           fgets(user_name, 1000, stdin); // get user's name from stdin
(gdb) n
9           printf("hello %s", user_name);
(gdb) x/56bc $sp
0x7fffffffdbf0: 106 'j'     97  'a'     99  'c'     107 'k'     10  '\n'    0   '\000'  0   '\000'  0   '\000'
0x7fffffffdbf8: 77  'M'     82  'R'     85  'U'     85  'U'     85  'U'     85  'U'     0   '\000'  0   '\000'
0x7fffffffdc00: -24 '\350'  -78 '\262'  -5  '\373'  -9  '\367'  -1  '\377'  127 '\177'  0   '\000'  0   '\000'
0x7fffffffdc08: 0   '\000'  82  'R'     85  'U'     85  'U'     85  'U'     85  'U'     0   '\000'  0   '\000'
0x7fffffffdc10: 119 'w'     104 'h'     97  'a'     116 't'     32  ' '     97  'a'     32  ' '     98  'b'
0x7fffffffdc18: 101 'e'     97  'a'     117 'u'     116 't'     105 'i'     102 'f'     117 'u'     108 'l'
0x7fffffffdc20: 32  ' '     115 's'     116 't'     114 'r'     105 'i'     110 'n'     103 'g'     0   '\000'

we can see our two character arrays are right next to each other on the stack (user_name contains some gibberish as it is not zero-initialised).

note: this code was compiled with -fno-stack-protector to reproduce this behaviour. compilers have certain techniques which can help protect against such attacks, but there are often ways around these by using less primitive attacks. we are just ignoring these in this article for simplicity.

okay, at least it's a pretty easy fix: we just need to change the fgets() parameter size to 32.

note: you may initially think "why not 31? don't we need to save a character for the null byte at the end?". thankfully, fgets does this for us. excerpt from man fgets:

"fgets() reads in _at most one less than size_ characters from stream and stores them into the buffer pointed to by s [...] A terminating null byte ('\0') is stored after the last character in the buffer".

this is a good question to be asking though, being careful is key when it comes to these kinds of things.

why hardware?

okay, that wasn't too bad. why are we talking about doing anything in hardware here? just write the code correctly!

we've looked at a very simplistic situation, with no real stakes and nothing to exploit (and an unrealistically simple bug). if this bug was exploitable for malicious gain, it could already be too late by the time we found it.

memory safety problems make up the vast majority of problematic security issues. the Chromium project found 70% of its serious security bugs were memory safety related and Microsoft found the same prevalence. some memory safety bugs can be incredibly complicated and go unnoticed for decades. the C language especially gives the programmer many, many opportunities to make mistakes - and it only takes one to be a problem. a lot of the software we are using these days is based on layers upon layers of software written in different languages, and there are going to be bugs in there. CHERI aims to give us some protection at a hardware level.

Note: some languages (e.g. Rust) are going to offer you strong memory safety guarantees at compile-time, but I'm not going to include the discussions around this and how it compares to CHERI in this article. this article will focussed on how CHERI applies to C (and to some extent, C++ by extension).

pointers recap

let's quickly recap a basic idea of what a pointer is. we're going to ignore things like virtual memory for brevity. we can think of a pointer in a normal 64-bit architecture (e.g. AArch64) simply as a 64-bit unsigned value that holds the memory address of something we care about. this is a simplification (as are most things), but it can help us reason about the general idea:

int val = 1593;
int *x = &val; // x points to val

int *x0x0000010000000004addressmem[0x0000010000000004]mem[0x0000010000000000]mem[0x0000010000000008]1593memory (as ints)

and on these normal architectures, this pointer generally is just a number. we can do weird things with it, treating it as a number...

{{ code(path="cheri/ptrs_as_numbers.c", syntax="c", linenos=true) }}

...and this code will often still work:

$ ./ptrs_as_numbers
*(7fff98640c20)=1234
*(7fff98640c24)=5678
*(7fff98640c28)=9999

yikes! now, when you start messing with pointers like this, you're bound to run into a bunch of undefined behaviour. but C programmers write undefined behaviour all the time (and not always by accident), and my computer executes this program fine without complaining at all. doesn't it feel a bit weird that we can take a pointer to arr[0] and modify it to load magic? they're not even part of the same array...

introducting capabilities

CHERI introduces capabilities, which can be thought of as an extension to pointers. they still store an address of something we care about, but they have extra information too! in a 64-bit system, a pointer would typically be a 64-bit value (as dicussed previously). the corresponding capability in a CHERI platform is 128 bits (or 129 bits if you look at it a certain way, more about that later).

as you might have guessed, this "extra information" takes up extra bits of the capability (how much is actually a little complicated - we will touch on that). bits are assigned to three key pieces of metadata: bounds, permissions, and object type. there is also an additional 1-bit tag which is stored out-of-band: it is not a 129-bit value - instead each 128-bit capability can be thought of as being associated with a 1-bit validity tag. the architecture manages this association for us. the diagram below is provided as a rough overview of this. note that it is not to scale.

int *x (capability)0x0000010000000004addressboundsobject typepermissionstag (out-of-band)

I am mostly going to focus on bounds in this article, as it is not too difficult to grasp, and the impact is fairly easy to demonstrate for some simple examples. the bounds represent an upper and lower bound on the memory region (address space) that this capability is allowed to access. if we try to use the capability to access some address outside of this range, the hardware will throw a fault - it simply won't let us do this!

note: it is important to note that I am going to oversimplify the way the bounds are stored in this article. this especially includes the diagram above. in reality, there is a complex compression method, necessitated by the range and sizes required by bounds. this depends on the address value, alignment, etc. for now, we shouldn't need to think about this much, just know it will be managed for us. the key take-away from this is that bounds can't always be 100% precise for all addresses and ranges.

can you imagine how we can use bounds to prevent our previous memory safety bug from occurring? the key is that we can set the bounds on the capability pointing to user_name which we pass to fgets, such that the capability may only access the contents of the array. this means that when fgets tries to write past the end of the user_name array, the processor will throw a capability fault, and execution of our program will cease.

the idea behind CHERI is that we as the C programmer don't have to set up these bounds ourselves most of the time---this is something the compiler can generate code for. the compiler knows that the user_name array has a length of 32, and can set the bounds accordingly on capabilities created that point to it. let's try it...

playing with CHERI RISC-V

unless you're lucky enough to have access to a physical Morello board, there is the issue of actually using a CHERI implementation. for this article I will be making use of the QEMU emulator to emulate a RISC-V CHERI environment. running CheriBSD on this emulator will allow us to have a nice FreeBSD-based capability-enabled environment to play around with. I'll use cheribuild to easily get set up (the cheribuild.py step will take a very long time the first time):

$ sudo apt install autoconf automake libtool pkg-config clang bison cmake \
ninja-build samba flex texinfo time libglib2.0-dev libpixman-1-dev \
libarchive-dev libarchive-tools libbz2-dev libattr1-dev libcap-ng-dev
$ git clone git@github.com:CTSRD-CHERI/cheribuild
$ cd cheribuild
$ ./cheribuild.py --include-dependencies --run/ssh-forwarding-port 2222 run-riscv64-purecap 
CheriBSD/riscv (cheribsd-riscv64-purecap) (ttyu0)

login: root
root@cheribsd-riscv64-purecap:~ #

now we have our shell inside our CheriBSD emulated platform, we can start to try things out. let's compile our membug program again, this time with the toolchain targetting CheriBSD RISC-V - this will have been built as part of the dependencies already.

once our membug-cheribsd executable is built, we can scp it over to the CheriBSD filesystem. remember, we set up the SSH forwarding port to 1111.

from a terminal on your host machine:

$ ~/cheri/output/sdk/utils/cheribsd-riscv64-purecap-clang membug.c -Wall -g -fno-stack-protector -o membug-cheribsd
$ scp -P 2222 ./membug-cheribsd root@localhost:~/

and now we can see what happens when we explore our bug with CHERI:

$ ./membug-cheribsd
enter your name: jack
hello jack
my_perfect_string: what a beautiful string

$ ./membug-cheribsd
enter your name: Hubert Blaine Wolfeschlegelsteinhausenbergerdorff Sr.
In-address space security exception (core dumped)

it's working! we are getting a capability fault as we exceed the bounds of the user_name capability bounds. we can use gdb to verify this is caused by the bounds fault:

(gdb) run
Starting program: /root/membug-cheribsd 
enter your name: Hubert Blaine Wolfeschlegelsteinhausenbergerdorff Sr.

Program received signal SIGPROT, CHERI protection violation.
Capability bounds fault caused by register ca6.
0x0000000040314ce8 in memcpy (dst0=0x3fffdfff44, src0=<optimized out>, length=54) at /home/jack/cheri/cheribsd/lib/libc/string/bcopy.c:143
(gdb) p $ca6
$1 = () 0x3fffdfff78 [rwRW,0x3fffdfff44-0x3fffdfff64]

as we can see, the bounds for our user_name capability (which is stored in capability register ca6) are 0x3fffdfff44-0x3fffdfff64, but the address is 0x3fffdfff78. this is out of the bounds allowed by the capability, so the architecture throws a fault. if we look at the assembly generated by the compiler, we can see it set our capability bounds to a size of 32 to enforce this behaviour:

0000000000001ce8 <main>:
; int main() {
    cincoffset	csp,    csp,        -160
    csc	        cra,    144         (csp)
    csc	        cs0,    128         (csp)
    cincoffset  cs0,    csp,        160
    cincoffset	ca0,    cs0,        -36
    csetbounds	ca2,    ca0,        4
    cincoffset	ca0,    cs0,        -60
    csetbounds	ca0,    ca0,        24
    csc	        ca0,    -128        (cs0)
    cincoffset	ca1,    cs0,        -92
    csetbounds	ca1,    ca1,        32
    csc	        ca1,    -144        (cs0)
    mv	        a1,     zero
    csd	        a1,     -104        (cs0)
    csw	        a1,     0           (ca2)

chains of capabilities

at this point you may be thinking "okay, that's great, but if we can just set the bounds of a capability with an instruction then what's the point? surely I can just set global bounds on some random pointer and access whatever I want?"

fundamental to the idea of capabilities is their provenance and monotonicity.

provenance, simply put, means we can only construct a capability from an existing capability, using specific instructions. we can't just create a capability from some random size_t and use it to load/store something. let's see what happens when we try to run our ptrs_as_numbers program on CheriBSD:

(gdb) run
Starting program: /root/ptrs_as_numbers-cheribsd 
*x=1234
Program received signal SIGPROT, CHERI protection violation.
Capability tag fault caused by register ca1.
0x0000000000101c66 in main () at ptrs_as_numbers.c:1414
          printf("*x=%d\n", *x);
(gdb) p $ca1
$1 = () 0x3fffdfff74

we get a fault, because the tag isn't set. any capability with a tag not set to 1 cannot be dereferenced -- it is invalid. in fact, this capability has no capability metadata -- when we copied it into our unsigned long, we just copied the 64-bit address.

monotonicity is what stops us taking an existing capability and creating a capability with more permissions and/or access than the original. it stipulates that when we create a capability from another capability (which we have to do -- provenance), the permissions and bounds of the new capability must be less than or equal to the original. so our bounds can only get narrower as we create new capabilites from an existing capability. this means that capabilities trace back in a chain - they are all created from other capabilities, and narrowed as necessary. in this case, (simplified) when the kernel loads our program it will give us capabilities that are wide enough to do everything we need to do, and the compiler will try and make sure all the capabilities that we make and use from these are as tightly bound and unpermissive as possible.

CHERI-fying code

you'll notice we got a lot of these benefits "for free". we only had to recompile our code, and we gained this extra security. of course, CHERI does require changes to program sources. naturally, the compiler was changed a lot to implement this behaviour. in particular, CHERI also requires changes to things like the C library and kernel in order to take advantage of the features fully. sufficiently large userspace programs will generally require source changes.

one common issue is that a lot of existing C code assumes that sizeof (*void) == sizeof(size_t). with CHERI, our pointers are now twice as big. however, size_t hasn't changed size, as the address space size hasn't changed - for example, if we index into an array with size_t, the index should still be the same size; the extra data in our void * capability is the metadata, not extra address data. any program that tries to convert from some unsigned long or size_t to a capability will fault - this violates provenance. so, sometimes code changes have to be made to ensure we are keeping the capability metadata around. in CHERI, we can use ptraddr_t to store addresses and [u]intptr_t to store capabilities.

let's make a program to see some differences in types, and demonstrate how uintptr_t can preserve capabilities:

{{ code(path="cheri/ptrtypes.c", syntax="c", linenos=true) }}

running this on our non-CHERI host will give us:

$ ./ptrtypes                                                        
type          size (hex)   size (dec)
=====================================
uintptr_t     0x08         08
size_t        0x08         08
void*         0x08         08
=====================================

running this on CHERI (64-bit):

$ ./ptrtypes-cheribsd
type          size (hex)   size (dec)
=====================================
ptraddr_t     0x08         08
uintptr_t     0x10         16
size_t        0x08         08
void*         0x10         16
=====================================
*b: 888
*b: 111
*b: 999

epilogue

I appreciate this has been a fragmented and surface level introduction to CHERI. hopefully it has provided some education in some basic aims of CHERI regardless. potential benefits and uses for CHERI go much deeper than anything I've touched on here, so please, read more about everything - and get your hands dirty trying out messing about with qemu and CheriBSD!

here are some links to check out: