Web Assembly (WASM) is an assembly-like language intended to make the web fast. Applications can be compiled into WASM, such as Rust, in order to make a faster internet. This article is about breaking parts of WASM to get code execution within the browser.
The WASM execution pipeline has three stages of execution: parsing and two JITs. The Low Level Interpreter (LLInt) or parsing functionality is used for the bytecode generation process for the browser.
This parser is responsible for verifying function validity, including type checks and flow control. Wasm functions have a structured control flow in the form of blocks (which can be a generic block, a loop, or an if conditional). Each block has its own expression stack and can return multiple variables.
The m_maxStackSize field of a function is needed to get track of the amount of stack slots. This value is updated at multiple points, such as a push operation. When the parsing is done, is it rounded up for stack alignment purposes.
However, this alignment is not validated for an integer overflow! By setting the m_maxStackSize to UINT_MAX (0xffffffff) we can wrap around to 0. The variable m_numCalleeLocals, which determines the stack frame size during the prolog, will not allocate any data for the stack frame but write a bunch of data!
To trigger the operation the bug, the authors wrote a Wasm function that contains 2^32 push operations. With
wildcopy bugs, we need to stop the write somehow. By using a quirk of the JS engine with it's handling of unreachable code, we can trigger the largest possible stack frame without crashing.
This exploit requires 16 vectors take on about 2GB of memory for a total of 32GB. On macOS, compression memory makes this doable! To run this, it takes about 2.5 minutes to allocate all of this memory.
With the stack frame in a weird state, we can now get a memory leak. To make this happen, the authors write a function that has 2 arguments. When processing Wasm, there are what are called slow-paths, which will call C++ functionality natively. By calling the slow path in a particular way, we can get the C++ functions to overwrite our parameters for an information leak. One of the paths gave the address of JavaScriptCore dylib and a stack address.
The offsets for writing will only go DOWN the stack. As a result, nothing useful can be overwritten within the context of our current thread. So, in order to make this exploit work, we are going to hop from our current thread into something else. By using quirks of the engine, we can write to arbitrary offsets in the positive direction.
With the arbitrary write from a particular offset, the authors can overwrite values on the victim stack. Using this, it is trivial to overwrite the RIP on the other threads stack. For leaks, we can simply read from an offset of the stack.
To read from the location on the stack, the following Wasm works:
local.get 0 ;; JavaScriptCore dylib address
i64.const <offset to gadget>
i64.add ;; the addition will write the gadget to the stack
For writing, the same primitive works but using a
local.get 1 instead.
To get arbitrary code execution with our own shellcode, we need to bypass SIP, which only allows sections of code to be RWX if mapped with MAP_JIT. So, we must map our own section to wrote our own shellcode.
This vulnerability was
patched by utilizing checked arithmetic within the generator for the various stack operations. Besides using unsigned values, it also has code to ensure that no overflows occur.