The hello world example used a single compartment, which had direct access to the UART device. In general, it is bad practice to grant direct access to devices to most compartments and so in this example we'll start moving the UART access to a separate compartment.
Note: The debug.hh
interfaces use direct access to the UART, but only in debug builds. Debug builds are often allowed to weaken security rules in exchange for a better debugging experience.
In this version, uart.cc
exposes a single function that writes a string to the UART. The code in hello.cc
calls this with a global string and again with an on-stack buffer.
What do we gain from this compartmentalisation? In an example this simple, not a huge amount (especially given that neither compartment operates in a way that an attacker could influence, since we don't have any external inputs), but the principles here apply to more complex examples. We can statically audit the firmware image to know that:
write
entry point on the UART compartment.If an attacker compromised the hello compartment then the would still be able to call the write
function to write arbitrary output to the UART, but they would not be able to read or write any of the control registers of the UART.
This is the kind of isolation that device drivers typically provide in operating systems and our uart compartment is an example of one of the simplest possible device drivers:
Note in addition that we‘ve added one more attribute to the function exposed here: [[cheri::interrupt_state(disabled)]]
. This means that the uart compartment will write the entire string to the UART, so two different threads writing to it will not interfere. This is not ideal for something like a write
function, which can take a caller-controlled amount of time to complete, because it prevents any other thread from making progress, even ones that don’t touch the uart.
The goal of this refactoring was to ensure that only the uart
compartment has access to the UART device. How do we know if we achieved that? This is where the cheriot-audit tool comes in. If you‘re using the dev container, this is installed in /cheriot-tools/bin/
, if you’ve built it somewhere else then replace /cheriot-tools/bin/
with the correct path.
First, let's use it to see which compartments or libraries have access to the UART:
$ /cheriot-tools/bin/cheriot-audit -b ../../sdk/boards/sail.json -j build/cheriot/cheriot/release/hello_compartment.json -q 'data.compartment.compartments_with_mmio_import(data.board.devices.uart)' ["debug", "scheduler"]
This uses -b
to find the board description file and -j
the report that the linker generated during the build. The query passed with -q
asks which compartments or libraries import the UART's MMIO capability. This tells us that the debug
library and the scheduler
compartment both have direct access to the UART. The latter is an artefact of how some simulators exit: simulation builds will have the UART exposed to the scheduler.
Note that our uart
compartment isn‘t on this list. It accesses the UART via the debug
library. This means that, in addition to the compartments and libraries that have direct access to the UART, we need to determine which compartments have access to the debug
library. Let’s run that as another query:
$ /cheriot-tools/bin/cheriot-audit -b ../../sdk/boards/sail.json -j build/cheriot/cheriot/release/hello_compartment.json -q 'data.compartment.compartments_calling("debug")' ["uart"]
Great! Our compartmentalisation goal has been achieved!
Note that it was not in the first version of this example (ooops!). The fail-simulator-on-error.h
header adds an error handler that writes debug messages via direct access to the UART. This was difficult to spot manually, but would show up in auditing easily. Try uncommenting this line in hello.cc
:
//#include <fail-simulator-on-error.h>
Rerunning the above query should now show the following output:
["hello", "uart"]
Now that we have something that is correct, we'd like to make sure that it remains correct. We do this by writing a Rego module that encapsulates our policy.
First, let's start with a rule that checks access to the MMIO region.
# For non-simulation platforms, we only allow the debug library to access the # UART uart_access_valid { data.compartment.mmio_allow_list("uart", {"debug"}) } # For simulation platforms, we allow the scheduler to access the UART as well uart_access_valid { data.compartment.mmio_allow_list("uart", {"debug", "scheduler"}) data.board.simulation }
Note that we‘ve specified this rule twice. Rego rules match if either instance is true. The expressions within the braces are anded together: all must be true for the rule to be true. This means that we’re happy with either of two cases:
uart
device is the debug
library.uart
device are the debug
library and the scheduler
compartment and we are targeting a simulator.We now want to add a rule that holds only if this is true and if the only caller of the debug
library is the uart
compartment:
# Check that the UART is accessible only to the authorised libraries and # compartments and that only the `uart` compartment can call the library that # has direct access. valid { uart_access_valid data.compartment.compartment_allow_list("debug", {"uart"}) }
With this, we can now run the tool to check whether the built firmware complies with our policy:
$ /cheriot-tools/bin/cheriot-audit -b ../../sdk/boards/sail.json -j build/cheriot/cheriot/release/hello_compartment.json -m policy.rego -q 'data.hello_compartment.valid' true
If this outputs true
, it worked! Try modifying the code and see if it still passes. Configuring the build with allocator debugging enabled will cause the policy check to fail, so this can even catch cases where you left debugging access to the UART enabled in a production build.