[RFC] Lightweight Fault Isolation (LFI): Efficient Native Code Sandboxing (Upstream LFI Target and Compiler Changes)

Authors: Zachary Yedidia, Tal Garfinkel, Taehyun Noh, Shravan Narayan, Sharjeel Khan, Pirama Arumuga Nainar, Nathan Egge

This RFC proposes adding Lightweight Fault Isolation (LFI) backend compiler passes and LFI-specific targets to LLVM. These changes primarily touch the MC layer of LLVM, the AArch64 and x86-64 backend, and the Clang driver.

Overview

Memory safety bugs in native code (C/C++/Assembly) constitute a majority of critical software vulnerabilities. Sandboxing is a compelling way to mitigate these bugs as it offers strong security properties, without the engineering costs of rewriting existing code in a different (memory safe) language.

Unfortunately, process based sandboxing can impose significant overheads that limit its utility. In-process sandboxing, can avoid some of these overheads (e.g. context switches in 10s of cycles vs. 1000s). However, WebAssembly—the only widely available in-process sandboxing technology— makes significant performance and compatibility compromises. These compromises make sense for the Web, where properties like platform independence are important. However, it limits its utility for sandboxing in other settings.

Lightweight Fault Isolation (LFI) is a lower level approach to in-process sandboxing that prioritizes performance and compatibility with existing code (and tools)—to support sandboxing existing C/C++/Assembly code with minimal performance overhead and engineering effort. Here we present the motivation, design, details on current implementation and performance, and plans to upstream and support LFI.

Use Cases at Google

Google has an enormous amount of third party and first party C/C++ code where memory safety is a primary concern. For example, third-party native libraries are a frequent source of critical security issues in Android. Many libraries have not been sandboxed due to process overheads, while others have been sandboxed using processes, but IPC overheads impose a significant tax.

For example, Android currently uses a sandbox process for media codecs. However, IPC overheads impose roughly a 50% overhead for audio encoding (e.g. libopus). LFI offers us a practical path to addressing these problems of security and efficiency. Eventually, we hope to make this technology available to Android applications developers who often rely on native libraries. We are also exploring LFI to mitigate bugs in third-party device drivers, which constitute the large source of local privilege escalation vulnerabilities in Android.

There has been significant interest from teams at Google, and multiple teams are supporting engineers who are already contributing to LFI’s development.

Our conversations with teams at large software and hardware companies outside of Google suggest the capabilities that LFI offers are compelling for many teams looking to harden third-party and first-party code at low cost.

Why LFI

Limitations of Process Sandboxing

The reliance of process-based sandboxes on the OS limits performance and flexibility in a number of ways. To start, OS context switches are often several orders of magnitude more expensive than function calls. Not only does this result in high IPC overheads, it scales poorly as the number of sandboxes per-core increases. In contrast, an LFI context switch is on the order of tens of cycles, very close to a normal function call. The memory overhead of processes can also be a limiting factor as the number of sandboxes scale.

Processes are also expensive to startup and destroy, which limits their utility for short lived sandboxes. Unsurprisingly, in-process sandboxing technologies like WebAssembly and eBPF have gained significant traction in settings like serverless and data plane extensibility (e.g. Envoy) where minimizing these overheads are critical. Finally, processes are entangled with the existing OS model of parallelism, concurrency, and resource management–which can make it difficult to retrofit process sandboxing in existing applications without (sometimes significant) refactoring.

In contrast, with LFI, context switches and IPC can also be close to that of a normal function call, removing scaling and IPC limitations, and per-sandbox memory overheads are minimal. LFI isolation is also orthogonal to parallelism and concurrency, making it possible to create an LFI sandbox per-thread, per-coroutine, or per-library, or have multiple threads or co-routines that all use the same sandboxed library. All these factors can make it easier, and more efficient, to add sandboxing to existing applications.

Limitations of WebAssembly

In recent years, part of our team has used WebAssembly to deploy sandboxing for third-party libraries in Firefox, and relied on it extensively in other research. We found that WebAssembly’s higher level, platform independent approach to sandboxing—while valuable in settings such as the Web—also requires significant trade-offs in performance and compatibility. These trade-offs limit its utility for sandboxing C/C++/Assembly code in existing applications where maximizing performance and minimizing engineering effort are the primary concerns.

To touch on some examples: Wasm cannot support handwritten platform specific assembly code, and its SIMD support is extremely limited, making it impractical to use for sandboxing high performance media codecs, and less than ideal for compression libraries, numerical code, cryptography, etc. that often depend on hand written assembly or platform specific intrinsics. Wasm’s requirement for precise traps (for determinism) preclude the use of many SFI optimization techniques that rely on masking and truncation. We can see these differences in performance, for example, we see a 3x-10x reduction in overhead for LFI ARM64 vs. state-of-the-art Wasm compilers (wasm2c, WAMR, and Wasmtime). Wasm also does not attempt to support existing libc’s (e.g. bionic, glibc), much of the linux system call API, relies on a different ABI (different pointer sizes), and breaks compatibility with many existing tools such as ASAN, TSAN, perf, debuggers, etc.

Design

Lightweight Fault Isolation (LFI) aims to support low level software sandboxing with minimal extra constraints. LFI eschews platform independence, and gives the compiler and runtime maximum freedom to exploit platform specific hardware or novel optimization techniques. At the same time, LFI tries to adhere as closely as possible to the existing target ABI and binary conventions (e.g. ELF and Dwarf specifications) with a few caveats (e.g. reserve registers) to maintain compatibility with existing code and tools.

Minimizing performance overhead is often what determines whether or not sandboxing can be deployed. Thus, LFI aims to give developers control over performance vs. security trade-offs, and to make use of hardware specific features when available. For example, developers can choose between full sandboxing (loads/stores/jumps), or stores-only (stores/jumps) sandboxing that provides integrity. LFI also supports MPK (jumps) which provides hardware support for zero cost sandboxing of loads and stores.

Compatibility is also critical for adoption. LFI can support nearly all existing C/C++/Assembly code without source modifications. This includes media codecs, compression libraries, etc. that can rely on tens or even hundreds of thousands of lines of handwritten assembly. Further, as LFI simply adds some additional instrumentation (like any other compiler pass), and doesn’t require deviating from normal dwarf or elf conventions, interoperability with existing debuggers and profiles can be seamless.

LFI also aims to be simple enough to enable high assurance compiler and runtime implementations. For the compiler, we are able to check that LFI based instrumented binaries are correctly sandboxed with a small stand-alone verifier.

LFI today

Our current LFI implementation supports both ARM64 and x86-64 processors. Since LFI sandboxes are in-process, sandbox context switches are just 10s of cycles, often ~100x faster or more than process-based sandboxes.

LFI’s closer to the metal approach offers a 3x-10x reduction in sandboxing overheads vs. state-of-the-art Wasm toolchains. On Spec2017, it incurs roughly 7% overhead on AArch64 vs. native code for full sandboxing, and 2% overhead when only sandboxing writes (providing integrity but not secrecy).

LFI is neutral to the system call/library interface, thus we are able to support both unmodified libraries (using the linux system call interface) and device drivers through the use of different runtimes.

The core of LFI consists of two parts: (a) a compiler pass implemented in LLVM that restricts memory accesses to within a sandbox by adding trampolines and rewriting assembly and (b) a runtime environment for libraries that restricts system call access. Our project also has several FFI tools (RLBox, lfi-bind) to enable easy and safe use of sandboxed libraries.

At present, we are only aiming to upstream the LLVM compiler changes for LFI (a). However, the runtime and other LFI components are all being developed under an open source license, and we are open to upstreaming other parts at a later stage if there is community support for making LFI a stand-alone Clang/LLVM tool.

LFI Design Details

We provided a section to explain the general LFI sandboxing scheme then explain the exact LLVM implementation with some results from our experiments.

LFI uses an architecture-specific sandboxing scheme designed to minimize performance overhead and maximize compatibility. In general, it relies on a combination of classical and modern software based fault isolation (SFI) techniques.

Sandboxed programs are restricted to a 4GiB portion of the virtual address space, within which all of their code and data (heap, stack, globals, etc…) must reside. Code pages are mapped non-writable, and data pages are mapped non-executable. The sandbox may not access any memory outside of its 4GiB region, and may not directly interact with the host OS (no system call instructions). Instead, it must perform runtime calls to have the trusted runtime environment interact with the host OS on its behalf.

When targeting LFI, the compiler must produce only instructions that are verifiably safe (i.e., guaranteed to not access memory outside the sandbox). Internally, this is implemented by rewriting a subset of machine instructions into one or more safe instructions. For the sake of exposition, we will focus on the AArch64 version here.

To start, LFI reserves a few registers to hold frequently used variables. We reserved the highest callee-saved registers since those are the least likely to be used in hand-written asm. On AArch64, x27 stores the base virtual address of the sandbox (the start of the 4GiB accessible region), and x28 may only contain valid sandbox addresses (addresses within the 4GiB accessible region). Here are some example rewrites:

//sandbox ldr by taking bottom 32-bits of x1, adding the sandbox base(x27)

ldr x0, [x1]

->

ldr x0, [x27, w1, uxtw] 



//similar, but use add to first "guard" x2, then do the ldp

ldp x0, x1, [x2]

->

add x28, x27, w2, uxtw

ldp x0, x1, [x28]

The LFI AArch64 target uses a standard LP64 ABI, even though accessible memory is restricted to 4GiB. This maintains compatibility with host code, allowing structure definitions to be shared between the host and the sandbox. The 64-bit ABI also allows us to support stores-only sandboxes (more on that in the performance section).

Rewrites are applied to the following types of instructions:

  • Memory accesses through non-sp registers.

  • Indirect branches.

  • Modifications of the stack pointer (sp must always contain a valid sandbox address).

  • Modifications of the link register (lr must always contain a valid sandbox address).

  • System calls.

  • Thread-local storage access (reads/writes of tpidr_el0).

These rewrites allow us to compile many existing libraries (including musl libc and libc++), as well as those with large bodies of hand written assembly code (e.g. libdav1d) for LFI without any modifications.

LLVM Implementation

There are two primary changes needed in LLVM to support LFI: a new target for LFI, and the LFI rewriter implementation in the MC backend.

LLVM Target

Conceptually, LFI is a new architecture subset, implemented as a “sub-architecture” similar to ARM64EC. For AArch64, LFI is enabled by using the aarch64_lfi architecture. A typical complete LFI target triple looks like aarch64_lfi-unknown-linux-musl. aarch64_lfi defaults to the load and stores LFI mode but you can change the mode by selecting specific subtarget features. For example, you can select stores-only LFI mode by enabling the +lfi-stores feature or control flow/jumps-only by enabling +lfi-jumps (for use in combination with memory protection keys or other hardware-based memory isolation). We also considered using the vendor field (aarch64-lfi-linux-musl), or enabling LFI via a flag (-fsanitize=lfi), but we believe that using a sub-architecture is the best approach.

LLVM MC Rewriter

The LLVM implementation of LFI is based on the Native Client (NaCl) auto-sandboxing assembler, which has been maintained out-of-tree at Google for ~10 years to continue support for the deprecated NaCl project, and served as a robust approach that required minimal development effort to keep functional.

In the LLVM MC layer, the MCStreamer class is responsible for emitting MCInst instructions to object code or textual assembly. We define a new MCLFIExpander class that performs LFI’s instruction rewrites during this emission process. MCStreamer is modified to have an MCLFIExpander, and, if enabled, passes the MCInst it would like to emit to the expander’s expandInst virtual method, which performs any necessary rewrites and emits new instructions. The actual expansion is performed by an architecture-specific subclass of MCLFIExpander – for now this is just AArch64MCLFIExpander and X86MCLFIExpander.cpp.

Performing rewrites at the assembler level is critical for compatibility and robustness. It allows us to transparently handle a large amount of hand-written and inline assembly (common in media codecs and in runtime libraries like libc, libc++, compiler-rt, libunwind, etc…).

Optimizations

The basic rewriter expands unsafe instructions on a per-instruction basis, without any further analysis of the surrounding instructions. This can lead to suboptimal performance due to redundant or unnecessary guards. Here are some examples of further optimizations:

  • Basic guard elimination: if the same register is guarded multiple times in the same basic block, without intervening modifications to the register, the later guards can be removed. A prototype of this optimization is implemented by modifying the MCStreamer to notify the MCLFIExpander when basic blocks begin and end, allowing the expander to track whether a guard is redundant with another in the same basic block.
  • Guard hoisting (Future Work): if a register is guarded inside a loop body, but the register is not modified in the loop body, the guard may be hoisted outside of the loop. For more aggressive hoisting, additional register(s) can be reserved specifically for hoisting. A prototype of this optimization is implemented via a Machine IR backend pass that inserts new directives to inform the MCLFIExpander of regions where guards are not necessary and a hoisting register should be used instead.
  • Stack guard elimination (Future Work): if the stack pointer is modified by a small constant offset and in the same basic block the stack pointer is accessed via a memory instruction, the guard on the modification may be removed. We do not currently have a prototype of this.

In general, the expander itself is too low-level to perform the analysis needed for optimizations. As a result, our approach to optimizations is to do the analysis in a MIR backend pass, and insert directives/metadata that is used by the expander to customize how guards are emitted. As a result, these optimizations only apply to code generated by LLVM, and not to hand-written or inline assembly. It is important to note the rewriter runs after relaxation, so relaxation must conservatively estimate the expansion and these optimizations would reduce the number of expanded instructions.

X86-64 Support

LFI also supports a sandboxing scheme for x86-64. The overall approach is similar to AArch64, using a 4GiB sandbox with MC-level rewrites, but the low-level scheme is architecture-specific, leading to differences between the two. In particular, there are two main differences:

  1. Since x86-64 supports variable-length instructions, restricting control-flow from jumping into the middle of an instruction requires either instruction bundling, or hardware CFI (i.e. CET IBT). Our current implementation uses bundling (support in LLVM for bundling was originally developed for Native Client) since it is relatively low-overhead (~4%) and does not require hardware or OS support. Bundling support was recently removed from LLVM’s generic MC layer, and the maintainer has recommended that it be reimplemented in the X86 backend instead.

  2. On X86-64, LFI uses Segue, an optimization that exploits the remaining support for segment relative addressing in x86-64 the %fs and %gs segment registers, which may be used as the base for a memory operation, but do not enforce bounds. However, LFI can still make use of segment registers to make memory accesses safe, since segment registers can be used to express an addressing mode with an addition between a 64-bit and 32-bit value. This is the exact guard operation needed by LFI – an access such as movq (%rax), %rdi is transformed into movq %gs:(%eax), %rdi, where %gs stores the base of the sandbox. %fs is used as the TLS base in the Linux x86-64 ABI, but %gs is unused. For platforms where no segment register is free (Windows), we can fall back to a scheme that only uses general-purpose registers (similar to the original Native Client SFI scheme, but with a 64-bit ABI).

LFI Runtime

A binary built for the LFI target runs within the “LFI runtime,” which can be thought of as an emulator. The runtime is responsible for loading the binary, running verification, initializing registers with appropriate values (i.e., x27 with the sandbox base), and handling transitions in/out of the sandbox. The runtime services three types of runtime calls:

  1. “System calls:” currently we build LFI binaries for a Linux target, allowing programs to make system calls according to a Linux API. During compilation, the actual system call instructions are rewritten into runtime calls that perform a “system call” operation. These are serviced by the LFI runtime, which provides a subset of the Linux API, and can block certain system calls, or apply a more fine-grained system call restriction policy.
  2. TLS reads: reads of tpidr_el0 are rewritten by the compiler into runtime calls, which load the sandbox’s TLS base. The runtime emulates the sandbox’s TLS base, meaning that tpidr_el0 is not modified when transitioning in/out of the sandbox. This is desirable for compatibility and performance (especially on x86-64, where modifications of %fs are quite costly).
  3. TLS writes: similar to TLS reads, writes to tpidr_el0 are rewritten by the compiler into runtime calls.

The sandbox uses an indirect branch to invoke runtime calls, whose code is located outside the sandbox. The indirect branch sequence for runtime calls loads from a specific known location where the runtime call entry points are stored. This location can be configured, but by default is the page before the start of the sandbox. Loading the entrypoint pointer uses a ldur instruction with a negative offset from the sandbox base register (x27). Other options include using the first page of the sandbox (the original approach), but this is undesirable because null pointer accesses will be masked to this location, or using the second page of the sandbox.

When using LFI for library sandboxing, the library is built with the LFI compiler, along with all its dependencies, into a static ELF binary. The host application uses the LFI runtime as a library to load the binary, and then may invoke individual functions inside it, similar to inter-process calls with a process-based sandbox, except without the IPC and synchronization overheads. Readers may also be familiar with WebAssembly, which uses a similar architecture: the compiler produces a binary for the WebAssembly target, which is then run within a WebAssembly runtime that may be linked with a host application.

Using LFI

Using LFI to sandbox a library involves changing how the library is built, and how the library is invoked by the application. The first step is to build the library for the LFI target – often this just involves changing the compiler used for the library build. Next, the LFI compiler is used to create a static ELF executable from the library that includes all its dependencies. This binary contains all code and static data that will be available in the sandbox.

On the host side, the application must be modified to use an FFI-like interface for invoking library functions. We have a tool called lfi-bind designed for C/C++ (host) to C (sandbox) invocation, as well as support for LFI in RLBox, a tool for C++ to C library sandboxing. Trampolines are automatically generated for each exported function, which transition in/out of the sandbox by saving and restoring the appropriate registers and state. Buffers passed into the sandboxed library must be within the sandbox, which often involves changing how they are created to use a special sandbox allocator that allocates memory from within the sandbox. Inputs received from the sandbox must be validated before use (e.g., pointers returned by the library). RLBox provides type-based compile-time enforcement that proper validation exists, and in the future, we’d like to investigate augmenting lfi-bind with static analysis tools that can provide similar guarantees when the host code is written in C rather than C++.

Current Performance

We compiled LFI with SPEC 2017 benchmarks on Apple M2 and AMD Ryzen 9 7950X alongside some Media codecs like libopus or libdav1d on Pixel 9. We ran the Media Codec by pinning them to the three different types of cores in Pixel: small, medium, and big. The cores’ processing speed increases when you go from the small core to the big core. The increased processing speed causes the overheads to decrease. You can see the results below and we summarize the results in the caption.

We profiled three different modes for LFI: LFI (full sandboxing), LFI-stores (sandboxing stores and control flow–loads unsandboxed), and LFI-jumps (only control flow sandboxed). As discussed, LFI offers the strongest isolation, LFI-stores offers only integrity—which is still useful in many situations and gain significant performance by removing load instrumentation, and LFI-jumps—which is useful in combination with hardware features like Intel MPK which can be leveraged for load/store sandboxing.


SPEC 2017 benchmarks on Apple M2 had a geometric performance mean of 6.9% for full sandboxing mode and 1.6% for stores only mode, and 0.9% for jumps mode.

SPEC 2017 benchmarks on AMD Ryzen 9 7950X had a geometric performance mean of 7.1% for L/S mode and 5.5% for stores only mode, and 4.4% for jumps mode. This added overheads on x86-64 is due to the cost of bundles.


Libopus was used to decode a giant file on Pixel 9 and we saw a geometric performance mean of 10.77% for L/S mode and 2.92% for stores only mode.

Libdav1d has many assembly files so it was a stress test for LFI. We saw a geometric performance mean of 6.58% for L/S mode and 1.93% for stores only mode.

Upstreaming and Ongoing Maintenance Effort

Our AArch64 LLVM LFI compiler changes are at lfi-project/llvm-project and we plan to upstream these changes first. These AArch64 changes are around ~4 KLOC being added to upstream LLVM and a majority of it is in LLVM backend.

As for x86-64, our current implementation is based on the original bundling implementation that has been removed in LLVM. We are already working on a new implementation for bundling in the x86 backend based on the recommendation of the maintainer who removed the original version. Once this new bundling is stable, we plan to upstream the x86-64 compiler changes alongside the newly implemented bundling in the next phase.

We have consulted many LLVM contributors on best practices, and tried our best to separate the LFI passes so we do not make it harder on current maintainers. If needed, we are ready to split the changes into smaller PRs so it is easier for reviews. We are committed to the ongoing support, maintenance, and development of the LFI project including the compiler passes in LLVM. We will adhere to the rules mentioned in LLVM’s Developer Policy for new targets.

As mentioned previously, we are only planning to upstream the LLVM compiler changes for LFI at this time. An LFI runtime and other tools are available as part of the open source lfi-project.

Extra Resources

We gave a talk about LFI at the Qualcomm Product Security Summit 2025 so you can find more information in the slides. In addition, we are giving a talk about LFI at this year’s LLVM Developers Meeting on Tuesday, October 28th at 11 AM.

3 Likes

Tagging Authors: @zyedidia @talg @Taehyun @pirama @shravanrn

Thanks very much for posting. I’m happy that this fits in the existing AArch64 ABI.

A few quick questions, which most could be answered in the patch-set, but I’m hoping it is quicker to ask here:

What does the clang driver interface for +lfi-stores and +lfi-jumps look like? I’m guessing these aren’t architectural features like -march=….+feat would they go on a separate command-line like -mlfi-features=…?

How much attention will the remainder of the backend need to pay attention to LFI constraints? For example will we need to avoid the reserved registers when LFI mode is enabled?

Will there be any support for getting an LFI mode up and running to diagnose problems? For example a change passes upstream CI but breaks a LFI enabled program that the author may not have easy access to.

For accessing tpidr_el0 there was an issue raised to support the equivalent of Arm’s -mtp=soft which uses void *__aeabi_read_tp(void); [rtabi64?] Add method for Software-based TLS Pointer Retrieval · Issue #316 · ARM-software/abi-aa · GitHub . If this is useful LFI, please do comment on that.

I’m assuming that all input objects at link-time need to be LFI compatible? With BuildAttributes we have some scope to mark objects as requiring LFI compatibility and fault combinations at link-time. Something that could be explored later.

This is an awesome proposal!

For Android we can make use of isolatedProcess which would be an out-of-process way to sandbox code. One of the advantages here is that the SELinux policy (which is set per process) can be much more restrictive than the host process. For LFI’s runtime, are there analogous ways to restrict what a sandboxed component can do? It would be nice to provide some level of restriction based on the specific component being isolated e.g. doesn’t need filesystem or network access; block `mprotect(PROT_EXEC)` etc.

Yes, w/o syscall interface restrictions any sandbox is mostly a fake. We would like to add functionality to restrict syscalls to LFI runtime at some point (hopefully soon). But it will be mostly independent of the compiler part (we just need some way to intercept syscalls).

Right now we don’t have a rich policy language supported in the LFI runtime, because we don’t need it for many libraries. A lot of what we are interested in sandboxing on Android to start (media codecs, image libraries, parsers, compression libraries, etc.) don’t really have much interesting interaction with the operating system, so the number of system calls required to support them is very small. For sure this is something we have in mind to support in the future as needed.

LFI runtime can and does restrict system calls, however, right now the policy is relatively minimal.

Is it possible to supply policy per sandboxed library? I have not found this functionality. As I read it from sources: LFI runtime permits all implemented syscalls for every library (which is a growing over time set).

Right now if you want to change what policies are allowed, you change how the runtime is built. System calls are all directed to syshandle()… (see lfi-runtime/linux/sys.c). And you can replace that with a different function with a different hardcoded policy (see lfi-runtime/linux/sys_minimal.c). sys.c is for development purposes. For Android, we will ship with a very minimal syshandle similar to what we have in sys_minimal.c. The nice thing about this approach is that it is dead simple and easy to audit. We have basically shipped something like this for years in Firefox with wasm2c + RLBox.

Could we build something that would support declarative policies per-library, sure. But it hasn’t been a priority up until now.

What does the clang driver interface for +lfi-stores and +lfi-jumps look like? I’m guessing these aren’t architectural features like -march=….+feat would they go on a separate command-line like -mlfi-features=…?

I think this is an area where it would be great to get feedback on how best to represent this. Currently these just exist as SubtargetFeatures in the backend, but we would like to be able to expose them via the Clang driver. I think a dedicated -mlfi=[full,stores,jumps] would be a good solution, but reusing an existing mechanism might be better if it is a good fit. For example, if there are other subarchitectures (e.g., arm64ec) that have their own subfeatures, we would like to use the same mechanism. At a quick glance though I don’t see anything that obviously looks like the right thing to follow.

How much attention will the remainder of the backend need to pay attention to LFI constraints? For example will we need to avoid the reserved registers when LFI mode is enabled?

The backend will need to avoid using reserved registers when compiling for LFI, but this just involves marking those registers as reserved in AArch64RegisterInfo.cpp, much like reserved registers that exist for other platforms (e.g., x18 on macOS, x23/x24/x28 on Arm64EC, etc…).

Will there be any support for getting an LFI mode up and running to diagnose problems? For example a change passes upstream CI but breaks a LFI enabled program that the author may not have easy access to.

I think it would be great to have CI for ensuring that LFI-compiled programs don’t break. The LFI runtime is public and open-source (GitHub - lfi-project/lfi-runtime: LFI runtime.) and has an lfi-run tool that can be used to run LFI-compiled binaries for testing and benchmarking.

For accessing tpidr_el0 there was an issue raised to support the equivalent of Arm’s -mtp=soft which uses void *__aeabi_read_tp(void); [rtabi64?] Add method for Software-based TLS Pointer Retrieval · Issue #316 · ARM-software/abi-aa · GitHub . If this is useful LFI, please do comment on that.

I think directly rewriting tpidr_el0 accesses is better for our use-case compared to making a function call, since we already need/have the infrastructure for MC expansion, and the expansion will need to have the specific form of an LFI runtime call, which can safely exit the sandbox. Doing this at the rewriter level also gives us the flexibility of switching to using a dedicated general-purpose register for TLS if necessary for performance (no function call/runtime call necessary) or due to platform constraints.

I’m assuming that all input objects at link-time need to be LFI compatible? With BuildAttributes we have some scope to mark objects as requiring LFI compatibility and fault combinations at link-time. Something that could be explored later.

We currently insert a NOTE section in LFI object files to indicate compatibility, but having something that more aggressively causes an error in the linker would be good. We do want all the object files to be LFI-compatible at link-time to produce a LFI executable/shared object.

Ah, I see, makes sense.

It sounds like this requires recompilation of any code expected to operate in a sandbox, and recompilation of code expecting to use the sandboxed processes. In essence it sounds like WASM without some of the restrictions or indirections needed for cross platform support and lack of trust of the compiler. E.g. it’s a sandbox in the sense of WASM rather than in the sense of system sandboxing, and so I’m not sure just how robust it would be.

Some of the restrictions being discussed in the verifier sound similar to what NaCl did originally - but I’m unclear if that’s required or simply a backup to ensure there have not been regressions in codegen that result in unsafe instructions being emitted?

There’s discussion of ABI compatibility with the host OS, but reserving a number of registers would imply that the code is not abi compatible - does the codegen use marshaling functions for entry and exit points to and from the sandbox?

Similarly there’s discussion of sharing data structures with the host, but it seems like sharing data between sandboxed and non-sandboxed code is not possible as the sandboxed code assumes the ability to truncate all pointers.

Finally I just want to confirm that this doesn’t do anything to prevent memory errors (buffer overflows, etc) inside the sandboxed process, just limits the ability to modify memory outside of the sandbox, is that correct?

>>>It sounds like this requires recompilation of any code expected to operate in a sandbox.

Yes, the sandbox is enforced by compiler instrumentation at the MCInst layer.

>>> and recompilation of code expecting to use the sandboxed processes.

No, the LFI’d library is compiled with instrumentation, the host application is compiled as normal.

>>> In essence it sounds like WASM without some of the restrictions or indirections needed for cross platform support and lack of trust of the compiler.

Wasm also doesn’t require trusted the compiler, it’s implementation dependant. For example, multiple verifiers have been built for cranelift. crocus, veriwasm. However, because LFI is much simpler, it’s easier to verify.

>>E.g. it’s a sandbox in the sense of WASM rather than in the sense of system sandboxing, and >> so I’m not sure just how robust it would be.

SFI is a fundamentally sound approach to isolation that has been studied for decades, the LFI verifier has even been verified against the ARM architecture spec. Wasm is a different animal and the assurance of a Wasm is highly implementation dependant, and also depends on how much of the spec is implemented, as it has gotten increasingly complicated over time.

>Some of the restrictions being discussed in the verifier sound similar to what NaCl did originally -

yes

>but I’m unclear if that’s required or simply a backup to ensure there have not been regressions in >codegen that result in unsafe instructions being emitted?

Verifiers have a variety of (related) uses. (1) removing the compiler from your trusted computing base— LLVM is a massive piece of code.The LFI verifier is around 400 lines, if you don’t count the disassembler (which is not particularly large). If you just have to trust the verifier, you can gain much greater confidence that your isolation is correct. And as you noted, catch implementation bugs. (2) decoupling compilation and isolation—if you want to prevent supply chain attacks, it is very nice to know that what came out of your build systems is actually sandboxed. If you want to build a serverless system, it’s nice to have the client build the code, and the server just have to verify it.

>There’s discussion of ABI compatibility with the host OS, but reserving a number of registers >would imply that the code is not abi compatible does the codegen use marshaling functions for >entry and exit points to and from the sandbox?

Entry and exist from the sandbox have to go through trampolines, e.g. to switch the stack pointer. In general the sandboxed code doesn’t interact directly with the host OS i.e. through system calls, all interaction is mediated by the runtime. But the runtime interface is largely orthogonal to the compiler portion, and in LFI our default runtime supports the normal linux system interface.

>Similarly there’s discussion of sharing data structures with the host, but it seems like sharing data >between sandboxed and non-sandboxed code is not possible as the sandboxed code assumes >the ability to truncate all pointers.

This works just fine, truncation is just being used to ensure that loads and stores are forced within sandbox bounds, but the actual addresses to the load/store inside are the same inside or outside of the sandbox, and there are no side effects so this is all transparent.

>Finally I just want to confirm that this doesn’t do anything to prevent memory errors (buffer >overflows, etc) inside the sandboxed process, just limits the ability to modify memory outside of >the sandbox, is that correct?

Yes, these are largely orthogonal issues. However, LFI can be used to strength certain mitigations. For example, SafeStack and ShadowControlStack generally rely on randomization to protect them because they run in the same protection domain as the program. With LFI we can move the protected stack (e.g. shadow control stack) outside the sandbox. Because of how structured stack access is, it’s not hard to verify that these accesses are safe.

It sounds like this requires recompilation of any code expected to operate in a sandbox.

Yes, the sandbox is enforced by compiler instrumentation at the MCInst layer.

:+1:

(This question was just a sanity check making sure I wasn’t misunderstanding the process)

and recompilation of code expecting to use the sandboxed processes.

No, the LFI’d library is compiled with instrumentation, the host application is compiled as normal.

(I’ll follow this up in my interop question below)

In essence it sounds like WASM without some of the restrictions or indirections needed for cross platform support and lack of trust of the compiler.

Wasm also doesn’t require trusted the compiler…

Sorry I was unclear: I meant “WASM attempts to achieve the same goal of being a low level sandboxed runtime, but the intended environment: cross platform, and presumed malicious code, etc means that it is constrained in ways that impact performance” - e.g this is a similar isolation/sandbox model to wasm, but because the intention is not to run arbitrarily untrusted cross platform code, it loses many of the limitations of wasm.

e.g this can generate almost completely standard assembly, with the subsequent performance improvements, because you’re not downloading and executing arbitrary untrusted code.

E.g. it’s a sandbox in the sense of WASM rather than in the sense of system sandboxing, and so I’m not sure just how robust it would be.

SFI is a fundamentally sound approach to isolation that has been studied for decades, the LFI verifier has even been verified against the ARM architecture spec.

The concern I have is the lack of isolation of memory spaces (again, discussed below in the context of interop)

Wasm is a different animal and the assurance of a Wasm

Sorry, I was not intending my comments as a “why not wasm” I was using to clarify the general execution model/intent vs “sandboxing” in say the darwin sandboxd sense.

but I’m unclear if that’s required or simply a backup to ensure there have not been regressions in
codegen that result in unsafe instructions being emitted?

Verifiers have a variety of (related) uses…

Right, so it’s essentially a back up safety enforcement mechanism: the assumption is that the generated code will be correct w.r.t to the LFI model, however compiler bugs happen and the verifier acts as a much more manageable/auditable codebase that can be used ensure that any codegen bugs (at least in the LFI semantic context) are caught before deployment.

What I’m really trying to clarify is whether the goal is “download untrusted code and trust the verifier to be 100% correct for that untrusted code, that is directly targeting bugs in the verifier”, it sounds like that is not a goal, but I’m just wanting to verify that understand.

There’s discussion of ABI compatibility with the host OS, …

Entry and exist from the sandbox have to go through trampolines, …

Similarly there’s discussion of sharing data structures with the host, …

This works just fine, truncation is just being used to ensure that loads and stores are forced within sandbox bounds…

This is what I’m not understanding, take:

struct S {
   int *ptr;
};

void some_lfi_function(S* s) {
  s->ptr[x] = 1234;
}

void some_native_function() {
  S s = ....;
  // assume some_lfi_function is the trampoline that wraps the real function
  some_lfi_function(&s);
}

If pointer loads are being truncated to remain inside the sandbox environment I don’t understand how this works?

Finally I just want to confirm that this doesn’t do anything to prevent memory errors …

Yes, these are largely orthogonal issues. …

Right, that was what I thought was the case, but I just wanted to verify that this wasn’t also dependent on some kind of magical-sufficiently-clever-compiler bounds checking :smiley:

>If pointer loads are being truncated to remain inside the sandbox environment I don’t understand >how this works?

Ah!, ok. I think I understand the confusion. Sharing data structures does not magically work, because we are in a different model of memory isolation. Stuff inside the sandbox can only access sandbox memory. Stuff outside the sandbox can see all of memory. So to share data you have two options.
(1) allocate shared object inside the sandbox—you can see this with RLBox and lfi-bind… basically the pattern is that you have to change your malloc()’s to malloc_in_sandbox() (or in your example, calloc_in_sandbox()). That way both sandbox and host application have visibility. In libraries that want you to use their allocators, this comes largely for free. Of course there is always option (2) copy stuff back and forth, but IMHO (1) is often preferable as aside from performance, you don’t have to worry about all the complexities of marshalling. This does have it’s own challenges (e.g. TOCTOU), but that may or may not matter depending on context.

BTW: for more details you might want to check out the original LFI paper which describes an earlier version of our ARM64 scheme, or the original SFI paper which even after 30 years is still wonderfully lucid and relevant.

Ok, let’s say I have something like:

struct Foo {
  int *data;
};

Foo f1 = some_lfi_protected_function1();
// or
Foo f2 = { .data = lfi_alloc(...) };
some_lfi_protected_function2(&f);

How do we ensure the pointer in the returned f1.data is within the sandbox? or that the pointer in f2 is not modified to a pointer outside the sandbox? The latter seems especially pernicious as transitive inclusion of pointers that are not intended to be used/modified by the lfi protected code seems extremely easy.

Are there annotations or similar that can be used to indicate that pointers need to be validated against the lfi sandbox bounds prior to use? Something that allows trampolines to check automatically, or force validation prior to use?

I recognize adding such annotations is difficult if the types are API definitions, and doing so automatically is difficult if those APIs use that opaque context parameter model.

Basically I’m concerned about the ability for an attacker to corrupt pointers used outside the lfi sandbox - all an attacker needs to do is ensure that corrupted pointers are not used from within sandboxed code. Essentially sharing objects that contain pointers does not appear to be safe, and I can’t work out how LFI protects against such attacks - it seems like there needs to be some mechanism that prevents sharing of such types, or that forces validation prior to use of potentially tainted values.

Right, so this is a great question!, and I think really brings us to what IHMO is the cutting edge of the usability question i.e. how do we make sandboxing easier/safer to use?

Of course, you can use lfi-bind and be careful, and there are a suprising number of situations where the interface between libraries and host code are pretty narrow, and pointers to struct’s/objects are just treated as opaque blobs passed between library API’s. But we know that approach is ultimately limited.

=====

We can treat this as one issue, or break it into two separate issues.

  1. retrofitting — how do we make sure that pointer being used by the sandbox point to objects that are allocated in the sandbox–and migrating code consuming a sandboxed library into this different model.

  2. input validation (your question)—how do we make sure that a pointer (or data e.g. an int that is an array index) being accessed from outside the sandbox is ``safe’’.

Our RLBox paper provides a good analysis of the way that things can go wrong, and describes how we solve this in Firefox using the RLBox framework, where we use the C++ type system, template, and a styled interface to sandbox functions to address these challenges.

For sure this is not the only answer, and we have been exploring other ways to achieve the same goals that are less labor intensive, don’t require C++, etc.

So we have been playing with both. I have started playing with using Clang Static Analyzer’s TaintAnalysis (with and without annotations), and with and without and additional pass for sound vs. best effort approach.

One of the other guys on our team built some stuff that does checks dynamically for linux device drivers by generating checks based on type signature information.

There are other folks who are interested in automaticly generating annotations and doing more eager safety checks/copies (in contrast to say RLBox’s lazy checking approach).

My own sense is that there is probably not “one right way” to answer this question. Rather, there are different solutions based on context.

For example, a lot of native code use is actually done through “safe languages” using wrappers. And we know there are ways to make this significantly more automated for Java and Rust—based on the research l linked, and our own investigations, and at least our group hasn’t even scatched the surface of say Python or Swift.

I think the questions of data marshaling/validation are important, but are really the next level of abstraction on top of a base sandboxing/isolation technology (like LFI). The only primitives a low-level sandboxing technology should provide is malloc/free inside of the sandbox. The rest can be done in lots of different ways, and can be reused across different sandboxing/isolation technologies (e.g. MPK-based sandbox). It’s like TCP protocol and implementation vs what data you want to send and how to marshal it. So per se I would suggest to not block this proposal on these questions.

But having said that, besides static analysis, type system, and LLMs for marshaling/validation, I was thinking about a dynamic sanitizer-like approach. We could reuse asan/tsan instrumentation with a special runtime that would detect violations of a presumed sandbox, and report them in a useful way (violation stack, heap/stack object allocation stack/size info). It could also report accessed to the sandbox memory outside of the sandbox, unless the function is marked with a special attribute, this may help to ensure that validation happens within known functions. Theoretically it can also suggest required annotations for marshaling, since it will know what objects were read/written, and if it was accessed as a singular object, or an array.
I think the implementation can be relatively simple (we have most building blocks in the sanitizers framework already), and it will work out-of-the-box w/o client code changes (as compared to type system approach), and w/o requiring a developer to understand the code well beforehand.

For convenience, here is a diff view for the AArch64 changes: Comparing llvm:release/20.x...lfi-project:lfi-integrated-20.1.7 · llvm/llvm-project · GitHub

This proposal generally looks reasonable to me, both in terms of the functionality being added, and the implementation approach.

@MaskRay As this is adding a significant chunk of new code operating at the MC layer, I’m wondering if you have any thoughts on this proposal.

Re x86-64/bundling: I’m wondering whether there has been sufficient consideration of alternatives to bundling. While bundling can probably be implemented with less complexity than it was before, I do fear the complexity (and possibly also performance impact) for this niche feature.

The root cause for bundling is to ensure that indirect jumps don’t go into the middle of instructions. But I think we could achieve a similar effect with less intrusive changes. There are three types of indirect jumps to consider:

  • Jump table: require all jumps to go through bounded (e.g. through bitmask) and validated jump tables. We can emit metadata about jump tables, which could be provided for validation.
  • Indirect call: there’s no requirement for a function pointer to be the actual address, so this could be transparently rewritten into a lookup into a validated and bounded jump table (e.g., assembler rewrite call reg → mov tmp, reg; and tmp, 0x1fff; call fs:[jump_table + 8 * tmp]).
  • Return: this is difficult to do efficiently. Ideally, this is handled by hardware CFI at no overhead. Otherwise, rewrite call to push <monotonically incremented number>; jmp tgt and ret to an indirect jump.

Unrelated question: Could you provide some more details on how you use MPK to prevent out-of-bounds loads/stores? In our WebAssembly runtime, we tried using MPK for this purpose by disabling all keys except the one permitting access to sandboxed pages, but ran into problems with glibc (IIRC, related to rseq), the cost of wrpkru, and the very low number of available keys.