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

  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)

The patch mitigating the exploit from Part 1 was designed to prevent overwriting the WebAssembly code space. However, because we still have access to an arbitrary address write primitive outside the V8 sandbox, we can simply overwrite the flag at runtime to disable this protection. This effectively reverts the environment to the state described in Part 1, restoring our ability to execute arbitrary code.

Setup

Place v8setup.py and sandbox.diff in 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

WebAssembly Code Protection

src/wasm/code-space-access.cc
82
83
84
85
86
87
88
89
90
91
// static
void CodeSpaceWriteScope::SetExecutable() {
auto* code_manager = GetWasmCodeManager();
if (code_manager->MemoryProtectionKeysEnabled()) {
DCHECK(FLAG_wasm_memory_protection_keys);
code_manager->SetThreadWritable(false);
} else if (FLAG_wasm_write_protect_code_memory) {
current_native_module_->RemoveWriter();
}
}

If FLAG_wasm_write_protect_code_memory is set to true, CodeSpaceWriteScope::SetExecutable() calls NativeModule::RemoveWriter() to revoke write permissions whenever write access to the WebAssembly code space is no longer needed.

src/wasm/wasm-code-manager.cc
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
void WasmCodeAllocator::MakeWritable(base::AddressRegion region) {
if (!protect_code_memory_) return;
DCHECK_LT(0, writers_count_);
DCHECK(!region.is_empty());
v8::PageAllocator* page_allocator = GetPlatformPageAllocator();

// Align to commit page size.
size_t commit_page_size = page_allocator->CommitPageSize();
DCHECK(base::bits::IsPowerOfTwo(commit_page_size));
Address begin = RoundDown(region.begin(), commit_page_size);
Address end = RoundUp(region.end(), commit_page_size);
region = base::AddressRegion(begin, end - begin);

InsertIntoWritableRegions(region, true);
}

Write permissions are restored only when necessary. WasmCodeAllocator::MakeWritable() calculates the memory range requiring write access and calls WasmCodeAllocator::InsertIntoWritableRegions().

src/wasm/wasm-code-manager.cc
894
895
896
897
898
899
900
901
902
903
904
new_writable_memory += region.size();
if (switch_to_writable) {
for (base::AddressRegion split_range :
SplitRangeByReservationsIfNeeded(region, owned_code_space_)) {
TRACE_HEAP("Set 0x%" V8PRIxPTR ":0x%" V8PRIxPTR " to RWX\n",
split_range.begin(), split_range.end());
CHECK(SetPermissions(page_allocator, split_range.begin(),
split_range.size(),
PageAllocator::kReadWriteExecute));
}
}

WasmCodeAllocator::InsertIntoWritableRegions() calls SetPermissions() to set the region’s permissions to RWX.

This mechanism prevents us from writing shellcode to the code space as we did in Part 1, since write permissions likely won’t be active at the moment we attempt our write. However, CodeSpaceWriteScope::SetExecutable() checks FLAG_wasm_write_protect_code_memory every time it is called. If we overwrite this flag to false at runtime, the protection is immediately disabled, allowing us to achieve arbitrary code execution exactly as we did in Part 1.

Exploitation

Obtaining address of FLAG_wasm_write_protect_code_memory

src/wasm/wasm-objects.h
339
DECL_PRIMITIVE_ACCESSORS(isolate_root, Address)
src/wasm/wasm-objects.h
385
V(kIsolateRootOffset, kSystemPointerSize)                               \
src/execution/isolate.h
2052
2053
2054
2055
// This class contains a collection of data accessible from both C++ runtime
// and compiled code (including assembly stubs, builtins, interpreter bytecode
// handlers and optimized code).
IsolateData isolate_data_;

The WasmInstanceObject class has an accessor, isolate_root, which points to Isolate::isolate_data_.

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();
% DebugPrint(instance);

src/execution/isolate-data.h
197
ExternalReferenceTable external_reference_table_;

The IsolateData class contains external_reference_table_, which holds pointers to d8 execution flags.

The order of the execution flags remains consistent as long as the V8 version does not change.

src/flags/flag-definitions.h
374
375
DEFINE_BOOL(builtin_subclassing, true,
"subclassing support in built-in methods")
src/flags/flag-definitions.h
940
941
DEFINE_BOOL(wasm_write_protect_code_memory, true,
"write protect code memory on the wasm native heap with mprotect")

Therefore, we can reliably calculate the address of FLAG_wasm_write_protect_code_memory by using a known flag address found in the IsolateData::external_reference_table_.

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

Bisection

[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 enables wasm_write_protect_code_memory by default.

Patch

[sandbox] Enable sandboxed pointers on Desktop (May 05, 2022)
Revert “[sandbox] Enable sandboxed pointers on Desktop” (May 06, 2022)
Reland “[sandbox] Enable sandboxed pointers on Desktop” (May 06, 2022)
Revert “Reland “[sandbox] Enable sandboxed pointers on Desktop”” (May 06, 2022)
Reland “Reland “[sandbox] Enable sandboxed pointers on Desktop”” (May 10, 2022)

The commits above enabled v8_enable_sandboxed_pointers by default when v8_enable_sandbox is enabled. Consequently, the external_pointer of the JSTypedArray class is no longer stored as a full 8-byte pointer, and the backing_store pointer of the JSArrayBuffer class is now allocated within the V8 sandbox.

[sandbox] Also enable the sandbox outside of Chromium builds (Jun 17, 2022)
Revert “[sandbox] Also enable the sandbox outside of Chromium builds” (Jun 20, 2022)
Reland “[sandbox] Also enable the sandbox outside of Chromium builds” (Jun 21, 2022)
[sandbox] Disable the sandbox by default outside of Chromium builds (Jul 19, 2022)
[sandbox] Enable the sandbox by default in V8 builds (Sep 23, 2022)

v8_enable_sandbox was enabled by default for standalone V8 builds in the commits above, while it was already enabled in Chromium builds in the following commit.

Turn on v8_enable_virtual_memory_cage for Chromium builds (Oct 4, 2021)