A Deep Dive into V8 Sandbox Escape Technique Used in In-The-Wild Exploit
Introduction
We were analyzing an in-the-wild V8 vulnerability, CVE-2023â2033. Once we exploited the bug, it was not difficult to get typical exploit primitives such as addrof, read and write in V8 heap. The problem is that we need to escape the V8 sandbox in order to get code execution.
One day we happened to read a tweet from @zh1x1an1221. He managed to pop a calculator by exploiting CVE-2023â3079, another in-the-wild vulnerability, which means that he bypassed the sandbox. In the tweet, he mentioned a sandbox-related patch commit he used to escape the sandbox. It seemed that the commit sandboxified a raw pointer in a WebAssembly object which had been abused to get V8 sandbox bypass. The commit was worth taking a look since raw pointers in the V8 heap always had been the sources of the V8 sandbox escape.
In this blog post, We will share the details of how we achieved arbitrary write and code execution primitives using a raw pointer in WasmIndirectFunctionTable
object. We will not deal with CVE-2023-2033, as there are already many detailed writeups about it. The following will be brief patch analyses related to the sandbox bypass.
Background
To understand the V8 sandbox bypass in this blog post, we need to grasp three concepts in WebAssembly: module, instance, and table. A module is a set of stateless WebAssembly code which we can instantiate using JavaScript. We can think of it as a binary (e.g., ELF) in that we can spawn processes from a binary. An instance is a stateful, executable object that is created from a module. Like modules in other programming languages, a WebAssembly module may contain exported WebAssembly functions that we can access using JavaScript.
A table is the most important concept in this post. It is an array of functions where we can access the functions through table indices. The entries in a table are both readable and writable dynamically either by WebAssembly code or JavaScript APIs.
When we instantiate a module, the instance can import JavaScript functions and WebAssembly tables. The following is an example WebAssembly code. It imports a JavaScript function and a WebAssembly table (jstimes
and tbl
). Then it defines two functions $f42
and $f83
which are used to initialize the imported table. Lastly, it defines two exported functions times2
and pwn
.
(module
;; The common type we use throughout the sample.
(type $int2int (func (param i32) (result i32)))
;; Import a function named jstimes3 from the environment and call it
;; $jstimes3 here.
(import "env" "jstimes3" (func $jstimes3 (type $int2int)))
(import "js" "tbl" (table 2 funcref))
(func $f42 (result i32) i32.const 42)
(func $f83 (result i32) i32.const 83)
(elem (i32.const 0) $f42 $f83)
(func (export "times2") (type $int2int) (i32.const 16))
(func (export "pwn") (type $int2int) (i32.const 16) (call $jstimes3))
)
We can import the above WebAssembly code into JavaScript with the following code.
const tbl = new WebAssembly.Table({
initial: 2,
element: "anyfunc"
});
const importObject = {
env: {
jstimes3: (n) => 3 * n,
},
js: { tbl }
};
var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 10, 2, 96, 1, 127, 1, 127, 96, 0, 1, 127, 2, 27, 2, 3, 101, 110, 118, 8, 106, 115, 116, 105, 109, 101, 115, 51, 0, 0, 2, 106, 115, 3, 116, 98, 108, 1, 112, 0, 2, 3, 5, 4, 1, 1, 0, 0, 7, 16, 2, 6, 116, 105, 109, 101, 115, 50, 0, 3, 3, 112, 119, 110, 0, 4, 9, 8, 1, 0, 65, 0, 11, 2, 1, 2, 10, 24, 4, 4, 0, 65, 42, 11, 5, 0, 65, 211, 0, 11, 4, 0, 65, 16, 11, 6, 0, 65, 16, 16, 0, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module, importObject);
var times2 = instance.exports.times2;
%DebugPrint(instance);
In V8, WebAssembly instance and table are implemented as WasmInstanceObject
and WasmTableObject
. When an instance imports a table, the imported table is stored into the tables
field of the WasmInstanceObject
. Then an WasmIndirectFunctionTable
is allocated, and it is stored into the indirect_function_tables
field of the WasmInstanceObject
. The WasmIndirectFunctionTable
has the targets
field which contains the function pointers in the WasmTableObject
. Imported JavaScript functions are stored into the imported_function_targets
field of the WasmInstanceObject
. So from the above WebAssembly and JavaScript code, the structure looks like this:
Using WasmIndirectFunctionTable to get Arbitrary Write Primitive
When we dump memory of an WasmIndirectFunctionTable
, we can see that the targets
is a raw pointer that points to a memory area outside the V8 sandbox.
DebugPrint: 0x239d001a43ed: [WasmInstanceObject] in OldSpace
- map: 0x239d001997a5 <Map[224](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x239d001a35d1 <Object map = 0x239d001a43c5>
- elements: 0x239d00000219 <FixedArray[0]> [HOLEY_ELEMENTS]
- module_object: 0x239d00042991 <Module map = 0x239d00199379>
- exports_object: 0x239d00042af1 <Object map = 0x239d001a4661>
- native_context: 0x239d00183c2d <NativeContext[282]>
- tables: 0x239d00042a91 <FixedArray[1]>
- indirect_function_tables: 0x239d00042a9d <FixedArray[1]
- ...
0x239d00042a9d: [FixedArray]
- map: 0x239d00000089 <Map(FIXED_ARRAY_TYPE)>
- length: 1
0: 0x239d00042ab9 <WasmIndirectFunctionTable>
0x239d00042ab9: [WasmIndirectFunctionTable]
- map: 0x239d00001599 <Map[32](WASM_INDIRECT_FUNCTION_TABLE_TYPE)>
- size: 2
- sig_ids: 0x562ebe531150
- targets: 0x562ebe531170
- managed_native_allocations: 0x239d00042ad9 <Foreign>
- refs: 0x239d00042aa9 <FixedArray[2]>
pwndbg> x/8gx 0x239d00042ab8
0x239d00042ab8: 0x0000000200001599 0x0000562ebe531150
0x239d00042ac8: 0x0000562ebe531170 <-- targets
0x239d00042ad8: 0x00008ba00000036d 0x0000000400000089
0x239d00042ae8: 0x00000000001a43ed 0x00000219001a4661
pwndbg> x/4gx 0x562ebe531170
0x562ebe531170: 0x00003bc1b5892000 0x00003bc1b5892005 <-- $f42, $f83
0x562ebe531180: 0x0000000000000020 0x0000000000000081
When we search for the codes that access the targets
pointer, we can find the following function:
void WasmIndirectFunctionTable::Set(uint32_t index, int sig_id,
Address call_target, Object ref) {
sig_ids()[index] = sig_id;
targets()[index] = call_target;
refs().set(index, ref);
}
The WasmIndirectFunctionTable::Set
writes the call_target
to the memory area pointed to by the targets
. Since the targets
is a raw pointer in the V8 sandbox, we can achieve arbitrary write primitive by modifying the pointer with our in-sandbox read/write primitives. Now the point here is whether we can set the value of the call_target
to an arbitrary value of our choice. So we analyzed how we can reach the WasmIndirectFunctionTable::Set
and where the call_target
value comes from.
The route to the WasmIndirectFunctionTable::Set
starts from WasmTableObject::Set
. It is the implementation of the WebAssembly.Table.prototype.set()
JavaScript API. First, it calls WasmTableObject::SetFunctionTableEntry
.
void WasmTableObject::Set(Isolate* isolate, Handle<WasmTableObject> table,
uint32_t index, Handle<Object> entry) {
// ...
switch (table->type().heap_representation()) {
// ...
default:
DCHECK(!table->instance().IsUndefined());
if (WasmInstanceObject::cast(table->instance())
.module()
->has_signature(table->type().ref_index())) {
SetFunctionTableEntry(isolate, table, entries, entry_index, entry);
return;
}
entries->set(entry_index, *entry);
return;
}
}
The WasmTableObject::SetFunctionTableEntry
checks if the function passed to WasmTableObject::Set
is of type WasmExportedFunction
. If so, it gets the parent WasmInstanceObject
of the exported function. Then it loads the index of the exported function, and the index is used to get a pointer to wasm::WasmFunction
object that resides in the WasmInstanceObject
. With all the values, it calls WasmTableObject::UpdateDispatchTables
.
void WasmTableObject::SetFunctionTableEntry(Isolate* isolate,
Handle<WasmTableObject> table,
Handle<FixedArray> entries,
int entry_index,
Handle<Object> entry) {
// ...
Handle<Object> external = WasmInternalFunction::GetOrCreateExternal(
Handle<WasmInternalFunction>::cast(entry));
if (WasmExportedFunction::IsWasmExportedFunction(*external)) {
auto exported_function = Handle<WasmExportedFunction>::cast(external);
Handle<WasmInstanceObject> target_instance(exported_function->instance(),
isolate);
int func_index = exported_function->function_index();
auto* wasm_function = &target_instance->module()->functions[func_index];
UpdateDispatchTables(isolate, *table, entry_index, wasm_function,
*target_instance);
}
// ...
}
The WasmTableObject::UpdateDispatchTables
iterates through the dispatch tables within the table and updates the corresponding WasmIndirectFunctionTable
for each entry by invoking the WasmIndirectFunctionTable::Set
. Here we see that the call_target
passed to the WasmIndirectFunctionTable::Set
is the return value of WasmInstanceObject::GetCallTarget
.
void WasmTableObject::UpdateDispatchTables(Isolate* isolate,
WasmTableObject table,
int entry_index,
const wasm::WasmFunction* func,
WasmInstanceObject target_instance) {
DisallowGarbageCollection no_gc;
// We simply need to update the IFTs for each instance that imports
// this table.
FixedArray dispatch_tables = table.dispatch_tables();
DCHECK_EQ(0, dispatch_tables.length() % kDispatchTableNumElements);
// ...
Address call_target = target_instance.GetCallTarget(func->func_index);
int original_sig_id = func->sig_index;
for (int i = 0, len = dispatch_tables.length(); i < len;
i += kDispatchTableNumElements) {
int table_index =
Smi::cast(dispatch_tables.get(i + kDispatchTableIndexOffset)).value();
WasmInstanceObject instance = WasmInstanceObject::cast(
dispatch_tables.get(i + kDispatchTableInstanceOffset));
int sig_id = target_instance.module()
->isorecursive_canonical_type_ids[original_sig_id];
WasmIndirectFunctionTable ift = WasmIndirectFunctionTable::cast(
instance.indirect_function_tables().get(table_index));
ift.Set(entry_index, sig_id, call_target, call_ref);
}
}
The WasmInstanceObject::GetCallTarget
returns the actual address (i.e., the code pointer of a function) of a WebAssembly function whose index in the instance is func_index
. The func_index
parameter can be either from an imported function or an exported function. If a function is an imported function, the call target will be retrieved from imported_function_targets
. Since we already checked that the func_index
is from WasmExportedFunction
, the return value will be from jump_table_start() + ...
.
Address WasmInstanceObject::GetCallTarget(uint32_t func_index) {
wasm::NativeModule* native_module = module_object().native_module();
if (func_index < native_module->num_imported_functions()) {
return imported_function_targets().get(func_index);
}
return jump_table_start() +
JumpTableOffset(native_module->module(), func_index);
}
The problem is that the imported_function_target
is a compressed pointer whereas the jump_table_start
is a raw pointer. Both pointers are in the V8 sandbox, which means that we can overwrite the two pointers. However, we cannot control the contents pointed to by the jump_table_start
since we do not have an arbitrary write primitive yet.
DebugPrint: 0x3ed3001a4f89: [WasmInstanceObject] in OldSpace
...
- imported_function_targets: 0x3ed300042cd9 <ByteArray[8]>
...
- jump_table_start: 0x10553c7e7000
...
So we should make the WasmInstanceObject::GetCallTarget
take the if (func_index < ...)
branch to make the return value controllable. The native_module->num_imported_functions()
is 1
from our Wasm code ((import "env" "jstimes3" (func $jstimes3 (type $int2int)))
). The func_index
is read from WasmExportedFunctionData
object which is in the V8 sandbox. So if we set the function_index
of an exported Wasm function to zero and call the WasmInstanceObject::GetCallTarget
, then the function will take the if
branch and return a value in the imported_function_targets
.
DebugPrint: 0x2bc001a4505: [Function] in OldSpace
- map: 0x02bc00193751 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x02bc00184299 <JSFunction (sfi = 0x2bc001460a5)>
- elements: 0x02bc00000219 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype: <no-prototype-slot>
- shared_info: 0x02bc001a44e1 <SharedFunctionInfo js-to-wasm:i:i>
- ...
0x2bc001a44e1: [SharedFunctionInfo] in OldSpace
- map: 0x02bc00000d75 <Map[36](SHARED_FUNCTION_INFO_TYPE)>
- name: 0x02bc00002775 <String[1]: #3>
- kind: NormalFunction
- syntax kind: AnonymousExpression
- function_map_index: 206
- formal_parameter_count: 1
- expected_nof_properties: 0
- language_mode: sloppy
- data: 0x02bc001a44b5 <Other heap object (WASM_EXPORTED_FUNCTION_DATA_TYPE)>
- ...
0x2bc001a44b5: [WasmExportedFunctionData] in OldSpace
- map: 0x02bc00001ea9 <Map[44](WASM_EXPORTED_FUNCTION_DATA_TYPE)>
- internal: 0x02bc001a449d <Other heap object (WASM_INTERNAL_FUNCTION_TYPE)>
- wrapper_code: 0x02bc0002bb9d <Code BUILTIN GenericJSToWasmWrapper>
- js_promise_flags: 0
- instance: 0x02bc001a4381 <Instance map = 0x2bc001997a5>
- function_index: 3
- ...
The following are the summarized steps to get arbitrary write primitive:
Create a WebAssembly table and a WebAssembly instance that imports the table.
- The WebAssembly module should import at least one JavaScript function to make thenative_module->num_imported_functions()
a non-zero value.Overwrite the
targets
pointer in theWasmIndirectFunctionTable
of theWasmInstanceObject
with an arbitrary address.
- This pointer will be thewhere
of the arbitrary write primitive.Set the
function_index
of an exported WebAssembly function to zero.Overwrite the contents pointed to by the
imported_function_targets
with an arbitrary value.
- This value will be thewhat
of the arbitrary write primitive.Call
WebAssembly.Table.prototype.set()
.
- This call will write thewhat
to thewhere
.

Arbitrary Write Primitive to Code Execution
Imported functions when instantiating a WebAssembly module are stored into imported_function_targets
of the WasmInstanceObject
. The imported_function_targets
contains the code entrypoints of the imported functions. The pointers are raw pointers with RWX permissions.
DebugPrint: 0x418001a4fa1: [WasmInstanceObject] in OldSpace
- ...
- imported_function_targets: 0x041800042cd9 <ByteArray[8]>
- ...
pwndbg> x/8gx 0x041800042cd8
0x41800042cd8: 0x000000100000095d 0x00003cef5608b700
0x41800042ce8: 0x0000000200000089 0x00000089001a5081
0x41800042cf8: 0x000000000000000a 0x0000000000000000
0x41800042d08: 0x001a5169001a50bd 0x00000006000000d9
pwndbg> vmmap 0x00003cef5608b700
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
Start End Perm Size Offset File
0x41b80c80000 0x52000000000 ---p 1047f380000 0 [anon_41b80c80]
âș 0x3cef5608b000 0x3cef5608c000 rwxp 1000 0 [anon_3cef5608b] +0x700
So with the arbitrary write primitive, we can copy our shellcode to the rwx memory and execute it via an exported Wasm function that calls the overwritten, imported function.
(module
;; ...
;; Import a function named jstimes3 from the environment and call it
;; $jstimes3 here.
(import "env" "jstimes3" (func $jstimes3 (type $int2int)))
;; ...
(func (export "pwn") (type $int2int) (i32.const 16) (call $jstimes3))
)
Full exploit code is available at our GitHub repo.
The patches
The patches for the sandbox bypass are done in two steps.
The first patch turned the targets
pointer into an on-heap (pointer-compressed) pointer so that the pointer cannot be abused to get arbitrary write primitive. We noticed that this commit was tagged with the same issue number as CVE-2023-2033. This means that the in-the-wild exploit available to the issue reporter might have been using the same exploit technique.
The code entrypoints in the targets
were also vulnerable, so the second patch turned the targets
into ExternalPointerArray
which contains encoded pointers (ExternalPointer
) instead of raw pointers. This patch prevented attackers from temparing code pointers in the target
.
0x3bdb0004cce5: [WasmIndirectFunctionTable]
- map: 0x3bdb00001589 <Map[20](WASM_INDIRECT_FUNCTION_TABLE_TYPE)>
- size: 2
- sig_ids: 0x3bdb0004ccc5 <ByteArray[8]>
- targets: 0x3bdb0004ccd5 <ExternalPointerArray[2]>
- refs: 0x3bdb0004ccb5 <FixedArray[2]>
The following is the timeline related to CVE-2023â2033 and the sandbox bypass fixes.
Jul 21, 2023: The second patch for the sandbox bypass was committed.
Apr 14, 2023: The first patch for the sandbox bypass was committed.
Apr 12, 2023: CVE-2023â2033 was patched.
Apr 11, 2023: The issue for CVE-2023â2033 was reported.
References
https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Module
https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Instance
https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Table
https://developer.mozilla.org/en-US/docs/WebAssembly/Exported_functions
https://bugs.chromium.org/p/chromium/issues/detail?id=1432210
https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format