Escaping V8 Sandbox via WebAssembly JIT Spraying: Part 2 (11.0.4 <= V8 < 12.2.170)

  1. Escaping V8 Sandbox via WebAssembly JIT Spraying (V8 < 10.6.24)
  2. Escaping V8 Sandbox via WebAssembly JIT Spraying: Part 2 (11.0.4 <= V8 < 12.2.170)

In Part 1, I demonstrated how to escape the V8 sandbox by overwriting the call target stored in the WasmInternalFunction object. This technique was eventually mitigated when V8 moved the call target pointer into the External Pointer Table, rendering it immutable from within the sandbox.

In this follow-up post, I will introduce an alternative technique that exploits the WebAssembly lazy compilation mechanism. By corrupting the jump_table_start field within the WasmInstanceObject—which remained exposed in the sandbox in the affected versions—we can hijack control flow and redirect execution to our JIT-sprayed shellcode.

Setup

Run v8setup.py in your working directory.

Analysis

Calling WebAssembly Function

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

let builder = new WasmModuleBuilder();

builder.addFunction("f", makeSig([], [])).addBody([]).exportFunc();
builder.addFunction("g", makeSig([], [])).addBody([]);
builder.addFunction("h", makeSig([], [])).addBody([]);

let instance = builder.instantiate();

instance.exports.f();

The JavaScript code above creates a WebAssembly module containing three functions, and calls the first one.

The JavaScript function call is handled by Builtins_CallFunction_ReceiverIsAny(), which is generated by Builtins::Generate_CallFunction_ReceiverIsAny().

src/builtins/builtins-call-gen.cc
31
32
33
void Builtins::Generate_CallFunction_ReceiverIsAny(MacroAssembler* masm) {
Generate_CallFunction(masm, ConvertReceiverMode::kAny);
}
src/builtins/x64/builtins-x64.cc
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
// ----------- S t a t e -------------
// -- rax : the number of arguments
// -- rdx : the shared function info.
// -- rdi : the function to call (checked to be a JSFunction)
// -- rsi : the function context.
// -----------------------------------

__ movzxwq(
rbx, FieldOperand(rdx, SharedFunctionInfo::kFormalParameterCountOffset));
__ InvokeFunctionCode(rdi, no_reg, rbx, rax, InvokeType::kJump);

Builtins::Generate_CallFunction() calls MacroAssembler::InvokeFunctionCode() to generate the function invocation sequence.

src/codegen/x64/macro-assembler-x64.cc
3489
3490
3491
3492
3493
3494
3495
3496
switch (type) {
case InvokeType::kCall:
CallJSFunction(function);
break;
case InvokeType::kJump:
JumpJSFunction(function);
break;
}

MacroAssembler::InvokeFunctionCode() calls MacroAssembler::JumpJSFunction() if type is InvokeType::kJump.

src/codegen/x64/macro-assembler-x64.cc
2813
2814
2815
2816
2817
// When the sandbox is enabled, we can directly fetch the entrypoint pointer
// from the code pointer table instead of going through the Code object. In
// this way, we avoid one memory load on this code path.
LoadCodeEntrypointViaCodePointer(
rcx, FieldOperand(function_object, JSFunction::kCodeOffset));

MacroAssembler::JumpJSFunction() calls MacroAssembler::LoadCodeEntrypointViaCodePointer() to emit instructions that fetch the entrypoint pointer.

src/objects/js-function.tq
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 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 JSFunctionOrBoundFunctionOrWrappedFunction {
// When the sandbox is enabled, the Code object is referenced through an
// indirect pointer. Otherwise, it is a regular tagged pointer.
@if(V8_ENABLE_SANDBOX) code: IndirectPointer<Code>;
@ifnot(V8_ENABLE_SANDBOX) code: Code;
shared_function_info: SharedFunctionInfo;
context: Context;
feedback_cell: FeedbackCell;
// Space for the following field may or may not be allocated.
prototype_or_initial_map: JSReceiver|Map;
}
include/v8-internal.h
581
582
583
// Code pointer handles are shifted by a different amount than indirect pointer
// handles as the tables have a different maximum size.
constexpr uint32_t kCodePointerHandleShift = 12;
include/v8-internal.h
600
601
constexpr int kCodePointerTableEntrySize = 16;
constexpr int kCodePointerTableEntrySizeLog2 = 4;
src/codegen/x64/macro-assembler-x64.cc
614
615
616
617
618
619
620
621
622
623
624
void MacroAssembler::LoadCodeEntrypointViaCodePointer(Register destination,
Operand field_operand) {
DCHECK(!AreAliased(destination, kScratchRegister));
DCHECK(!field_operand.AddressUsesRegister(kScratchRegister));
LoadAddress(kScratchRegister,
ExternalReference::code_pointer_table_address());
movl(destination, field_operand);
shrl(destination, Immediate(kCodePointerHandleShift));
shll(destination, Immediate(kCodePointerTableEntrySizeLog2));
movq(destination, Operand(kScratchRegister, destination, times_1, 0));
}

Builtins_CallFunction_ReceiverIsAny() reads the code pointer handle stored in the code field of the Function object. It converts the handle into an index to access the corresponding entry of the function in the code pointer table.

src/sandbox/code-pointer-table.h
84
85
86
87
88
89
90
91
92
93
std::atomic<Address> entrypoint_;
// The pointer to the Code object also contains the marking bit: since this is
// a tagged pointer to a V8 HeapObject, we know that it will be 4-byte aligned
// and that the LSB should always be set. We therefore use the LSB as marking
// bit. In this way:
// - When loading the pointer, we only need to perform an unconditional OR 1
// to get the correctly tagged pointer
// - When storing the pointer we don't need to do anything since the tagged
// pointer will automatically be marked
std::atomic<Address> code_;

The CodePointerTableEntry class has two fields: entrypoint_ and code_. The entrypoint_ field holds the entrypoint address, and the code_ field holds the address of the Code object corresponding to the function.

src/codegen/x64/macro-assembler-x64.cc
2818
2819
DCHECK_EQ(jump_mode, JumpMode::kJump);
jmp(rcx);

After getting the entrypoint address, Builtins_CallFunction_ReceiverIsAny() jumps to that address, where the generic JS-to-Wasm wrapper starts.

Builtins_JSToWasmWrapper() calls Builtins_JSToWasmWrapperAsm() generated by Builtins::Generate_JSToWasmWrapperAsm().

src/builtins/x64/builtins-x64.cc
3664
3665
3666
void Builtins::Generate_JSToWasmWrapperAsm(MacroAssembler* masm) {
JSToWasmWrapperHelper(masm, false);
}
src/builtins/x64/builtins-x64.cc
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
Register call_target = rdi;
// param_start should not alias with any parameter registers.
Register params_start = r11;
__ movq(params_start,
MemOperand(wrapper_buffer,
JSToWasmWrapperFrameConstants::kWrapperBufferParamStart));
Register params_end = rbx;
__ movq(params_end,
MemOperand(wrapper_buffer,
JSToWasmWrapperFrameConstants::kWrapperBufferParamEnd));
__ movq(call_target,
MemOperand(wrapper_buffer,
JSToWasmWrapperFrameConstants::kWrapperBufferCallTarget));

Builtins_JSToWasmWrapperAsm() loads the call target address into rdi, the parameter start address into r11, and the parameter end address into rbx.

The call target points to the jump table entry corresponding to the invoked WebAssembly function.

src/wasm/wasm-linkage.h
35
36
37
38
39
40
41
42
43
#elif V8_TARGET_ARCH_X64
// ===========================================================================
// == x64 ====================================================================
// ===========================================================================
constexpr Register kGpParamRegisters[] = {rsi, rax, rdx, rcx, rbx, r9};
constexpr Register kGpReturnRegisters[] = {rax, rdx};
constexpr DoubleRegister kFpParamRegisters[] = {xmm1, xmm2, xmm3,
xmm4, xmm5, xmm6};
constexpr DoubleRegister kFpReturnRegisters[] = {xmm1, xmm2};
src/builtins/x64/builtins-x64.cc
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
int next_offset = 0;
for (size_t i = 1; i < arraysize(wasm::kGpParamRegisters); ++i) {
// Check that {params_start} does not overlap with any of the parameter
// registers, so that we don't overwrite it by accident with the loads
// below.
DCHECK_NE(params_start, wasm::kGpParamRegisters[i]);
__ movq(wasm::kGpParamRegisters[i], MemOperand(params_start, next_offset));
next_offset += kSystemPointerSize;
}

for (size_t i = 0; i < arraysize(wasm::kFpParamRegisters); ++i) {
__ Movsd(wasm::kFpParamRegisters[i], MemOperand(params_start, next_offset));
next_offset += kDoubleSize;
}
DCHECK_EQ(next_offset, stack_params_offset);

Register thread_in_wasm_flag_addr = r12;
__ movq(
thread_in_wasm_flag_addr,
MemOperand(kRootRegister, Isolate::thread_in_wasm_flag_address_offset()));
__ movl(MemOperand(thread_in_wasm_flag_addr, 0), Immediate(1));

__ call(call_target);

Then, Builtins_JSToWasmWrapperAsm() copies the parameters to the reserved registers, and jumps to the call target address.

WebAssembly Lazy Compilation

src/flags/flag-definitions.h
1562
1563
DEFINE_BOOL(wasm_lazy_compilation, true,
"enable lazy compilation for all wasm modules")

With wasm_lazy_compilation enabled, WebAssembly functions are compiled when they are first called, rather than when the module is instantiated.

The jump table directs execution to Builtins_WasmCompileLazy() generated by Builtins::Generate_WasmCompileLazy().

src/builtins/x64/builtins-x64.cc
3130
3131
3132
3133
3134
3135
3136
// Push arguments for the runtime function.
__ Push(kWasmInstanceRegister);
__ Push(r15);
// Initialize the JavaScript context with 0. CEntry will use it to
// set the current context on the isolate.
__ Move(kContextRegister, Smi::zero());
__ CallRuntime(Runtime::kWasmCompileLazy, 2);

Builtins_WasmCompileLazy() calls Runtime_WasmCompileLazy() to compile the function.

src/wasm/wasm-objects.h
375
DECL_PRIMITIVE_ACCESSORS(jump_table_start, Address)
src/builtins/x64/builtins-x64.cc
3143
3144
3145
3146
3147
3148
3149
3150
3151
  // After the instance register has been restored, we can add the jump table
// start to the jump table offset already stored in r15.
__ addq(r15, MemOperand(kWasmInstanceRegister,
wasm::ObjectAccess::ToTagged(
WasmInstanceObject::kJumpTableStartOffset)));
}

// Finally, jump to the jump table slot for the function.
__ jmp(r15);

After the compilation is finished, Builtins_WasmCompileLazy() retrieves the function’s jump table slot address from the WasmInstanceObject, and jumps to that address. This time, the jump table transfers control to the function’s compiled code.

Since the WasmInstanceObject resides within the V8 sandbox, we can hijack control flow by overwriting the jump_table_start field with an arbitrary address before lazy compilation proceeds.

Exploitation

shellcode.py pwn.wat pwn.js

Bisection

[wasm] Enable lazy compilation by default (Nov 14, 2022)

This technique was introduced in the above commit, which enabled lazy compilation by default.

Patch

[wasm] Introduce WasmTrustedInstanceData (Jan 4, 2024)
This CL moves most data from the WasmInstanceObject to a new WasmTrustedInstanceData. As the name suggests, this new object is allocated in the trusted space and can hence hold otherwise-unsafe data (like direct pointers). As the Wasm instance was still storing some unsafe pointers, this CL closes holes in the V8 sandbox, and allows us to land follow-up refactorings to remove more indirections for sandboxing (potentially after moving more data structures to the trusted space).
The general idea is that during execution we mostly work with the WasmTrustedInstanceData object. This is passed as a direct pointer to Wasm functions and is stored in Wasm frames. The WasmInstanceObject is the JS-exposed wrapper, which also holds user-defined properties and elements.

The above commit moves some sensitive data including jump_table_start to the newly introduced WasmTrustedInstanceData, which resides outside the V8 sandbox. This prevents overwriting jump_table_start to hijack control flow.