Compartmentalised hello world

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:

  • The uart compartment is the only one that has direct access to the UART device.
  • The hello compartment is the only one that calls the 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:

  • It provides useful interfaces to the underlying hardware that abstract over its implementation.
  • It protects the hardware interfaces from use by arbitrary software on the system.

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.

Validating the compartmentalisation model

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:

  • The only thing with access to the uart device is the debug library.
  • The only things with access to the 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.