Escaping the V8 Sandbox by Overwriting WebAssembly Jump Table via TypedArray or ArrayBuffer (V8 < 10.0.138)

  1. Escaping the V8 Sandbox by Overwriting WebAssembly Jump Table via TypedArray or ArrayBuffer (V8 < 10.0.138)
  2. Escaping the V8 Sandbox by Overwriting WebAssembly Jump Table via TypedArray or ArrayBuffer: Part 2 (10.0.138 <= V8 < 10.3.163)

In this post, I will demonstrate a technique to escape the V8 sandbox using TypedArray or ArrayBuffer. This walkthrough assumes we already have standard sandboxed exploit primitives.

Setup

Place v8setup.py and sandbox.diff into your working directory and run v8setup.py.

[sandbox] Add new Memory Corruption API (May 20, 2022)
When enabled, this API exposes a new global ‘Sandbox’ object which contains a number of functions and objects that in effect emulate typical memory corruption primitives constructed by exploits. In particular, the ‘MemoryView’ constructor can construct ArrayBuffers instances that can corrupt arbitrary memory inside the sandbox. Further, the getAddressOf(obj) and getSizeInBytesOf(obj) functions can be used respectively to obtain the address (relative to the base of the sandbox) and size of any HeapObject that can be accessed from JavaScript.
This API is useful for testing the sandbox, for example to facilitate developing PoC sandbox escapes or writing regression tests. In the future, it may also be used by custom V8 sandbox fuzzers.

sandbox.diff corresponds to the commit above, which introduces a new memory corruption API. This allows us to simulate sandboxed exploit primitives for testing purposes.

Analysis

TypedArray

The TypedArray object in JavaScript is represented internally by the JSTypedArray class.

src/objects/js-array-buffer.tq
90
91
92
93
94
95
extern class JSTypedArray extends JSArrayBufferView {
length: uintptr;
// A SandboxedPtr if the sandbox is enabled
external_pointer: RawPtr;
base_pointer: ByteArray|Smi;
}

The JSTypedArray class contains two types of pointers:

  • The base_pointer points to the ByteArray object that stores the data.
  • The external_pointer holds the offset from the base_pointer to the actual address where the data is stored.
let arr = new BigUint64Array(1);
arr[0] = 0x4141414141414141n;
% DebugPrint(arr);

When accessing the data, V8 calculates the address by adding base_pointer and external_pointer.

Since the JSTypedArray object resides within the V8 sandbox, we can:

  • Leak the V8 sandbox base address by reading the external_pointer using the sandboxed arbitrary address read primitive.
  • Access arbitrary addresses outside the V8 sandbox via the JSTypedArray object by overwriting the external_pointer and the base_pointer with the sandboxed arbitrary address write primitive.

ArrayBuffer

The ArrayBuffer object in JavaScript is represented internally by the JSArrayBuffer class.

src/objects/js-array-buffer.tq
14
15
16
17
18
19
20
21
22
23
24
extern class JSArrayBuffer extends JSObject {
byte_length: uintptr;
max_byte_length: uintptr;
// A SandboxedPtr if the sandbox is enabled
backing_store: RawPtr;
extension: RawPtr;
bit_field: JSArrayBufferFlags;
// Pads header size to be a multiple of kTaggedSize.
@if(TAGGED_SIZE_8_BYTES) optional_padding: uint32;
@ifnot(TAGGED_SIZE_8_BYTES) optional_padding: void;
}

The JSArrayBuffer class contains a backing_store pointer, which points to the actual address where the data is stored.

let buf = new ArrayBuffer(8);
let view = new DataView(buf);
view.setBigUint64(0, 0x4141414141414141n, true);
% DebugPrint(buf);

When accessing the data, V8 reads from the backing store of the JSArrayBuffer object.

Since the JSArrayBuffer object resides within the V8 sandbox, we can access arbitrary addresses outside the V8 sandbox by overwriting the backing_store pointer using the sandboxed arbitrary address write primitive.

WebAssembly Code Space

src/wasm/wasm-code-manager.cc
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
// Try up to two times; getting rid of dead JSArrayBuffer allocations might
// require two GCs because the first GC maybe incremental and may have
// floating garbage.
static constexpr int kAllocationRetries = 2;
VirtualMemory code_space;
for (int retries = 0;; ++retries) {
code_space = TryAllocate(code_vmem_size);
if (code_space.IsReserved()) break;
if (retries == kAllocationRetries) {
V8::FatalProcessOutOfMemory(isolate, "NewNativeModule");
UNREACHABLE();
}
// Run one GC, then try the allocation again.
isolate->heap()->MemoryPressureNotification(MemoryPressureLevel::kCritical,
true);
}

When a WebAssembly module is constructed, WasmCodeManager::NewNativeModule() allocates a code_space for that module.

src/wasm/wasm-code-manager.cc
1581
1582
1583
1584
1585
if (needs_jump_table) {
jump_table = CreateEmptyJumpTableInRegionLocked(
JumpTableAssembler::SizeForNumberOfSlots(num_wasm_functions), region);
CHECK(region.contains(jump_table->instruction_start()));
}

NativeModule::AddCodeSpaceLocked() creates a jump_table at the beginning of the code space. The jump table dispatches execution to the appropriate WebAssembly functions.

src/wasm/wasm-code-manager.cc
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
// Even when we employ W^X with FLAG_wasm_write_protect_code_memory == true,
// code pages need to be initially allocated with RWX permission because of
// concurrent compilation/execution. For this reason there is no distinction
// here based on FLAG_wasm_write_protect_code_memory.
// TODO(dlehmann): This allocates initially as writable and executable, and
// as such is not safe-by-default. In particular, if
// {WasmCodeAllocator::SetWritable(false)} is never called afterwards (e.g.,
// because no {CodeSpaceWriteScope} is created), the writable permission is
// never withdrawn.
// One potential fix is to allocate initially with kReadExecute only, which
// forces all compilation threads to add the missing {CodeSpaceWriteScope}s
// before modification; and/or adding DCHECKs that {CodeSpaceWriteScope} is
// open when calling this method.
PageAllocator::Permission permission = PageAllocator::kReadWriteExecute;

bool success;
if (MemoryProtectionKeysEnabled()) {
TRACE_HEAP(
"Setting rwx permissions and memory protection key %d for 0x%" PRIxPTR
":0x%" PRIxPTR "\n",
memory_protection_key_, region.begin(), region.end());
success = SetPermissionsAndMemoryProtectionKey(
GetPlatformPageAllocator(), region, permission, memory_protection_key_);
} else {
TRACE_HEAP("Setting rwx permissions for 0x%" PRIxPTR ":0x%" PRIxPTR "\n",
region.begin(), region.end());
success = SetPermissions(GetPlatformPageAllocator(), region.begin(),
region.size(), permission);
}

The code space is mapped with RWX permissions to allow WebAssembly functions to be compiled and executed at runtime.

d8.file.execute("v8/test/mjsunit/wasm/wasm-module-builder.js");

let builder = new WasmModuleBuilder();
builder.addFunction("f1", makeSig([], [])).addBody([]).exportFunc();
builder.addFunction("f2", makeSig([], [])).addBody([]).exportFunc();
builder.addFunction("f3", makeSig([], [])).addBody([]).exportFunc();
let module = builder.toModule();

The JavaScript code above creates a WebAssembly module, which contains three empty functions exported to JavaScript.

The jump table consists of jmp instructions, where each entry corresponds to a function in the module.

We can access the exported functions using WebAssembly.Instance.prototype.exports.

let instance = new WebAssembly.Instance(module);
% DebugPrint(instance.exports);

Exported WebAssembly functions are represented internally by the JSFunction class, just like any other ordinary JavaScript function.

src/objects/js-function.tq
20
21
22
23
24
25
26
27
28
29
30
31
// This class does not use the generated verifier, so if you change anything
// here, please also update JSFunctionVerify in objects-debug.cc.
@highestInstanceTypeWithinParentClassRange
extern class JSFunction extends JSFunctionOrBoundFunction {
shared_function_info: SharedFunctionInfo;
context: Context;
feedback_cell: FeedbackCell;
@if(V8_EXTERNAL_CODE_SPACE) code: CodeDataContainer;
@ifnot(V8_EXTERNAL_CODE_SPACE) code: Code;
// Space for the following field may or may not be allocated.
prototype_or_initial_map: JSReceiver|Map;
}

The JSFunction class has an accessor named code pointing to a CodeDataContainer object.

src/objects/code.h
86
87
88
// Cached value of code().InstructionStart().
// Available only when V8_EXTERNAL_CODE_SPACE is defined.
DECL_GETTER(code_entry_point, Address)
src/objects/code.h
104
105
106
107
108
109
110
111
// Alias for code_entry_point to make it API compatible with Code.
inline Address InstructionStart() const;

// Alias for code_entry_point to make it API compatible with Code.
inline Address raw_instruction_start();

// Alias for code_entry_point to make it API compatible with Code.
inline Address entry() const;

The CodeDataContainer class has an accessor named code_entry_point pointing to the execution entry point of the function.

% DebugPrint(instance.exports.f3);

instance.exports.f3();

At this point, rdi holds the address of the Function object. The instructions from here get the call target corresponding to the function.

The instruction pointer eventually hits the third entry of the jump table, which corresponds to f3(), the function we called.

After we implement an arbitrary address write primitive, we can overwrite the jump table with our shellcode, so that it’s executed when the WebAssembly function is called.

Exploitation

Using TypedArray

The following scripts demonstrate the full exploit chain using TypedArray:

shellcode.py pwn_ta.js

Using ArrayBuffer

Similarly, here is the implementation using ArrayBuffer:

shellcode.py pwn_ab.js

Patch

[wasm] Ship code protection via mprotect (Feb 15, 2022)
Even though this is not a perfect protection, it will make it harder to write to the wasm code space because it’s not permanently RWX.

The patch above mitigates the technique demonstrated in this post by preventing WebAssembly code spaces from being permanently RWX. This prevents us from simply overwriting the memory with shellcode.