Software Compartmentalisation and Interfaces
You can access the slides 🖼️ for this lecture.
Retrofitting Compartmentalisation
We have seen that compartmentalisation is present in production only in a handful of specific use cases, such as micro-kernels, web browsers/servers, etc. These all represent software that was designed from scratch with compartmentalisation in mind. Another approach at compartmentalising applications is rather to retrofit it into existing monolithic software: there is a huge amount of legacy systems software code that would benefit from compartmentalisation.
Retrofitting compartmentalisation manually requires expert knowledge and represents a large amount of engineering effort, hence an ideal goal would be to compartmentalise monolithic software automatically:
Automated Compartmentalisation
Automated compartmentalisation tools let the user compartmentalise seamlessly and without expert knowledge. Let us consider the different steps of compartmentalising an application, and reason about how easily these can be automated:
-
Policy Definition. Certain policy-definition approaches relying on standard programming language/runtime constructs can be at least partially automated, for example strategies placing every or particular libraries/object files/functions within their own compartments. Most policies that are manually defined aim primarily to isolate security-critical software components from low-trusted ones: some approaches can help automate that process, e.g., to identify trusted/untrusted code through static or dynamic analysis.
-
Abstractions: Identifying Cross-Compartment Shared Data. A particular challenge is to identify all shared data, i.e., data that needs to be accessed from several compartments. This is difficult but recent research efforts have proposed static analysis approaches that can provide an (over)estimate for the subset of data that needs to be shared in a compartmentalised application. Another abstraction-related task is the identification of cross-compartment boundaries in the code (e.g., by inserting gates), which is something that can be easily automated with static analysis once a policy is defined.
-
Applying an isolation mechanism: there are existing primitives (e.g.
fork()for processes) to automate isolation, and similar support for other mechanisms in several existing compartmentalisation frameworks. Establishing cross-compartment communications is also automatable once a mechanism has been selected and shared data has been identified: depending on the desired communication method, the automation technique will allocate such data in shared memory, or establish message-passing based communication channels between compartments.
Overall, although automation may not do as good a job as manual compartmentalisation, all the steps described above can be automated. However, there is a particular step of the compartmentalisation process that is still very hard to automate today: securing cross-compartment interfaces.
Interface Safety
Motivational Example
Consider the following monolithic program, that we would like to compartmentalise:
double data[DATA_SIZE];
/* ... */
int lib_function(int index, double object) {
data[index] = object;
/* ... */
}
int main() {
int index = get_index();
double object = get_index();
if (index < DATA_SIZE && index > 0)
lib_function(index, object);
/* ... */
}
In that monolithic version we do not trust the input source where index comes from, for example get_index may be getting it from the standard input.
Hence, there is a sanity check on the value of index before sending it as parameter to lib_function to avoid over/underflowing data.
Now assume that we want to compartmentalise that application according to the following policy: placing: lib_function in a compartment, main in another.
More precisely, we want to safebox lib_function, that we trust, from main that we do not fully trust.
Assuming we choose to compartmentalised using process-based isolation, we can simply partition the process into two processes, one running main and invoking the other running lib_function while passing it the necessary parameters index and object through some form of IPC.
The new (safebox) trust model we want to enforce leads to the emergence of a new internal trust boundary inside the application, that is the interface between the two compartments.
The existing sanity checks, that were written by the monolithic application developer that assumed that the entirety of the application’s code was fully trusted, are no longer sufficient.
If, as assumed by our trust model, the main compartment is malicious, we need to assume that the attacker can run arbitrary code in the context of that compartment, and execute instructions that are completely different from what is in the source code.
That can be achieved through code injection, control flow hijacking, return oriented programming, etc.
If main is compromised and sends corrupted values for the parameters passed to lib_function, this can lead to memory corruption in the context of the safebox, because there is no sanity checks enforced by the safebox on its input parameters, in particular on index.
In fact, an attacker taking over main gains an arbitrary memory write primitive within the safebox, which is a powerful attack tool.
A proper compartmentalised version of that application would introduce sanity checks on the trusted side, i.e., within the safebox, on the data flowing inside that compartment:
The lib_function compartment would look as follows:
double data[DATA_SIZE];
/* ... */
int lib_function(int index, double object) {
if (index >= DATA_SIZE || index < 0)
return -1;
data[index] = object;
/* ... */
}
Interface Safety
The issue we illustrated in our example relates to interface safety. Enforcing interface safety corresponds to the integration, in compartmentalised applications, of sanity and validity checks on the data and control flow that exists between communicating distrustful compartments. As illustrated in our example, the lack of interface safety can provide ways for attackers to escape the compartmentalisation. In reality, we are going to see next that interface safety is in fact an absolute necessity, and that its absence negates most of the security benefits one would expect from compartmentalisation.
Our example also showed that retrofitting compartmentalisation creates internal trust boundaries, within monolithic software that was never designed with these internal trust assumptions:
As a result, the number of issues related to interface safety is likely to be high. How bad is the problem? This is particularly concerning when the majority of existing approaches at compartmentalisation (including automated ones) do not provide any help for securing interfaces. With the aforementioned (ideal) goal of automating compartmentalisation, can we also automate the enforcement of interface safety?
Compartment Interface Vulnerabilities (CIVs)
CIV: Definition
The vulnerabilities that emerge due to the lack or absence of interface safety in a compartmentalised application, such as the one we saw in our example, are called Compartment Interface Vulnerabilities (CIVs). Through invalid and improperly-checked interface requests, a malicious compartment m can confuse a victim compartment v into misbehaving, and compromise some of the integrity, confidentiality, and availability guarantees expected from compartmentalisation. The main classes of CIV include:
- Data leakage: m can trick v into exposing some of v’s confidential data (e.g., cryptokeys, passwords, etc.) or addresses/pointer values.
- Data corruption: m can confuse v into dereferencing corrupted pointers (
NULLpointers, references to invalid data structures or unaccessible memory), using corrupted indexing information (with array indexing or pointer arithmetics), or using corrupted data structures. - Temporal violations: m can call functions exposed by v in the wrong order, and if both compartments run concurrently m can pass to v corrupted synchronisation primitives (e.g., locks) and perform TOCTTOU attacks in shared memory.
These issues can be triggered when m and v interact. m can either invoke v with invalid interface requests, e.g., invalid function call arguments or improper API usage ordering, but issues also emerge when v invokes m, which can feed v with corrupted return values or data referenced through pointer arguments. Issues regarding corrupted synchronisation primitives and TOCTTOU arise when m and v run concurrently. All types of attack are illustrated below:
How Bad is the Issue of CIVs?
A recent research work proposes a tool named Conffuzz, which injects malformed data in monolithic software at potential compartment boundaries. The idea is to emulate the result of compartmentalising without securing interfaces, in order to study the impact of CIVs when interface safety is not properly addressed:
The fuzzer hooks into the program at a particular cross-compartment interface, e.g., between a main program and a library it links against. By corrupting the data flowing through that interface, the fuzzer emulates possible cross-compartment attacks by triggering a subset of the CIV types described above (mostly data corruption):
Conffuzz has been used to fuzz 36 APIs in total, divided into two trust models:
- Sandbox: Apache + libmarkdown (e.g., Apache is the trusted victim compartment, and libmarkdown is the untrusted malicious compartment), cURL + libnghttp2, git + libcurl, etc.
- Safebox: cURL + libssl (e.g., libssl is the trusted victim compartment and cURL is the untrusted malicious compartment), GPG + libgcrypt, sudo + libapparmor, etc.
The study mostly targets compartmentalisation policies at the library granularity, but also considers modules and internal APIs.
Many of these applications and libraries/modules/internal interfaces have been compartmentalised in past research studies.
The study uncovered a total of 629 unique CIVs, allowing the malicious compartment to perform limited or arbitrary memory accesses, in read, write or execute mode, in the context of the victim. It also uncovered additional CIVs consisting of allocation and NULL pointer dereference bugs.
Real-World CIV Example 1.
Here is an example of CIV, in the sudo application, with a compartmentalisation scenario in which the authentication API (victim compartment) is safeboxed from the rest of the program (malicious compartment):
int sudo_passwd_verify(struct passwd *pw, char *pass, sudo_auth *auth,
struct sudo_conv_callback *cb) {
/* ... abbreviated ... */
sav = pass[8]; // read CIV
pass[8] = '\0'; // write CIV
} /* ... abbreviated ... */
This code belongs to the sandboxed authentication API compartment, and that function can be invoked by the untrusted compartment containing the rest of the code.
If the size of the string pass passed by the untrusted main compartment is smaller than 8 bytes, the victim will read and write past the end of that buffer.
An interesting point to note is that, outside of compartmentalised scenarios, this bug can also be triggered by passing a password whose size is smaller than 8 bytes as input to sudo: this is CVE-2022-43995.
Real-World CIV Example 2. Below are two other examples of CIV, this time when the libssl cryptographic library is safeboxed:
// CIV 1: option setting API leads to arbitrary R/W
ulong SSL_CTX_set_options(SSL_CTX *ctx, ulong op) {
return ctx->options |= op;
}
// CIV 2: cross-API object SSL_CTX with function
// pointers leads to arbitrary execution
SSL *SSL_new(SSL_CTX *ctx) {
/* ... */
s->method = ctx->method;
/* ... */
if (!s->method->ssl_new(s)) // arbitrary execution
goto err;
} /* ... */
In the first CIV, the data referenced by ctx as well as the value of op are under the control of the malicious compartment, which gives the attacker a memory read and write primitive at arbitrary locations in the context of the victim (libssl) compartment.
In the second CIV, the malicious compartment controls the function pointer ctx->method->ssl_new(), dereferenced by the victim compartment, giving the attacker a powerful arbitrary code execution primitive.
Takeaways from the Study. The main conclusion from the study is that when compartmentalising without considering interface safety, CIVs are pervasive. In other words, compartmentalisation without securing interfaces is mostly meaningless.
Another insight from the study is that there are clear CIV disparities among different APIs. There is no correlation between API size and CIV count: there are large and almost totally CIV-free APIs, and small but fully vulnerable APIs. Further, some API design patterns (e.g. modules) are highly vulnerable because of a large amount of state exposure. In terms of impact, the study concludes that CIVs are high-impact: 75% of scenarios have at least 1 write vulnerability, and 70% of read/write CIVs and 50% of execute CIVs are arbitrary in nature, meaning they give an attacker memory access anywhere in the context of the victim compartment. Finally, the authors of the study note that fixing CIVs often goes beyond writing simple checks: it requires API redesign in many cases, something that is application-specific and hard to automate.
More details on the fuzzer, applications targeted, CIVs uncovered, and analysis are given in the paper.