RFC-0082: Running unmodified Linux programs on Fuchsia

RFC-0082: Running unmodified Linux programs on Fuchsia
StatusAccepted
Areas
  • Foreign ABI Compatibility
Description

This document proposes a mechanism for running unmodified Linux programs on Fuchsia.

Issues
Gerrit change
Authors
Reviewers
Date submitted (year-month-day)2021-02-11
Date reviewed (year-month-day)2021-03-25

Summary

This document proposes a mechanism for running unmodified Linux programs on Fuchsia. The programs are run in a userspace process whose system interface is compatible with the Linux ABI. Rather than using the Linux kernel to implement this interface, we will implement the interface in a Fuchsia userspace program, called starnix. Largely, starnix will serve as a compatibility layer, translating requests from the Linux client program to the appropriate Fuchsia subsystem. Many of these subsystems will need to be elaborated in order to support all the functionality implied by the Linux system interface.

Motivation

In order to run on Fuchsia today, software needs to be recompiled from source to target Fuchsia. In order to reduce the amount of source modification needed to run on Fuchsia, Fuchsia offers a POSIX compatibility layer, POSIX Lite, that this software can target. POSIX Lite is layered on top of the underlying Fuchsia System ABI as a client library.

However, POSIX Lite is not a complete implementation of POSIX. For example, POSIX Lite does not contain parts of POSIX that imply mutable global state (e.g., the kill function) because Fuchsia is designed around an object-capability discipline that eschews mutable global state to provide strong security guarantees. Instead, software that uses POSIX Lite needs to be modified to use the Fuchsia system interface directly for those use cases (e.g., the zx_task_kill function).

This approach has worked well so far because we have had access to the source code for the software we needed to run on Fuchsia, which has let us recompile the software for the Fuchsia System ABI as well as modify parts of the software that need to be adapted to an object-capability system.

As we expand the universe of software we wish to run on Fuchsia, we are encountering software that we wish to run on Fuchsia that we do not have the ability to recompile. For example, Android applications contain native code modules that have been compiled for Linux. In order to run this software on Fuchsia, we need to be able to run binaries without modifying them.

Design

The most direct way of running Linux binaries on Fuchsia would be to run those binaries in a virtual machine with the Linux kernel as the guest kernel in the virtual machine. However, this approach makes it difficult to integrate the guest programs with the rest of the Fuchsia system because they are running in a different operating system from the rest of the system.

Fuchsia is designed so that you can bring your own runtime, which means the Fuchsia system does not impose an opinion about the internal structure of components. In order to interoperate as a first-class citizen with the Fuchsia system, a component need only send and receive correctly formatted messages over the appropriate zx::channel objects.

Rather than running Linux binaries in a virtual machine, starnix creates a Linux runtime natively in Fuchsia. Specifically, a Linux program can be wrapped with a component manifest that identifies starnix as the runner for that component. Rather than using the ELF Runner directly, the binary for the Linux program is given to starnix to run.

In order to execute a given Linux binary, starnix manually creates a zx::process with an initial memory layout that matches the Linux ABI. For example, starnix populates argv and environ for the program as data on the stack of the initial thread (along with the aux vector) rather than as a message on the bootstrap channel, as this data is populated in the Fuchsia System ABI.

System calls

After loading the binary into the client process, starnix registers to handle all the syscalls from the client process (see Syscall Mechanism below). Whenever the client issues a syscall, the Zircon kernel transfers control to starnix, which decodes the syscall according to Linux syscall conventions and does the work of the syscall.

For example, if the client program issues a brk syscall, starnix will manipulate the address space of the client process using the appropriate zx::vmar and zx::vmo operations to change the address of the program break of the client process. In some cases, we might need to elaborate the ability for one process (i.e., starnix) to manipulate the address space of another process (i.e., the client), but early experimentation indicates that Zircon already contains the bulk of the machinery needed for remote address-space manipulation.

As another example, suppose the client program issues a write syscall. To implement file-related functionality, starnix will maintain a file descriptor table for each client process. Upon receiving a write syscall, starnix will look up the identified file descriptor in the file descriptor table for the client process. Typically, that file descriptor will be backed by a zx::channel that implements the fuchsia.io.File FIDL protocol. To execute the write, starnix will format a fuchsia.io.File#Write message containing the data from the client address space (see Memory access) and send that message through the channel, similar to how POSIX Lite implements write in a client library.

Global state

To handle syscalls that imply mutable global state, starnix will maintain some mutable state shared between client processes. For example, starnix will assign a pid_t to each client process it runs and maintain a table mapping pid_t to the underlying zx::process handle for that process. To implement the kill syscall, starnix will look up the given pid_t in this table and issue a zx_task_kill syscall on the associated zx::process handle.

In this way, each starnix instance serves as a container for related Linux processes. If we wish to have strong isolation guarantees between two Linux processes, we can run those processes in separate starnix instances without the overhead (e.g., scheduling complexities) of running multiple virtual machines.

Each starnix instance will also expose its global state for use by other Fuchsia processes. For example, starnix will maintain a namespace of AF_UNIX sockets. This namespace will be accessible both from Linux binaries run by starnix and from Fuchsia binaries that communicate with starnix over FIDL.

The Linux system interface also implies a global file system. As Fuchsia does not have a global file system, starnix will synthesize a "global" file system for its client processes from its own namespace. For example, starnix will mount /data/root from its own namespace as / in the global file system presented to client processes. Other mount points, such as /proc can be implemented internally by starnix, for example by consulting its table of running processes.

Security

As much as possible, starnix will build upon the security mechanisms of the underlying Fuchsia system. For example, when interfacing with system services, such as file systems, networking, and graphics, starnix will serve largely as a translation layer, reformatting requests from the Linux ABI to the Fuchsia System ABI. The system services will be responsible for enforcing their own security invariant, just as they do for every other client. However, starnix will need to implement some security mechanisms to protect access to its own services. For example, starnix will need to determine whether one client process is allowed to kill another client process.

To make these security decisions, starnix will track a security context for each client process, including a uid_t, gid_t, effective uid_t, and effective gid_t. Operations that require security checks will use this security context to make appropriate access control decisions. Initially, we expect this mechanism to be used infrequently, but as our use cases grow more sophisticated, our needs for access control are also likely to grow more complex.

As she is spoke

When faced with a choice for how starnix ought to behave in a certain situation, the design favors behaving as close to how Linux behaves as feasible. The intention is to create an implementation of the Linux interface that can run existing, unmodified Linux binaries. Whenever starnix diverges from Linux semantics, we run a risk that some Linux binary will notice the divergence and behave improperly.

To be able to discuss this design principle more easily, we say that starnix implements Linux as she is spoke, which is to say with all the beauty, ugliness, coincidences, and quirks of a real Linux system.

In some cases, implementing the Linux interfaces as she is spoke will require adding functionality to a Fuchsia service to provide the require semantics. For example, implementing inotify requires support from the underlying file system implementation in order to work efficiently. We should aim to add this functionality to Fuchsia services in a way that integrates well with the rest of the functionality exposed by the service.

Implementation

We plan to implement starnix as a Fuchsia component, specifically a normal userspace component that implements the runner protocol. We plan to implement starnix in Rust to help avoid privilege escalation from the client process to the starnix process.

Executive

One of the core pieces of starnix is the executive, which implements the semantic concepts in the Linux system interface. For example, the executive will have objects that represent threads, processes, and file descriptions.

The executive will be structured such that it can be unit tested independently from the rest of the starnix system. For example, we will be able to unit test that duplicating a file descriptor shares an underlying file description without needing to run a process with the Linux ABI.

Linux syscall definitions

In order to implement Linux syscalls, starnix needs a description of each Linux syscall as well as the userspace memory layout of any associated input or output parameters. These are defined in the Linux uapi, which is a freestanding collection of C headers. To make use of these definitions in Rust, we will use Rust bindgen to generate Rust declarations.

The Linux uapi evolves over time. Initially, we will target the Linux uapi from Linux 5.10 LTS, but we will likely need to adjust the exact version of the Linux uapi we support over time.

Syscall mechanism

The initial implementation of starnix will use Zircon exceptions to trap syscalls from the client process. Specifically, whenever the client process attempts to issue a syscall, Zircon will reject the syscall because Zircon requires syscalls to be issued from within the Zircon vDSO, which the client process is unaware exists.

Zircon rejects these syscalls by generating a ZX_EXCP_POLICY_CODE_BAD_SYSCALL exception. The starnix process will catch these exceptions by installing an exception handler on each client process. To receive the parameters for the syscall, starnix will use zx_thread_read_state to read the registers from the thread that generated the exception. After processing the syscall, starnix sets the return value for the syscall using zx_thread_write_state and then resumes the thread in the client process.

This mechanism works but is unlikely to have high enough performance to be useful. After we build out a sufficient amount of starnix to run Linux benchmarks, we will likely want to replace this syscall mechanism with a more efficient mechanism. For example, perhaps starnix will associate a zx::port for handling syscalls from the client process and Zircon will queue a packet to the zx::port with register state of the client process. When we have benchmarks in place, we can prototype a variety of approaches and select the best design at that time.

Memory access

The initial implementation of starnix will use the zx_process_read_memory and zx_process_write_memory to read and write data from the address space of the client process. This mechanism works, but is undesirable for two reasons:

  1. These syscalls are disabled in production builds due to security concerns.
  2. These syscalls are vastly more expensive than reading and writing memory directly.

After we build out a sufficient amount of starnix to run Linux benchmarks, we will want to replace this mechanism with something more efficient. For example, perhaps starnix will restrict the size of the client address space and map each client's address space into its own address space at some client-specific offset. Alternatively, perhaps when the starnix services a syscall from a client, Zircon will arrange for that client's address space to be visible from that thread (e.g., similar to how kernel threads have visibility into the address space of userspace process when servicing syscalls from those processes).

As with the syscall mechanism, we can prototype a variety of approaches and select the best design once we have more running code to use to evaluate the approaches.

Interoperability

We will develop starnix using a test-driven approach. Initially, we will use a naively simple implementation that is sufficient to run basic Linux binaries. We have already prototyped an implementation that can run a -static-pie build of a hello_world.c program. The next step will be to clean up that prototype and teach starnix how to run a dynamically linked hello_world.c binary.

After running these basic binaries, we will bring up unit test binaries from various codebases. These binaries will help ensure that our implementation of the Linux ABI is correct (i.e., as Linux is spoke). For example, we will run some low-level test binaries from the Android source tree as well as binaries from the Linux Test Project.

Performance

Performance is a critical aspect of this project. Initially, starnix will perform quite poorly because we will be using inefficient mechanisms for trapping syscalls and for access client memory. However, those are areas that we should be able to optimize substantially once we have sufficient functionality to run benchmarks in the Linux execution environment.

In addition to optimizing these mechanisms, we also have the opportunity to offload high-frequency operations to the client. For example, we can implement gettimeofday directly in the client address space by loading code into the client process before transferring control to the Linux binary. For example, if the Linux binary invokes gettimeofday through the Linux vDSO, starnix can provide a shared library in place of the Linux vDSO that implements gettimeofday directly by calling through to the Zircon vDSO.

Security considerations

This proposal has many subtle security considerations. There is a trust boundary between the starnix process and the client process. Specifically, the starnix process can hold object-capabilities that are not fully exposed to the client. For example, the starnix process maintains a file descriptor table for each client process. One client process should be able to access handles stored in its file descriptor table but not handles stored in the file descriptor table for another process. Similarly, starnix maintains shared mutable state that clients can interact with only subject to access control.

To provide this trust boundary, starnix runs in a separate userspace process from the client processes. To help avoid privilege escalation, we plan to implement starnix in Rust and to use Rust's type system to avoid type confusion. We also plan to use Rust's type system to clearly distinguish client data, such as addresses in the client's address space and data read from the client address space, from reliable data maintained by starnix itself.

Additionally, we need to consider the provenance of the Linux binaries themselves because starnix runs those binaries directly, rather than, for example, in virtual machine or SFI container. We will need to revisit this consideration in the context of a specific, end-to-end product use case that involves Linux binaries.

The access control mechanism within starnix will require a detailed security evaluation, ideally including direct participation from the security team in its design and, potentially, implementation. Initially, we expect to have a simple access control mechanism. As the requirements for this mechanism grow more sophisticated, we will need further security scrutiny.

Finally, the designs for the high-performance syscall and client memory mechanisms will need careful security scrutiny, especially if we end up using an exotic address space configuration for starnix or attempt to directly transfer register state from the client thread to a starnix thread.

Privacy considerations

This design does not have any immediate privacy considerations. However, once we have a specific, end-to-end product use case that involves Linux binaries, we will need to evaluate the privacy implications of that use case.

Testing

Testing is a central aspect of building starnix. We will directly unit test the starnix executive. We will also build out our implementation of the Linux system interface by attempting to pass test binaries intended to run on Linux. We will then run these binaries in continuous integration to ensure that starnix does not regress.

We will also compare running Linux binaries in starnix with running those same binaries in a virtual machine on Fuchsia. We expect to be able to run Linux binaries more efficiently in starnix, but we should validate that hypothesis.

Documentation

At this stage, we plan to document starnix through this RFC. Once we get non-trivial binaries running, we will need to document how to run Linux binaries on Fuchsia.

Drawbacks, alternatives, and unknowns

There is a large design space to explore for how to run unmodified Linux binaries on Fuchsia. This section summarizes the main design decisions.

Linux kernel

An important design choice is whether to use the Linux kernel itself to implement the Linux system interface. In addition to building starnix, we will also build a mechanism for running unmodified Linux binaries by running the Linux kernel inside a Machina virtual machine. This approach has a small implementation burden because the Linux kernel is designed to run inside a virtual machine and the Linux kernel already contains an implementation of the hundreds of syscalls that make up the Linux system interface.

There are several ways we could use the Linux kernel. For example, we could run the Linux kernel in a virtual machine, we could use User-Mode Linux (UML) or we could use the Linux Kernel Library (LKL). However, regardless of how we run it, there is a large cost to running an entire Linux kernel in order to run Linux binaries. At its core, the job of the Linux kernel is to reduce high-level operations (e.g., write) to low-level operations (e.g., DMA data to an underlying piece of hardware). This core function is counter-productive for integrating Linux binaries into a Fuchsia system. Instead of reducing a write operation to a DMA, we wish to translate a write operation into a fuchsia.io/File.Write operation, which is at an equivalent semantic level.

Similarly, the Linux kernel comes with a scheduler, which controls the threads in the processes it manages. The purpose of this functionality is to reduce high-level operations (e.g., run a dozen concurrent threads) to low-level operations (e.g., execute this time slice on this processor). Again, this core functionality is counter-productive. We can compute a better schedule for the system as a whole if the threads running for each Linux binary are actually Zircon threads scheduled by the same scheduler as all the other threads in the system.

Environment

Once we have decided to implement the Linux system interface directly using the Fuchsia system, we need to choose where to run that implementation.

In-process

We could run the implementation in the same process as the Linux binary. For example, this approach is used by POSIX Lite to translate POSIX operations into Fuchsia operations. However, this approach is less desirable when running unmodified Linux binaries for two reasons:

  1. If we run the implementation in-process, we will need to "hide" the implementation from the Linux binary because Linux binaries do not expect the system to be running (much) code in their process. For example, any use of thread-local storage by the implementation must take care not to collide with the thread-local storage managed by the Linux binary's C runtime.

  2. Many parts of the Linux system interface imply mutable global state. An in-process implementation would still need to coordinate with an out-of-process server to implement those parts of the interface correctly.

For these reasons, we have chosen to start with an out-of-process server implementation. However, we will likely offload some operations from the server to the client for performance.

Userspace

In this approach, the implementation runs in a separate userspace process from the Linux process. This approach is the one we have selected for starnix. The primary challenges with this approach are that we need to carefully design the mechanisms we use for syscalls and client memory access to give sufficient performance. There is some unavoidable overhead to involving a second userspace process because we will need to perform an extra context switch to enter that process, but there is evidence from other systems that we can achieve excellent performance.

Kernel

Finally, we could run the implementation in the kernel. This approach is the traditional approach for providing foreign personalities for operating systems. However, we would like to avoid this approach in order to reduce the complexity of the kernel. Having a kernel that follows a clear object-capability discipline makes reasoning about the behavior of the kernel much easier, resulting in better security.

The primary advantage that an in-kernel implementation offers over a userspace implementation is performance. For example, the kernel can directly receive syscalls and already has a high-performance mechanism for interacting with client address spaces. If we are able to achieve excellent performance with a userspace approach, then there will be little reason to run the implementation in the kernel.

Async signals

Linux binaries expect the kernel to run some of their code in async signal handlers. Fuchsia currently does not contain a mechanism for directly invoking code in a process, which means there is no obvious mechanism for invoking async signal handlers. Once we encounter a Linux binary that requires support for async signal handlers, we will need to devise a way to support that functionality.

Futexes

Futexes work differently on Fuchsia and Linux. On Fuchsia, futexes are keyed off virtual addresses whereas Linux provides the option to key futexes off physical addresses. Additionally, Linux futexes offer a wide variety of options and operations that are not available on Fuchsia futexes.

In order to implement the Linux futex interface, we will either need to implement futexes in starnix or add functionality to the Zircon kernel to support the functionality required by Linux binaries.

Prior art and references

There is a large amount of prior art for running Linux (or POSIX) binaries on non-POSIX systems. This section describes two related systems.

WSL1

The design in this document is similar to the first Windows Subsystem for Linux (WSL1), which was an implementation of the Linux system interface on Windows that was able to run unmodified Linux binaries, including entire GNU/Linux distributions such as Ubuntu, Debian, and openSUSE. Unlike starnix, WSL1 ran in the kernel and provided a Linux personality for the NT kernel.

Unfortunately, WSL1 was hampered by the performance characteristics of NTFS, which do not match the expectations of Linux software. Microsoft has since replaced WSL1 with WSL2, which provides similar functionality by running the Linux kernel in a virtual machine. In WSL2, Linux software runs against an ext4 file system, rather than an NTFS file system.

An important cautionary lesson we should draw from WSL1 is that the performance of starnix will hinge on the performance of the underlying system services that starnix exposes to the client program. For example, we will need to provide a file system implementation with comparable performance to ext4 if we want Linux software to perform well on Fuchsia.

QNX Neutrino

QNX Neutrino is a commercial microkernel-based operating system that provides a high-quality POSIX implementation. The approach described in this document for starnix is similar to the proc server in QNX, which services POSIX calls from client processes and maintains the mutable global state implied by the POSIX interface. Similar to starnix, proc is a userspace process on QNX.