WebAssembly Out of Bounds Trap Handling
V8 currently enforces memory safety in WebAssembly by using explicit bounds checks. This has several downsides:
This document describes how to safely replace the bounds checks with a signal handler. Here we will focus on memory references on Linux for x86-64. Future design documents will consider other platforms and also handling the stack overflow case.
The overall goal is to surround the WebAssembly memory with large guard regions that are marked PROT_NONE and then install a signal handler that traps attempts to access memory in the guard region. The signal handler is then responsible for determining where the fault happened and throwing an appropriate JavaScript exception. There are several components needed to make this happen:
Wasm memory instructions include a base address and an offset, such as in this example:
(i32.load offset=12 (get_local $x))
Executing this instruction would compute the sum of the offset (12), and the base, which here is stored in a local variable $x, and then load from the resulting address. This roughly corresponds to the following x64-64 instruction[1]:
mov eax, dword ptr [rdi + 12]
Wasm uses unsigned arithmetic for address calculations. The base and offset are each 32-bit values but added together using infinite precision arithmetic.[2] Thus, the largest possible memory address is 0xffffffff + 0xffffffff = 0x1fffffffe, or just under 8GB.
The minimum requirements to ensure safety, therefore, are to allocate an 8GB chunk of address space, use the first portion of it for the Wasm linear memory and mark the remainder as inaccessible (e.g. PROT_NONE). This will only work on 64-bit systems, so we will need to do something different for 32-bit. We assume, and will need to ensure, that mapping and accessing the guard pages does not lead to paging of unused memory.
For defense in depth, it makes sense to also allocate guard pages in front of the Wasm memory. This is to defend against potential code generation bugs where one or both of the operands are sign extended to 64 bits when they should have been zero extended. Protecting against this will require an 8GB guard region in front of the Wasm memory. The diagram below shows the layout of the memory under this design.
One potential concern is the amount of virtual address space available. Most 64 bit operating systems give processes 128TB of address space.[3] If we require 16GB per Wasm module, then a 128TB address space allows for a maximum of 16,000 Wasm modules per process. 16k ought to be enough for anybody.
This change will likely let us simplify grow_memory’s implementation for 64-bit systems. There is no longer any reason to move the memory, because the guard pages mean we have already reserved enough memory. Instead, we simply need need to make the newly added pages accessible.
See https://codereview.chromium.org/2396433008/ for a work-in-progress CL to add guard regions to Wasm memories.
Creating guard regions around Wasm memory happens during module instantiation. There are several cases we need to consider.
The easiest case is the a new memory is created for the module instance. In this case, we allocate enough space for the Wasm memory and its surrounding guard pages. We use the appropriate OS functions (such as mprotect) to make the guard regions inaccessible. Then we wrap this buffer in a new ArrayBuffer. Because we are managing allocation of this buffer ourselves, we mark it as external. A finalizer will free the buffer when the last reference to the ArrayBuffer has gone away.
A trickier case is when an existing ArrayBuffer is given. In this case, we still allocate a new region of memory and set up memory protection on the guard regions. However, we must also copy the contents of the previous buffer into the new one. Once this copy is done, we will use the V8 allocator to free the old buffer, and then update the backing store pointer for the ArrayBuffer to point to the new store.
In general, the backing store for an ArrayBuffer can come from anywhere. For example, it could possibly be allocated in GPU memory. In general, we cannot handle every possible backing store. The safe thing then is probably to check whether the ArrayBuffer is allocated by V8 and if not, fail to instantiate the module. Note that this probably is not unique to guard regions, but is also present with the way grow_memory is currently implemented, since grow_memory might need to move the backing store too. The newly allocated backing store will also use a finalizer to ensure it is freed at the right time.
Because V8 can be embedded in many different hosts (such as Chrome, Android WebView, NodeJS, etc.) we will not implement our own signal handler. The various hosts may provide their own signal handler, and it is nearly impossible to ensure that everything works correctly together. Instead, hosts must explicitly opt in to use signal handlers for out-of-bounds checking.
V8 will provide a new API to enable signal handling. If this API is not used, V8 will continue to generate code with bounds checks as before. Once signal handling is enabled, the host is responsible for installing their own SIGSEGV handler. We will provide an additional function to call from the signal handler that will determine whether this fault was due to an out of bounds memory access in WebAssembly.
We will provide a signal handler in the d8 shell, which can also serve as an example for other V8 embedders. For enabling this feature in Chrome, it would be best to integrate with Breakpad and Crashpad.
This section provides an overview of the V8-provided signal helper. For more details, with a particular focus on the security and correctness concerns, see this document. We must be cautious in the signal handler given that we cannot assume that memory has not been corrupted. Thus, we will use a sequence of checks of increasing complexity to rule out possible failures while determine whether a fault is one V8 can recover from. Below is pseudocode for the the V8 fault handler helper.
bool MaybeHandlerWasmFault(byte* code_addr,
byte* data_addr,
byte** target_addr)
// code_addr: the address of the instruction that faulted.
// data_addr: the address the instruction was trying to access.
// target_addr: an output parameter for the address to return to
// if V8 decides to handle the fault.
// returns true if V8 can recover from the fault, false otherwise[4]
{
if (!thread_in_wasm_code) {
return false;
}
thread_in_wasm_code = false;
restore_signal_mask();
if (acquire_spinlock()) {
for(code_object in gAllWasmCodeObjects) {
if (code_object.contains(code_addr)
&& code_object.heap.contains(data_addr) {
for(instruction in code_object.protected_instructions) {
if (instruction.addr == code_addr) {
*target_addr = code_addr + instruction.landing_pad_offset;
thread_in_wasm_code = true;
release_spinlock();
return true;
}
}
}
}
}
thread_in_wasm_code = true;
release_spinlock();
return false;
}
This function first checks a thread-local variable indicating whether the thread was executing Wasm code (we will call this the TLS check). If it was not, we tell the caller to report a crash.
Next, it restores the signal mask to guard against crashes in the signal handler itself. Because we have cleared the thread_in_wasm_code flag, the TLS will fail and this would be reported as a crash.
Then we attempt to acquire the spinlock protecting the fault location table. The normal case for why this would fail is if the thread_in_wasm_code flag was not clear. We may also choose to add a timeout here to protect against deadlocks.
Having acquired the spinlock, we search a list of Wasm code objects for one whose code and associated data range include the faulting instruction and the address it was trying to access. If one was found, we pass the address of the instruction’s landing pad to the calling signal handler and return true.
The fault location table is used by the signal handler to determine whether a fault originated for Wasm code, and to give enough information to continue with the JS exception throwing process. We’ll consider two versions here: the simple version and an improved version. The simple version contains basically the smallest amount of information that could possibly work, while the improved version contains more information that will allow us to avoid generating too much code.
This table will be kept outside of the GC Heap for several reasons:
Later on there is a section that discusses the possibility of implementing this feature in terms of existing data structures on the GC heap.
The diagram below shows the simple version:
The simple version is linked list with information about the code objects generated by Wasm. The signal handler simply traverses the list and checks whether the faulting program counter (PC) is within each code object. Each module’s entry in the table also includes an array of offsets to memory instructions and offsets to trap code for each memory instruction.
This table will be allocated outside of garbage collected memory. The reason is that the table needs to stay in a well-known location for the signal handler to find it, which is difficult in the presence of a moving GC. A module’s entry in the table will be removed when the corresponding module instance is destroyed. Since the GC may move the code object that is pointed to in this table, we will need to either add relocation information to update the table any time the code object is moved or make these changes directly in Code::Relocate. In order to simplify this part, we will only have one raw pointer for each object in the table, and any other pointers will be offsets relative to this pointer.
The simple version still requires code to be generated for each memory reference to throw a JS exception. This generated code mostly consists of plumbing some metadata around. In the improved fault location table, we try to store this information in the table instead. The diagram below shows the updated version, with the changes highlighted in blue.
Throwing a Wasm out of bounds exception in JavaScript[5] needs a few parameters: the exception type, a source position, and a context. Using the simple table, these parameters are hardcoded in the trap code. In this improved case, we store this data in a table and use a single trap handler to throw the exception.
While the exception type and source position is straightforward, the context is a little less clear. One option is to store the context in the table, as shown in the diagram above. This seems problematic, however, as at the very least the GC will have more bookkeeping to do if the context moves. Another option is to see if we can reliably find a context on the stack.
The current plan is to implement the simple version of the table, since many of the pieces are already prototyped. Once that is done, we can switch to the improved version. The main benefit of the improved table will be significantly less generated code.
There are several factors that complicate modifying the fault location table. Modifications are necessary when new Wasm modules are instantiated and destroyed. Some of the complicating factors are:
See Mark Seaborn’s document for some additional hazards involving signal handlers.
To overcome these challenges, the fault location table will be protected by a spinlock. The advantage of using a spinlock is that we can implement it without relying on any library state. The lock should very rarely be contended, so spinning should not be too expensive. Any code that modifies the fault location table will do so while holding the lock, and the signal handler will acquire the lock before accessing the table.
This spinlock introduces the possibility of deadlocks in these ways:
We will avoid these issues by ensuring that we only take the spinlock while the thread_in_wasm_code flag is clear. Because the signal handler immediate exits if the thread was not in Wasm code, it will never attempt to take the lock if the same thread is already holding it.
The design presented here uses an array of pointers to code object descriptors coupled with a linear search, primarily because of its simplicity.
A desirable feature for this data structure is that descriptors never move in memory until they are destroyed. The reason is that moving entries in the table requires updating the corresponding code object’s relocation information so that the garbage collector knows the new location to update when code changes.
If linear time is unacceptable, it shouldn’t be too much trouble to sort the array and use a binary search or to use a binary tree or even a hash table instead.
The compiler will need to be modified to generate the fault location table, among other things. The fault location table contains physical code addresses, so it cannot be generated until very late in the compiler. However, the Wasm frontend needs to communicate where its memory references are so the code generator knows when to generate this table. We will facilitate this by adding a new ProtectedLoad and analogous ProtectedStore instruction. These instructions work the same as the usual Load and Store instructions, except that they include extra parameters for the metadata needed for trap handling. The behavior of these instructions differs depending on whether we are using the simple or improved fault location table.
The fault location table is finally written out during the Wasm linking phase, since by this point all of the code addresses have been finalized.
In this version, ProtectedLoad and ProtectedStore take a context parameter, representing the JS context, and a source position parameter, indicating which instruction in the Wasm module caused the fault.
The code generator emits a memory load or store instruction, but also adds and out of line landing pad to the end of the function that throws an exception. This code is essentially the same as was generated for a bounds check failure. Finally, the code generator records the location of the memory reference and an offset in the fault location table so that the signal handler can find the landing pad.
The diagram below shows the relationship between a fault location table entry and the Wasm code.
Each memory reference in the code object has a slot in the table entry, as well as an out of line trap handler/landing pad. In each case, the landing pad consists of code to call Runtime_ThrowWasmError with the correct context and source location.
The improved fault location table includes enough metadata that we do not have to generate any per-memory reference code. Instead, the code generator records in the fault location table each memory reference and its Wasm source position. There will be a single global trap handler which will read the position information out of the fault location table and throw an exception. This arrangement is shown in the diagram below.
The code will be structured to make a clear separation between the rest of V8 and the code that accesses the fault handling data, including the signal handler. This will make it easier to audit the code and make sure the invariants that the signal handler relies on are preserved. We will follow a convention that code is allowed to call into the signal handler-related code, but the signal handler-related code cannot call out (other than to return to the correct landing pad). In addition, the signal handler-related code will be protected by an OWNERS file that requires a review from someone on the Chrome Security Team to change.
By the time a segmentation fault happens, there is very little that can safely be assumed about the state of the process memory. As such, this feature is security-sensitive. These security concerns are discussed in detail here. The main technique for mitigating the security threats is to use a sequence of checks progressing from simple to more complex, and designing each layer of checks to fail in favor of sending a crash report rather than handling a bogus out of bounds error.
There will be tests to exercise at least the following behaviors:
One of the main goals for making this change is to improve performance. We will evaluate the performance in three categories:
We will use the v8-perf benchmarks to evaluate the performance. We want to show an increase in performance on the Wasm benchmarks for item (1), a decrease is code size on the Wasm benchmarks for item (2), and no reduction in compile- and runtime performance on the other benchmarks for (3).
Much of the complexity in the design so far involves creating and maintaining the fault location table outside of the garbage collector’s heap. Another option is to reuse a lot of the existing functionality in V8. The signal handler would do the following steps:
This approach would require additional relocation information be added that would include the same information about memory references as in the fault location table.
In many ways, this design is simpler, and it is able to reuse a lot of existing, well tested code in V8. However, there are several security concerns.
First, it greatly increases the amount of code we need to audit to ensure the trap handler is safe. Similarly, it couples the trap handler to potentially changing assumptions in other parts of V8. For example, a seemingly innocuous change elsewhere in V8 might lead to the signal handler depending on state that is unreliable during a signal handler.
Secondly, this approach depends on data structures that are near to the code and other V8 or JS heap data. This makes it more likely to be corrupted if something goes wrong.
Determining whether a fault occurred in Wasm code and maintaining the fault location table is complicated by the fact that Wasm code is intermingled with other JS code and subject to being moved by the garbage collector. If we could ensure Wasm code never moves then the GC would never have to update the fault location table once it has been created. If we could move the Wasm code to its own part of the heap then determine whether a fault came from Wasm code is simply a matter of checking whether the faulting instruction pointer is within the Wasm code region.
V8’s garbage collector already has a notion of spaces. One of these is the large object space, which has a lot of the properties we would want here. Each object in the large object space is a single mmapped object, and therefore never moves once it is created.
This proposal is to add a new space for Wasm code objects. This space would be set up so that the GC does not move objects in the the space. We could also modify the allocator to reserve a large block of address space from which to allocate Wasm objects, thereby simplifying the check for whether a fault was from Wasm code.
Despite these advantages, this change is a rather significant shift in the GC design, basically treating WebAssembly as a special case. Having immovable code objects also makes the heap prone to fragmentation and complicates the allocation bookkeeping. Some of the fragmentation issues could be alleviated by grouping Wasm code objects (i.e. functions) from each module into one large allocation from the Wasm code space.
A less intrusive variant of putting the Wasm code in a separate GC space is to set aside a large chunk of address space for V8-generated code. This would allow us to add another extremely simple check to the signal handler before having to traverse the fault location table. V8 already keeps each isolate’s code confined to a 2GB region. In this change, V8 would first reserve a large (say, several terabytes) region of address space and then each isolate would select a 2GB region out of this space for its own generated code.
The communication between isolates would be minimal. A bitmask would probably be needed to track which 2GB code regions are in use. Isolates would use either a lock or atomic operations to set a bit in the bitmask to claim a region, and then clear the bit to release it when the isolate shuts down. Once a region has been claimed, the isolate will be able to continue without any changes.
[1] See http://goo.gl/6knvFe to explore this example further.
[2] See https://github.com/WebAssembly/design/blob/master/AstSemantics.md#linear-memory for the relevant parts of the Wasm spec.
[3] See, for example, https://msdn.microsoft.com/en-us/library/windows/desktop/aa366778(v=vs.85).aspx#memory_limits.
[4] In Chrome, for example, if this function returns false then Chrome would send a crash report.
[5] Accomplished by calling Runtime_ThrowWasmError.