RLBox

Overview

RLBox is a toolkit for sandboxing third-party libraries. The toolkit consists of (1) a Wasm-based sandbox and (2) an API for retrofitting existing application code to interface with a sandboxed library. In this overview, we focus on the API, which abstracts over the underlying sandboxing mechanism. This lets you port your application without worrying about the Wasm sandboxing details. The Wasm-based sandbox is documented in a separate chaper.

Why do we need a sandboxing API?

Sandboxing libraries without the RLBox API is tedious and error-prone. This is especially the case when retrofitting an existing codebase like Firefox where libraries are trusted and thus the application-library boundary is blurry. To sandbox a library — and thus to move to a world where the library is no longer trusted — we need to modify this application-library boundary. For example, we need to add security checks in Firefox to ensure that any value from the sandboxed library is properly validated before it is used. Otherwise, the library (when compromised) may be able to abuse Firefox code to hijack its control flow 1. The RLBox API is explicitly designed to make retrofitting of existing application code simpler and less error-prone.2

What does RLBox provide?

RLBox ensures that a sandboxed library is memory isolated from the rest of the application — the library cannot directly access memory outside its designated region — and that all boundary crossings are explicit. This ensures that the library cannot, for example, corrupt Firefox's address space. It also ensures that Firefox cannot inadvertently expose sensitive data to the library. The figure below illustrates this idea.

RLBox explicitly isolates the library data and control flow from the application

Memory isolation is enforced by the underlying sandboxing mechanism (e.g., using Wasm3) from the start, when you create the sandbox with create_sandbox(). Explicit boundary crossings are enforced by RLBox (either at compile- or and run-time). For example, with RLBox you can't call library functions directly; instead, you must use the invoke_sandbox_function() method. Similarly, the library cannot call arbitrary Firefox functions; instead, it can only call functions that you expose with the register_callback() method. (To simplify the sandboxing task, though, RLBox does expose a standard library as described in the Standard Library.)

When calling a library function, RLBox copies simple values into the sandbox memory before calling the function. For larger data types, such as structs and arrays, you can't simply pass a pointer to the object. This would leak ASLR and, more importantly, would not work: sandboxed code cannot access application memory. So, you must explicitly allocate memory in the sandbox via malloc_in_sandbox() and copy application data to this region of memory (e.g., via strlcpy).

RLBox similarly copies simple return values and callback arguments. Larger data structures, however, must (again) be passed by sandbox-reference, i.e., via a reference/pointer to sandbox memory.

To ensure that application code doesn't unsafely use values that originate in the sandbox -- and may thus be under the control of an attacker -- RLBox considers all such values as untrusted and taints them. Tainted values are essentially opaque values (though RLBox does provide some basic operators on tainted values). To use a tainted value, you must unwrap it by (typically) copying the value into application memory -- and thus out of the reach of the attacker -- and verifying it. Indeed, RLBox forces application code to perform the copy and verification in sync using verification functions (see this chapter).

References

Build and install RLBox

First you should clone the repo:

git clone [email protected]:PLSysSec/rlbox.git

Then, setup a build folder using cmake:

cmake -S . -B ./build -DCMAKE_BUILD_TYPE=Release

Third, build:

cmake --build ./build --config Release --parallel

Finally, install:

cd build && sudo make install

Sandboxing a simple library

To get a feel for what it's like to use RLBox, we're going to sandbox a tiny library mylib. This library is very simple but exercises enough parts of RLBox to be interesting: calling functions, copying strings into the sandbox, registering and handling callbacks from the library. In this example we're going to use the noop sandbox. In a later chapter we'll extend this example to use the Wasm sandbox.

Note: We are actively working on a cleaner and easier to use API and will update this example once that API is ready. If you are curious what this API looks like, take a look at the WOFF2 sandbox in Firefox.

The library

The library four functions declared in mylib.h:

#pragma once

#ifdef __cplusplus
extern "C" {
#endif
    void hello();
    unsigned add(unsigned, unsigned);
    void echo(const char* str);
    void call_cb(void (*cb) (const char* str));
#ifdef __cplusplus
}
#endif

And implemented in mylib.c:

#include <stdio.h>
#include "mylib.h"

void hello() {
  printf("Hello from mylib\n");
}

unsigned add(unsigned a, unsigned b) {
  return a + b;
}

void echo(const char* str) {
  printf("echo: %s\n", str);
}

void call_cb(void (*cb) (const char* str)) {
  cb("hi again!");
}

Boilerplate

To get started, in our main application (main.cpp) let's first import the RLBox library and implement some necessary boilerplate:

// We're going to use RLBox in a single-threaded environment.
#define RLBOX_SINGLE_THREADED_INVOCATIONS
// All calls into the sandbox are resolved statically.
#define RLBOX_USE_STATIC_CALLS() rlbox_noop_sandbox_lookup_symbol

#include <stdio.h>
#include <cassert>
#include <rlbox/rlbox.hpp>
#include <rlbox/rlbox_noop_sandbox.hpp>

#include "mylib.h"

using namespace std;
using namespace rlbox;

// Define base type for mylib using the noop sandbox
RLBOX_DEFINE_BASE_TYPES_FOR(mylib, noop);

// Declare callback function we're going to call from sandboxed code.
void hello_cb(rlbox_sandbox_mylib& _, tainted_mylib<const char*> str);

int main(int argc, char const *argv[]) {
  // ... will fill in shortly ...
  // destroy sandbox
  sandbox.destroy_sandbox();

  return 0;
}

Why the boilerplate? RLBox has support for different kinds of sandboxing back-ends. In practice we start with the noop sandbox, which is not a real sandbox, to get our types right and only at the end change from noop to a real sandbox like Wasm. This, alas, means the RLBox types are typically generic in the sandbox type (e.g., rlbox::tainted<T, sandbox_type>); macros like RLBOX_DEFINE_BASE_TYPES_FOR define simpler types for us (e.g., we can use tainted_mylib<T>). In this simple example we only use the noop sandbox; we walk through how you modify this code to use Wasm in this chaper.

Creating sandboxes and calling sandboxed functions

Now that the boilerplate is out of the way, let's now create a new sandbox and call the hello function:

  // Declare and create a new sandbox
  rlbox_sandbox_mylib sandbox;
  sandbox.create_sandbox();

  // Call the library hello function:
  sandbox.invoke_sandbox_function(hello);

We do not call hello() directly. Instead, we use the invoke_sandbox_function() method. Once we turn on sandboxing, i.e., switch from the noop sandbox to Wasm, we won't be able to call the function directly either (e.g., because Wasm's ABI might be different from the app).

Calling sandboxed functions and verifying their return value

Let's now the add function:

  // call the add function and check the result:
  auto val = sandbox.invoke_sandbox_function(add, 3, 4);
  printf("Adding... 3+4 = %d\n", val);

  auto ok = sandbox.invoke_sandbox_function(add, 3, 4)
                   .copy_and_verify([](unsigned ret){
    printf("Adding... 3+4 = %d\n", ret);
    return ret == 7;
  });
  printf("OK? = %d\n", ok);

This call is a bit more interesting. First, we call add with arguments. Since these arguments are primitive types RLBox doesn't impose any restrictions. Second, RLBox ensures that the unsigned return value that add returns is tainted and thus cannot be used without verification. For example, Here, we call the copy_and_verify() method which copies the value into application memory and runs our verifier function:

[](unsigned ret){
      printf("Adding... 3+4 = %d\n", ret);
      return ret == 7;
}

This function (lambda) simply prints the tainted value and returns true if it is 7. A compromised library could return any value and if we use this value to, say, index an array this could potentially introduce an out-of-bounds memory access.

Calling functions with (tainted) strings

Let's now call the echo function which takes a slightly more interesting argument: a string. Here, we can't simply pass a string literal as an argument: the sandbox cannot access application memory where this would be allocated. Instead, we must allocate a buffer in sandbox memory and copy the string we want to pass to echo into this region:

  // Call the library echo function
  const char* helloStr = "hi hi!";
  size_t helloSize = strlen(helloStr) + 1;
  tainted_mylib<char*> taintedStr = sandbox.malloc_in_sandbox<char>(helloSize);
  strncpy(taintedStr
            .unverified_safe_pointer_because(helloSize, "writing to region")
         , helloStr, helloSize);

Here taintedStr is a tainted string: it lives in the sandbox memory and could be written to by the (compromised) library code concurrently. In general, it's unsafe for us to use tainted data without verification since it could be attacker controlled. In this particular case, though, we just want to copy data (helloStr specifically) to taintedStr. We do this by using the unverified_safe_pointer_because to essentially cast taintedStr to a char* the without any verification. This is safe because we are just copying helloStr to sandbox memory: at worst, the sandboxed library can overwrite the memory region pointed to by taintedStr and crash when it tries to print it.1

Note: Internally, unverified_safe_pointer_because is not actual just a cast. It also ensures (1) that the the pointer is within the sandbox and that (2) accessing helloSize bytes off the pointer would stay within the sandbox boundary.

It's worth mentionig that the string "writing to region" does not have any special meaning in the code. Rather the RLBox API asks you to provide a free-form string that acts as documentation. Essentially you are providing a string that says it is safe to remove the tainting from this type because... . Such documentation may be useful to other developers who read your code. In the above example, a write to the sandbox region cannot cause a memory safety error in the application so it's safe to remove the taint.

Now, we can just call the function and free the allocated string:

  sandbox.invoke_sandbox_function(echo, taintedStr);
  sandbox.free_in_sandbox(taintedStr);

Registering and handling callbacks

Finally, let's call the call_cb function. To do this, let's first define a callback for the function to call. We declared our callback in the boilerplate, but never defined the function. So let's do that at the end of the file:

void hello_cb(rlbox_sandbox_mylib& _, tainted_mylib<const char*> str) {
  auto checked_string =
    str.copy_and_verify_string([](unique_ptr<char[]> val) {
        assert(val != nullptr && strlen(val.get()) < 1024);
        return move(val);
    });
  printf("hello_cb: %s\n", checked_string.get());
}

This callback is called with a tainted string. To actually use the tainted string we need to verify it. To do this, we use the string verification function copy_and_verify_string() with a simple verifier:

    str.copy_and_verify_string([](unique_ptr<char[]> val) {
        assert(val != nullptr && strlen(val.get()) < 1024);
        return move(val);
    });

This verifier moves the string is not null and if it's length is less than 1KB. In the callback we simply print this string.

Let's now continue back in main. To call_cb with the callback with first need o register the callback -- otherwise RLBox will disallow the library-application call -- and pass the callback to the call_cb function:

  // register callback and call it
  auto cb = sandbox.register_callback(hello_cb);
  sandbox.invoke_sandbox_function(call_cb, cb);

Build and run

If you haven't installed RLBox, see the Install chapter.

Clone this books' repository:

git clone [email protected]:PLSysSec/rlbox-book.git
cd rlbox-book/src/chapters/examples/noop-hello-example

Build:

cmake -S . -B ./build -DCMAKE_BUILD_TYPE=Release
cmake --build ./build --config Release --parallel

Run:

$ ./build/main
Hello from mylib
Adding... 3+4 = 7
Adding... 3+4 = 7
OK? = 1
echo: hi hi!
hello_cb: hi again!
1

For single threaded applications the attacker can't overwrite the pointer because we're not calling into the sandbox before calling `strncpy.

Additional material

  • The best example of how to user RLBox is to see its use in Firefox. The Firefox code search is a great way to do this.

  • Working through the simple library example repo is a good way to get a feel for retrofitting a simple application that uses a potentially buggy library is a good next. The solution is available in the solution folder in the same repo.

  • Short tutorial on using the RLBox APIs. Note that this tutorial uses the old Lucet Wasm compiler.

  • The RLBox test suite itself has a number of examples.

  • Finally, the original academic paper explaning the RLBox and its use in Firefox RLBoxPaper at the USENIX Security 2020 and the accompanying video explanations are a good way to get an overview of RLBox.