This is an isolate-and-bounds lab, not a renderer 0-day. Target is d8 from a Chromium build I keep for notes (V8 version 10.9 era, matching a 2023 Chrome 109 lab image) and a 40-line JS file that allocates a Uint8Array and refuses out-of-range indexes in a lab assert. Goal: print the typed-array dump, throw on i >= length, and optionally %DebugPrint under --allow-natives-syntax. I do not JIT-spray, I do not confuse maps into a fake object, I do not turn an OOB into a backing-store primitive.

1Figure 1. Untrusted JS hits V8, then the renderer sandbox. This lab stops at a thrown assert inside d8.
2script -> typed array bounds  ->  LAB_ASSERT throw  (or d8 abort)
3JIT / maps / isolates are the map; we do not break them here

Lab layout

1labs/v8_lab/
2  toy.js              # Uint8Array + lab assert
3  crash.js            # uncaught throw for --abort-on-uncaught-exception
4  d8-version.txt
1$ ./d8 --version
2V8 version 10.9.194.5
3
4$ ./d8 --help | egrep 'allow-natives-syntax|abort-on-uncaught|trace-maps'
5  --allow-natives-syntax (allow natives syntax)
6  --abort-on-uncaught-exception
7  --trace-maps

Host is a Linux lab VM. d8 is the V8 shell, not Chrome. A crash here is process abort of the shell, not a sandbox escape.

Lab binary (JS)

 1// toy.js — typed array, explicit bounds, no OOB primitive
 2const a = new Uint8Array(8);
 3a.fill(0x41);
 4
 5function read_at(i) {
 6  if ((i >>> 0) >= a.length) {
 7    throw new Error("LAB_ASSERT oob i=" + i + " len=" + a.length);
 8  }
 9  return a[i];
10}
11
12function write_at(i, v) {
13  if ((i >>> 0) >= a.length) {
14    throw new Error("LAB_ASSERT oob i=" + i + " len=" + a.length);
15  }
16  a[i] = v & 0xff;
17}
18
19print("len " + a.length);
20print("a[0] " + read_at(0));
21write_at(7, 0x5a);
22print("a[7] " + read_at(7));
23try {
24  print(read_at(8));
25} catch (e) {
26  print(String(e));
27}
1$ ./d8 toy.js
2len 8
3a[0] 65
4a[7] 90
5Error: LAB_ASSERT oob i=8 len=8

That is the whole happy path. Index 8 on length 8 is rejected in JS, not in a missing CheckBounds node. Engine bugs in this class are “the JIT deleted a check the interpreter still did”. I do not delete the check.

Optional: --allow-natives-syntax

Natives are a debug surface. I use them to print the object, not to call %SetAllocationTimeout tricks.

1// toy_native.js — same array, DebugPrint, then the assert
2const a = new Uint8Array(8);
3a.fill(0x41);
4%DebugPrint(a);
5function read_at(i) {
6  if ((i >>> 0) >= a.length) throw new Error("LAB_ASSERT oob i=" + i);
7  return a[i];
8}
9read_at(8);
 1$ ./d8 --allow-natives-syntax toy_native.js
 2DebugPrint: 0x1a0[REDACTED]: [JSTypedArray]
 3- map: 0x1a0[REDACTED] <Map(UINT8_ELEMENTS)> [FastProperties]
 4- prototype: 0x1a0[REDACTED] <Object map = 0x...>
 5- elements: 0x1a0[REDACTED] <FixedUint8Array[8]> [UINT8_ELEMENTS]
 6- embedder fields: 2
 7- length: 8
 8- byte_length: 8
 9- byte_offset: 0
10- buffer: 0x1a0[REDACTED] <ArrayBuffer map = 0x...>
11Error: LAB_ASSERT oob i=8

Addresses are redacted. What I actually need from %DebugPrint:

  • UINT8_ELEMENTS — the elements kind. A real type-confusion write-up would claim this kind is no longer true. I do not force a transition.
  • length: 8 / byte_length: 8 — the bounds the assert used.
  • ArrayBuffer backing store — a separate object. Length mismatch between the view and the buffer is a class of bug; both are 8 here.

I do not call %OptimizeFunctionOnNextCall + %NeverOptimize dances to land a check-elimination. Optional natives in this note stop at DebugPrint.

Sanitized reproduction: abort is a lab crash

Uncaught throw, then the shell flag that turns it into a fatal:

1// crash.js
2const a = new Uint8Array(4);
3function poke(i, v) {
4  if ((i >>> 0) >= a.length)
5    throw new Error("LAB_ASSERT oob i=" + i + " len=" + a.length);
6  a[i] = v;
7}
8poke(4, 1);   // uncaught
 1$ ./d8 crash.js
 2toy: uncaught:
 3Error: LAB_ASSERT oob i=4 len=4
 4    at poke (crash.js:5:11)
 5    at crash.js:8:1
 6
 7$ ./d8 --abort-on-uncaught-exception crash.js
 8#
 9# Fatal error in , line 0
10# unreachable code
11#
12#
13#
14#FailureMessage Object: 0x7ff[REDACTED]
15==== C stack trace ===============================
16 d8  [REDACTED]
17# aborted (core dumped)     ; lab only, same box
1$ gdb -q ./d8 core
2(gdb) bt
3#0  abort () from /lib/x86_64-linux-gnu/libc.so.6
4#1  V8_Fatal ...
5#2  v8::internal::Isolate::Throw ...
6# ... frames into d8 main  [REDACTED]

Tombstone: input poke(4,1) on Uint8Array(4), message LAB_ASSERT oob i=4 len=4, process abort with --abort-on-uncaught-exception. That is a lab assert, not an in-engine CHECK about a corrupted map.

ASan: I do not rebuild V8 with ASan for this note. A userspace analog — a C typed-buffer with the same bounds — does:

 1/* ta_lab.c — mirror of crash.js, ASan on the C side */
 2#include <stdint.h>
 3#include <stdio.h>
 4#include <stdlib.h>
 5int main(void) {
 6    uint8_t a[4] = {0};
 7    int i = 4;            /* the JS poke(4) */
 8    if ((unsigned)i >= 4) {
 9        fprintf(stderr, "LAB_ASSERT oob i=%d len=4\n", i);
10        return 1;
11    }
12    a[i] = 1;             /* not reached */
13    return 0;
14}
1$ cc -O0 -fsanitize=address -g -o ta_lab ta_lab.c
2$ ./ta_lab
3LAB_ASSERT oob i=4 len=4
4# ASan silent — we did not store. Remove the if to see heap/stack overflow;
5# I keep the if. The engine bug is when the equivalent if is gone after JIT.

Isolates, JIT, renderer (the map, still no exploit)

1untrusted script / Wasm
2    ->  V8 isolate (heap, maps, typed-array views)
3        ->  interpreter / Sparkplug / Maglev / TurboFan
4            ->  renderer process
5                ->  sandbox + IPC brokers
6                    ->  OS

What I write next to a Chrome V8 CVE, using this lab as vocabulary:

  1. Isolate / context — embedder wiring. Node and Electron often have no renderer sandbox. Same engine, different wrap.
  2. Map / elements kind%DebugPrint showed UINT8_ELEMENTS. Type confusion = trusted kind was wrong.
  3. JIT check eliminationread_at in toy.js is the check TurboFan must not drop.
  4. Typed array view vs ArrayBuffer — length 8 / byte_length 8 in the dump. Mismatch is a class.
  5. Patch the embedder (Chrome/Electron/Node), not a handwritten d8 flag, in production.

--trace-maps on the toy, truncated:

1$ ./d8 --trace-maps toy.js
2[trace-maps] [0x...] Initial: ... JSObject
3[trace-maps] [0x...] Transition: ... UINT8_ELEMENTS
4len 8
5...

Useful when a crash dump mentions a map id. Not useful as a primitive.

Mitigation

  • Chrome auto-update; Electron rebuilt on current Chromium; Node on a supported line. Inventory V8 version, not only the product major.
  • Site isolation / renderer sandbox on. A d8 abort in this lab is unsandboxed by design — do not run untrusted scripts in d8.
  • Crash telemetry: cluster renderer crashes in V8 JIT ranges. This lab’s abort is V8_Fatal from an uncaught JS exception — different bucket than a SIGSEGV in generated code.
  • Custom embeds: one isolate per trust domain; no privileged bindings on the untrusted context.
  • --allow-natives-syntax is off in production. It is a debug switch. I used it for %DebugPrint only.

What I file after this lab

  • d8 --versionV8 version 10.9.194.5
  • toy.js: len 8, a[0]=65, LAB_ASSERT oob i=8 len=8
  • %DebugPrint: UINT8_ELEMENTS, length 8, ArrayBuffer backing, addresses redacted
  • Crash: d8 --abort-on-uncaught-exception crash.jsV8_Fatal / abort, message LAB_ASSERT oob i=4 len=4
  • C mirror ta_lab.c with ASan: assert prints, no overflow
  • Out of scope: map confusion, JIT check elimination, sandbox escape, d8 natives other than DebugPrint

Commands appendix

1./d8 --version
2./d8 toy.js
3./d8 --allow-natives-syntax toy_native.js
4./d8 --abort-on-uncaught-exception crash.js
5cc -O0 -fsanitize=address -g -o ta_lab ta_lab.c && ./ta_lab