Escaping V8 Sandbox via WebAssembly JIT Spraying (V8 < 10.6.24)

  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 this post, I will demonstrate a technique to escape the V8 sandbox by applying JIT spraying to WebAssembly. By manipulating the immediate constants in a WebAssembly function and redirecting execution flow, we can force the JIT compiler to emit executable shellcode into memory. This exploit assumes we have already achieved arbitrary read/write primitives within the sandbox and focuses on crossing the sandbox boundary to achieve full remote code execution.

Setup

Run v8setup.py in your working directory.

Analysis

Compiling i64.const

src/wasm/function-body-decoder-impl.h
2678
2679
2680
2681
2682
2683
2684
2685
if (opcode == kExprLocalGet) {
len = WasmFullDecoder::DecodeLocalGet(this, opcode);
} else if (opcode == kExprI32Const) {
len = WasmFullDecoder::DecodeI32Const(this, opcode);
} else {
OpcodeHandler handler = GetOpcodeHandler(first_byte);
len = (*handler)(this, opcode);
}

To decode the WebAssembly i64.const instruction, WasmFullDecoder::DecodeFunctionBody() calls WasmFullDecoder::DecodeI64Const().

src/wasm/function-body-decoder-impl.h
2862
2863
2864
2865
2866
2867
#define DECODE(name)                                                     \
static int Decode##name(WasmFullDecoder* decoder, WasmOpcode opcode) { \
TraceLine trace_msg(decoder); \
return decoder->Decode##name##Impl(&trace_msg, opcode); \
} \
V8_INLINE int Decode##name##Impl(TraceLine* trace_msg, WasmOpcode opcode)

WasmFullDecoder::DecodeI64Const() calls WasmFullDecoder::DecodeI64ConstImpl().

src/wasm/function-body-decoder-impl.h
3344
3345
3346
3347
3348
3349
3350
DECODE(I64Const) {
ImmI64Immediate<validate> imm(this, this->pc_ + 1);
Value value = CreateValue(kWasmI64);
CALL_INTERFACE_IF_OK_AND_REACHABLE(I64Const, &value, imm.value);
Push(value);
return 1 + imm.length;
}

WasmFullDecoder::DecodeI64ConstImpl() calls LiftoffCompiler::I64Const().

src/wasm/baseline/liftoff-compiler.cc
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
void I64Const(FullDecoder* decoder, Value* result, int64_t value) {
// The {VarState} stores constant values as int32_t, thus we only store
// 64-bit constants in this field if it fits in an int32_t. Larger values
// cannot be used as immediate value anyway, so we can also just put them in
// a register immediately.
int32_t value_i32 = static_cast<int32_t>(value);
if (value_i32 == value) {
__ PushConstant(kI64, value_i32);
} else {
LiftoffRegister reg = __ GetUnusedRegister(reg_class_for(kI64), {});
__ LoadConstant(reg, WasmValue(value));
__ PushRegister(kI64, reg);
}
}

LiftoffCompiler::I64Const() calls LiftoffAssembler::LoadConstant() to generate instructions loading the constant value into reg.

src/wasm/baseline/x64/liftoff-assembler-x64.h
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
void LiftoffAssembler::LoadConstant(LiftoffRegister reg, WasmValue value,
RelocInfo::Mode rmode) {
switch (value.type().kind()) {
case kI32:
if (value.to_i32() == 0 && RelocInfo::IsNoInfo(rmode)) {
xorl(reg.gp(), reg.gp());
} else {
movl(reg.gp(), Immediate(value.to_i32(), rmode));
}
break;
case kI64:
if (RelocInfo::IsNoInfo(rmode)) {
TurboAssembler::Move(reg.gp(), value.to_i64());
} else {
movq(reg.gp(), Immediate64(value.to_i64(), rmode));
}
break;
case kF32:
TurboAssembler::Move(reg.fp(), value.to_f32_boxed().get_bits());
break;
case kF64:
TurboAssembler::Move(reg.fp(), value.to_f64_boxed().get_bits());
break;
default:
UNREACHABLE();
}
}

LiftoffAssembler::LoadConstant() calls TurboAssembler::Move() if the value type is kI64.

src/codegen/x64/macro-assembler-x64.h
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
void Move(Register dst, intptr_t x) {
if (x == 0) {
xorl(dst, dst);
// The following shorter sequence for uint8 causes performance
// regressions:
// xorl(dst, dst); movb(dst,
// Immediate(static_cast<uint32_t>(x)));
} else if (is_uint32(x)) {
movl(dst, Immediate(static_cast<uint32_t>(x)));
} else if (is_int32(x)) {
// "movq reg64, imm32" is sign extending.
movq(dst, Immediate(static_cast<int32_t>(x)));
} else {
movq(dst, Immediate64(x));
}
}

TurboAssembler::Move() calls Assembler::movq() for a 64-bit integer.

src/codegen/x64/assembler-x64.h
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
#define DECLARE_INSTRUCTION(instruction)    \
template <typename... Ps> \
void instruction##_tagged(Ps... ps) { \
emit_##instruction(ps..., kTaggedSize); \
} \
\
template <typename... Ps> \
void instruction##l(Ps... ps) { \
emit_##instruction(ps..., kInt32Size); \
} \
\
template <typename... Ps> \
void instruction##q(Ps... ps) { \
emit_##instruction(ps..., kInt64Size); \
}
ASSEMBLER_INSTRUCTION_LIST(DECLARE_INSTRUCTION)
#undef DECLARE_INSTRUCTION

Assembler::movq() calls Assembler::emit_mov().

src/codegen/x64/assembler-x64.cc
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
void Assembler::emit_mov(Register dst, Immediate64 value, int size) {
DCHECK_EQ(size, kInt64Size);
if (constpool_.TryRecordEntry(value.value_, value.rmode_)) {
// Emit rip-relative move with offset = 0
Label label;
emit_mov(dst, Operand(&label, 0), size);
bind(&label);
} else {
EnsureSpace ensure_space(this);
emit_rex(dst, size);
emit(0xB8 | dst.low_bits());
emit(value);
}
}

Assembler::emit_mov() emits the opcode for the 64-bit mov instruction followed by the raw value.

JIT Spraying

JIT spraying is an exploitation technique used to bypass the memory protection mechanism. It abuses the JIT compiler emitting immediate constants directly into the compiled code, to insert shellcode into executable memory. We can apply this technique to WebAssembly because Liftoff (the baseline compiler) emits the operand of the i64.const instruction directly into the compiled code, as analyzed above.

(module
(func (export "f")
i64.const 0x4141414141414141
i64.const 0x4242424242424242
i64.const 0x4343434343434343
return
)
)

After Liftoff compiles the function f, we can observe the following instructions:

We can insert arbitrary 8-byte constant numbers into the middle of the function code. If we can redirect the instruction pointer to the exact location of the constant, those bytes are interpreted as instructions (shellcode).

Consequently, we can execute arbitrary 8-byte shellcode. However, 8 bytes is typically insufficient to execute a complex payload, such as execve("/bin/sh", 0, 0). To overcome this limitation, we can chain several shellcode segments using the relative jmp instruction.

Calling WebAssembly Function

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

let builder = new WasmModuleBuilder();
builder.addFunction("f", makeSig([], [])).addBody([]).exportFunc();
let instance = builder.instantiate();

instance.exports.f();

The JavaScript code above creates a WebAssembly module containing an empty function, then calls the exported function.

src/objects/js-function.tq
32
33
34
35
36
37
38
39
40
extern class JSFunction extends JSFunctionOrBoundFunctionOrWrappedFunction {
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;
}

When an exported WebAssembly function is called, the function call handler reads the address of the CodeDataContainer object corresponding to the function from the code field of the Function object.

src/objects/code.h
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
// Layout description.
#define CODE_DATA_FIELDS(V) \
/* Strong pointer fields. */ \
V(kPointerFieldsStrongEndOffset, 0) \
/* Weak pointer fields. */ \
V(kNextCodeLinkOffset, kTaggedSize) \
V(kPointerFieldsWeakEndOffset, 0) \
/* Strong Code pointer fields. */ \
V(kCodeOffset, V8_EXTERNAL_CODE_SPACE_BOOL ? kTaggedSize : 0) \
V(kCodePointerFieldsStrongEndOffset, 0) \
/* Raw data fields. */ \
V(kCodeCageBaseUpper32BitsOffset, \
V8_EXTERNAL_CODE_SPACE_BOOL ? kTaggedSize : 0) \
V(kCodeEntryPointOffset, \
V8_EXTERNAL_CODE_SPACE_BOOL ? kExternalPointerSlotSize : 0) \
V(kFlagsOffset, V8_EXTERNAL_CODE_SPACE_BOOL ? kUInt16Size : 0) \
V(kBuiltinIdOffset, V8_EXTERNAL_CODE_SPACE_BOOL ? kInt16Size : 0) \
V(kKindSpecificFlagsOffset, kInt32Size) \
V(kUnalignedSize, OBJECT_POINTER_PADDING(kUnalignedSize)) \
/* Total size. */ \
V(kSize, 0)

Next, the handler retrieves the entrypoint from the CodeDataContainer object and jumps to that address. This is not the WebAssembly function’s entrypoint, but rather the generic JS-to-Wasm wrapper.

src/objects/js-function.tq
32
33
34
35
36
37
38
39
40
extern class JSFunction extends JSFunctionOrBoundFunctionOrWrappedFunction {
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 wrapper then retrieves the SharedFunctionInfo from the Function object.

src/objects/shared-function-info.tq
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@generateBodyDescriptor
extern class SharedFunctionInfo extends HeapObject {
// function_data field is treated as a custom weak pointer. We visit this
// field as a weak pointer if there is aged bytecode. If there is no bytecode
// or if the bytecode is young then we treat it as a strong pointer. This is
// done to support flushing of bytecode.
@customWeakMarking function_data: Object;
name_or_scope_info: String|NoSharedNameSentinel|ScopeInfo;
outer_scope_info_or_feedback_metadata: HeapObject;
script_or_debug_info: Script|DebugInfo|Undefined;
// [length]: The function length - usually the number of declared parameters
// (always without the receiver).
// Use up to 2^16-2 parameters (16 bits of values, where one is reserved for
// kDontAdaptArgumentsSentinel). The value is only reliable when the function
// has been compiled.
length: int16;
// [formal_parameter_count]: The number of declared parameters (or the special
// value kDontAdaptArgumentsSentinel to indicate that arguments are passed
// unaltered).
// In contrast to [length], formal_parameter_count includes the receiver.
formal_parameter_count: uint16;
function_token_offset: uint16;
// [expected_nof_properties]: Expected number of properties for the
// function. The value is only reliable when the function has been compiled.
expected_nof_properties: uint8;
flags2: SharedFunctionInfoFlags2;
flags: SharedFunctionInfoFlags;
// [function_literal_id] - uniquely identifies the FunctionLiteral this
// SharedFunctionInfo represents within its script, or -1 if this
// SharedFunctionInfo object doesn't correspond to a parsed FunctionLiteral.
function_literal_id: int32;
// [unique_id] - For --log-maps purposes, an identifier that's persistent
// even if the GC moves this SharedFunctionInfo.
@if(V8_SFI_HAS_UNIQUE_ID) unique_id: int32;
}

From there, it obtains the address of the WasmExportedFunctionData object.

src/wasm/wasm-objects.tq
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
extern class WasmFunctionData extends HeapObject {
// The wasm-internal representation of this function object.
internal: WasmInternalFunction;
// Used for calling this function from JavaScript.
@if(V8_EXTERNAL_CODE_SPACE) wrapper_code: CodeDataContainer;
@ifnot(V8_EXTERNAL_CODE_SPACE) wrapper_code: Code;
}

extern class WasmExportedFunctionData extends WasmFunctionData {
// This is the instance that exported the function (which in case of
// imported and re-exported functions is different from the instance
// where the function is defined -- for the latter see WasmFunctionData::ref).
instance: WasmInstanceObject;
function_index: Smi;
signature: Foreign;
wrapper_budget: Smi;
// The remaining fields are for fast calling from C++. The contract is
// that they are lazily populated, and either all will be present or none.
@if(V8_EXTERNAL_CODE_SPACE) c_wrapper_code: CodeDataContainer;
@ifnot(V8_EXTERNAL_CODE_SPACE) c_wrapper_code: Code;
packed_args_size: Smi;
// Functions returned by suspender.returnPromiseOnSuspend() have this field
// set to the host suspender object.
suspend: Smi; // Boolean.
}

Subsequently, it accesses the WasmInternalFunction referenced by the WasmExportedFunctionData object.

src/objects/foreign.tq
6
7
8
extern class Foreign extends HeapObject {
foreign_address: ExternalPointer;
}
src/wasm/wasm-objects.tq
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// This is the representation that is used internally by wasm to represent
// function references.
// The {foreign_address} field inherited from {Foreign} points to the call
// target.
extern class WasmInternalFunction extends Foreign {
// This is the "reference" value that must be passed along in the "instance"
// register when calling the given function. It is either the target instance
// (for wasm functions), or a WasmApiFunctionRef object (for functions defined
// through the JS or C APIs).
// For imported functions, this value equals the respective entry in
// the module's imported_function_refs array.
ref: WasmInstanceObject|WasmApiFunctionRef;
// The external (JS) representation of this function reference.
external: JSFunction|Undefined;
// This field is used when the call target is null.
@if(V8_EXTERNAL_CODE_SPACE) code: CodeDataContainer;
@ifnot(V8_EXTERNAL_CODE_SPACE) code: Code;
}

Finally, it reads the call target address from the WasmInternalFunction object and jumps to that address, which points to the WebAssembly jump table.

The WasmInternalFunction object resides within the V8 sandbox (heap), but its foreign_address field points to the executable machine code (JIT page) located outside the sandbox. By overwriting this field with the address of our shellcode (which we can calculate by leaking the code address of f() and adding the offset to our constants) using a sandboxed arbitrary write primitive, we can hijack control flow and execute the shellcode.

Exploitation

shellcode.py pwn.wat pwn.js

Patch

[sandbox] Refactor and sandboxify WasmInternalFunction::call_target (Jul 26, 2022)
This CL refactors WasmInternalFunction to no longer inherit from Foreign but instead contain a (sandboxed) ExternalPointer field for the call target.

The above patch moves the call target address into the external pointer table, which is located outside the V8 sandbox and referenced by an index (handle), thus preventing the direct overwrite.