Highly exposed software components suffer from code vulnerability attacks, see for example here. Techniques to mitigate these attacks include Stack Canaries and Address Space Layout Randomization (ASLR). In traditional operating systems (OS), the kernel is monolithic and includes most OS functionality. Notably, application loading, and random number generation (RNG) are implemented in the kernel. That way, ASLR can right away use the RNG.
This does not blend in with the microkernel philosophy and brings some other concerns.
In a microkernel-based OS, the aim is to minimize the complexity of the kernel, so the application loading is itself an application and, also, the RNG can be encapsulated in yet another application. That way, the attack surface of the kernel is reduced: less interfaces and less code gadgets to exploit in case of an initial vulnerability. Also, having the RNG state in a separate process, creates an additional step for the attacker trying to exfiltrate secrets, if the attacker controls already the kernel or the loader application.
So with no RNG in the kernel, it looks like we have created a bootstrap issue. How can the microkernel load the initial application in a random location, if the kernel has no RNG? How can the initial application load the RNG application with randomized layout, before the RNG application is loaded?
Our TEE has a privileged position to access a hardware True Random Number Generator (TRNG). Even if we can only use it at boot to get 32 bytes, this is enough to initialize the Pseudo Random Number Generator (PRNG) algorithm that was running in our second application.
So in a first iteration, we followed the obvious:
We moved the PRNG algorithm and the TRNG access into the microkernel and exported a new system call to get random numbers. That way, the kernel could set up its own Stack canary before starting the first application and also randomize the address layout of the first application. The first application could use the get_random system call to randomize the address layout of the second application and actually, each application can just use this system call. Like that, we could have moved everything into the kernel and would have end up with a monolithic kernel eventually.
Even if it looks like a solution, we did not considered it as an acceptable trade-off for many reasons, mainly about security and performance.
To keep our goal of having a kernel that is minimal at runtime, and yet randomize components at boot, we had to:
- keep a PRNG algorithm in an application. (maintain a minimal kernel)
- use a second PRNG instance during early boot to increase the number of random numbers provided by the TRNG hardware. (randomize all components at boot)
Since we do not want the PRNG in the kernel, we use the following strategy: we calculate the total number of random numbers required to boot the system up to the initialization of the PRNG application and its PRNG algorithm. We then define a new piece of run-once software that access the TRNG hardware and runs the PRNG algorithm at boot and provides the required entropy to the boot process of our OS. This piece of software could be a part of our binary that bootstraps the actual kernel. Or, it could be some sample code that we include in the existing bootloader. In both cases, if the PRNG algorithm is only used at boot time, it would be good to zero-out and unmap the code and data at the end of the boot process.
There are two ways to implement this:
- Using header fields in the component binaries and a modified loading process; or
- Using a set of dynamic call interfaces to iteratively retrieve the data.
In the first approach we define a header format with two fields: one field for the application to specify how much entropy it needs to boot, and one field for the loading application to put the buffer with entropy. The system designer calculates the required number for each component and each component consumes the random from the buffer and passes the remainder to any other component it loads itself. This approach resembles the ELF file format AT_RANDOM auxiliary vector (AUXV). The boot component would generate all the random data at once, store it in the kernel random buffer, and then dispose of the TRNG and PRNG code if applicable.
The second approach defines a set of dynamic interfaces, such that each component calls a get_random function in a previous component to retrieve some random. It works as follows: the kernel would call the bootloader a number of times at boot before loading the initial application, using part of (or all) the retrieved RNs. Then the initial application would call the kernel, which forwards the requests to the bootloader. The second application could directly call the kernel. A library in any application could also call the kernel.
End of boot handling
Here there are two ways to notice the end of the boot process: like in the the first approach, the boot component could have a configurable number, generate this at boot into a buffer, dispose of the TRNG and PRNG code and just hand out bits of this buffer until the buffer is empty, at which it returns an error code. An even more dynamic way would be to have a second function, called terminate_random. It will be used when at last the final boot component has booted and a new instance of the PRNG algorithm is ready. This last boot component, for example the second application, then calls the terminate_random function, this call is forwarded up to the booting component that will then dispose of the boot-PRNG algorithm. Until this final function is called, the booting component will hand out random numbers iteratively.
When comparing both approaches, the first idea reduces the number of runtime interfaces and the run-once nature of the boot PRNG is evident. However, manual work is required to calculate the number of boot-time random for each component, different loaders need to be modified and also memory needs to be reserved for the random buffer of each component. In the second approach we have more flexibility and may more easily adapt to changes of the boot process. However, the get_random system call will remain in the kernel at runtime, even if it is not called anymore. Also, the boot PRNG algorithm is now called several times and there is a risk that we forget to dispose of it at the end.
In this blog post we have showed how to solve the bootstrap of entropy in a microkernel context, enabling support for ASLR, without adding an RNG algorithm to the microkernel. We recommend that silicon partners provide a sufficient number of random numbers at boot, even if it means to use a run-once PRNG algorithm.