core-jmp core-jmpdeath of core jump

A Brilliantly Simple Bug in V8: CVE-2026-19174 and the 32-bit Multiply Nobody Did

QED’s CVE-2026-19174: three constants overflow in V8’s wasm deserializer, 102.4 MiB instead of 921.6, overlapping RWX JIT, renderer RCE without a heap-sandbox bypass. Chrome M110–151.0.7922.75, fixed 2026-08-06.

oxfemale September 16, 2026 30 min read 202 reads
Export PDF
A Brilliantly Simple Bug in V8: CVE-2026-19174 and the 32-bit Multiply Nobody Did
Original text: "A Brilliantly Simple Bug In V8"QED, QED Audit (5 September 2026). Code listings and the CVE table follow the source. Figures 1–5 on qedaudit.io are in-page animations; the diagrams here are static reconstructions of those captions, plus the original hero PNG. We do not host a complete Chrome exploit beyond the published snippets.
A Brilliantly Simple Bug In V8
Original hero from QED. Source: original article.

Executive Summary

On 5 September 2026 QED published CVE-2026-19174 (b/538378084): a 32-bit integer overflow in V8’s WebAssembly deserializer. The overflowing expression is three constants. No attacker length. Default flag 1024, times MB, times 9, wraps, divide by 10, and Chrome on x86/x86-64 has been allocating 102.4 MiB where it meant 921.6 MiB since November 2022. That mismatch, plus debug-only bounds, plus a Shrink() CFI hole keyed on start address, is renderer native-code execution from a web page — no V8 heap-sandbox bypass. They showed it end-to-end on Chrome M150 in V8CTF. Fixed in 151.0.7922.108 on 6 August 2026. Affects M110 through 151.0.7922.75. Not ARM64/Loong64/PPC64.

In the post-LLM era, what’s the simplest bug you can find on one of the most audited codebases in the world? Turns out, it’s just (uint32_t)(1024u * (1024 * 1024) * 9)!

QED, 5 September 2026
FieldValue
CVECVE-2026-19174 (b/538378084)
Wherev8/src/wasm/wasm-serialization.cc, NativeModuleDeserializer::ReadCode
AffectsChrome M110 up to and including 151.0.7922.75
Fixed inChrome 151.0.7922.108, 2026-08-06
ImpactArbitrary code execution in the renderer from a malicious website
CVE card. Source: original article.
Kitchen table: Imagine a warehouse that always orders “ninety percent of a thousand palettes.” The clerk multiplies 1024 × 1024 × 1024 × 9 on a 32-bit pocket calculator, the top bit falls off, and they order a tenth of a warehouse. The forklift still thinks the aisle is empty past that tenth. You park a second truck in the overhang. That is the bug. Nobody asked the calculator whether it wrapped. For three years and eight months.

Background: the most audited engine, and a line nobody multiplied

V8 is the poster child for “LLMs will enumerate the rest of the bugs.” Fuzzers, a dedicated team, a VRP, V8CTF, agent-assisted review. The Chrome VRP even cut rewards by over an order of magnitude in the AI era because so many bugs were landing. The wasm deserializer has been a favorite target since 2025. External reports, ClusterFuzz, Big Sleep, Project Fortify — all pointed at this file. Almost none were vulnerabilities: they assumed a forged code-cache blob. That false-positive rain was so heavy V8 added a banner that d8.wasm.deserializeModule() is not VRP eligible.

What they all missed is constant integer arithmetic that has been wrong the whole time. No attacker size. Anyone who asks “does this multiply overflow?” gets the answer in seconds. For 3.5 years, nobody asked — including models handed the exact function.

For operators: If you triage wasm-serialization crashes: “attacker-controlled blob” is the default close. This CVE is the exception that proves you must ask who serializes. Chrome’s HTTP GeneratedCodeCache stores V8’s own output and hashes it. Tamper is out. Self-inconsistency is in.

Bug

The one-line overflow

size_t max_reservation = RoundUp<kCodeAlignment>(v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);
  • v8_flags.wasm_max_code_space_size_mb is a 32-bit unsigned 1024 (210).
  • MB is constexpr int 1024×1024 (220).
  • 9 is 23+1.

210·220·(23+1) = 233+230. All three operands are 32-bit, so 233 wraps away modulo 232. Left: 230. Divide by 10, round up to 64-byte code alignment: max_reservation = 107,374,208 (102.4 MiB) instead of 966,367,680 (921.6 MiB). The code asks for 90% of the max code space and gets 10% of one.

Context

In every snippet, [!] marks a line that matters for the bug or exploit, [*] a locator. Both are QED’s annotations; unmarked comments are V8’s.

// [*] v8/src/wasm/wasm-serialization.cc, NativeModuleDeserializer::ReadCode
uint32_t code_size = reader->Read<uint32_t>();
DCHECK(IsAligned(code_size, kCodeAlignment));
DCHECK_GE(remaining_code_size_, code_size);
if (current_code_space_.size() < static_cast<size_t>(code_size)) {
  // Allocate the next code space. Don't allocate more than 90% of
  // {kMaxCodeSpaceSize}, to leave some space for jump tables.
  size_t max_reservation = RoundUp<kCodeAlignment>(
      v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);       // [!] 32-bit wrap
  size_t code_space_size = std::min(max_reservation, remaining_code_size_);
  std::tie(current_code_space_, current_jump_tables_) =
      native_module_->AllocateForDeserializedCode(code_space_size);
  DCHECK_EQ(current_code_space_.size(), code_space_size);
  CHECK(current_jump_tables_.is_valid());
}
base::Vector<uint8_t> instructions =
    current_code_space_.SubVector(0, code_size);                 // [!] DCHECK-only bound
current_code_space_ += code_size;                                // [!] DCHECK-only bound, length_ underflows
remaining_code_size_ -= code_size;

max_reservation caps code_space_size passed to AllocateForDeserializedCode. Every operand is 32-bit, a few xrefs from the line:

// [*] src/flags/flag-definitions.h -> C++ type is `unsigned int`
DEFINE_UINT(wasm_max_code_space_size_mb, kDefaultMaxWasmCodeSpaceSizeMb,
            "maximum size of a single wasm code space")

// [*] src/common/globals.h
kDefaultMaxWasmCodeSpaceSizeMb = 1024      // [!] #else branch, all but ARM64/Loong64/PPC64

// [*] include/v8-internal.h
constexpr int KB = 1024;
constexpr int MB = KB * 1024;              // [!] int, never widened

The deserializer allocates 102.4 MiB, carves functions from it. code_size is compared only to remaining space. First real function trips the if, gets a space already capped at 102.4 MiB, carved with no further check.

Debug-only bounds check

The only bounds on an oversized code_size are in SubVector and operator+=. Both are DCHECKs:

// [*] src/base/vector.h
Vector<T> SubVector(size_t from, size_t to) const {
  DCHECK_LE(from, to);
  DCHECK_LE(to, length_);          // [!] DCHECK only, release returns an oversized span
  return Vector<T>(begin() + from, to - from);
}

Vector<T> operator+=(size_t offset) {
  DCHECK_LE(offset, length_);      // [!] DCHECK only
  start_ += offset;
  length_ -= offset;               // [!] wraps to ~1.8e19
  return *this;
}

Once length_ wraps (~1.8e19), the size guard never fires again. Every later function is carved contiguously past the end. Those spans go to a relocation job: CopyAndRelocate registers them with ThreadIsolation and memcpys compiled code into them.

Implicit invariants: why 90%?

Reserving 90% and not checking code_size is safe if no compiled function is larger than that. AddCompiledCode caps a single function at half a code space:

// [*] src/wasm/wasm-code-manager.cc, NativeModule::AddCompiledCode()
// Never add more than half of a code space at once. This leaves some space
// for jump tables and other overhead.
size_t max_code_batch_size = v8_flags.wasm_max_code_space_size_mb * MB / 2;
size_t total_code_space = 0;
for (auto& result : results) {
  size_t new_code_space = RoundUp<kCodeAlignment>(result.code_desc.instr_size);
  if (total_code_space + new_code_space > max_code_batch_size) {
    size_t split_point = &result - results.begin();
    if (split_point == 0) {        // [!] a single function, over half a code space
      // [*] ... OOM instead if the flag was lowered for fuzzing, otherwise:
      FATAL("A single code object needs more than half of the code space size");
    }
    // [*] ... otherwise split the batch in two and process each part

50% is below 90%, so with correct math the deserializer’s debug-only check never fires. Overflow drops the deserializer side from 921.6 MiB (90%) to 102.4 MiB (10%) while the compiler side stays 512 MiB (50%). The inequality flips. Now it is a bug.

Affected builds

The wrap needs kDefaultMaxWasmCodeSpaceSizeMb above 455 MiB. ARM64 and Loong64 cap at 128 MiB, PPC64 at 32 MiB — no wrap. Everyone else, including x86 and x86-64, takes the #else 1024 MiB branch and wraps. That is desktop Windows, Linux, ChromeOS, Intel Macs. Apple Silicon and 64-bit Android are ARM64 and not affected. QED exploited x86-64 only; 32-bit x86/ARM wrap the same but were not demonstrated.

Bisect

e284517ba83c (2021-01-26) started allocating one large code space and slicing functions. Not yet vulnerable: reservation was a compile-time size_t constant. 8dc30ad2f455 (2022-11-14) replaced it with the flag so tests could shrink the space:

constexpr size_t kMaxReservation =
    RoundUp<kCodeAlignment>(WasmCodeAllocator::kMaxCodeSpaceSize * 9 / 10);
size_t code_space_size = std::min(kMaxReservation, remaining_code_size_);

size_t max_reservation = RoundUp<kCodeAlignment>(
    v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);
size_t code_space_size = std::min(max_reservation, remaining_code_space_);

WasmCodeAllocator::kMaxCodeSpaceSize was static constexpr size_t of 1024 * MB, so * 9 / 10 folded in 64-bit at compile time. wasm_max_code_space_size_mb is unsigned int, so the same expression became 32-bit at runtime. Review and tooling missed it for years.

Kitchen table: The warehouse used to chisel “90% of 1024 MB” into the stone (64-bit constant). Then they put the number on a sticker so tests could peel it. The sticker is 32-bit. The chisel was 64-bit. Same formula, different calculator.

Exploitation (as QED published it)

Terminology: big is the oversized function that overflows the space. The overhang is the part of big past the end, in memory the allocator still calls free. victim compiles later into that overhang and overwrites big’s tail. V8 compiles wasm twice: Liftoff, then Turboshaft for hot functions. We walk QED’s five steps. We do not ship a Chrome page that pops a shell.

Step 1: Getting the deserializer to run

Not reachable from JS directly. Structured clone is closed: IndexedDB throws DataCloneError when for_storage_ is true; postMessage passes NativeModule by reference. What remains is Chrome’s HTTP GeneratedCodeCache, on by default. Blink serializes a tiered-up module, the browser process stores it, a later response attaches it, V8 deserializes.

Two compiles and the HTTP code cache
Reconstruction of QED Figure 1 (original is an in-page animation). First compile stores the blob; after GC, second compile runs ReadCode.

Two conditions: (1) the blob exists and its SHA-256 matches the wire bytes. 1 MB of top-tier code triggers serialize immediately; one 102 MiB function passes that. (2) the first module must be GC’d — V8’s per-process cache otherwise hands back the live module and never reads the blob.

Cache size: a single entry ≤ max(cache_size/2, 5 MiB), cache_size = min(PreferredCacheSizeInternal(free_space), 480 MiB). ~102 MiB entry wants ~20 GiB free disk. The cache does not use that space; the cap just scales with free. Below that, drop and recompile. Realistic on a desktop. V8CTF’s 8 GB tmpfs is different: UsePersistentCacheForCodeCache is off by default but field-trial testing turns it on, and that path does not use MaxFileSize.

The blob is V8’s own serializer output. Blink hashes wire bytes and checks them before V8 sees the blob. d8’s “not VRP eligible” banner does not apply. V8 emits a module its own deserializer cannot read back safely.

Step 2: Making a function big enough

Need one function whose compiled code exceeds 107,374,208 bytes. Wasm body cap is kV8MaxWasmFunctionSize = 7,654,321, so Turboshaft must expand ≥14×, compile in reasonable time, encode the same on every x86-64. A chain of call_indirect to a 100-parameter, 100-result identity does that at ~1920 code bytes per call:

const pin = []; for (let j = 0; j < P; j++) pin.push(kWasmI64);            // [*] P = 100
const S = b.addType(makeSig(pin, pin));                                    // [*] (i64 x100) -> (i64 x100)
const idbody = []; for (let j = 0; j < PADW; j++) idbody.push(kExprNop);   // [*] > 500 wire bytes, or it gets inlined
for (let j = 0; j < P; j++) idbody.push(kExprLocalGet, j);
const idf = b.addFunction('id', S).addBody(idbody).exportFunc();

Step 3: Overhang

big carved past the code space into supposedly free memory
Reconstruction of QED Figure 2. SubVector oversized, operator+= underflows, victim compiles into the overhang.

Only top-tier code is serialized. victim is never tiered up, so the blob has a one-byte marker and it is compiled after deserialize, into the free space big overhangs. It must fit or the allocator takes a fresh space and nothing overlaps. V8 reserves address space from an estimate that scales with the code-section length, not with what actually compiles, so unused declared functions still enlarge it. Six 7 MB nop bodies left ~20 MB behind the space in QED’s runs.

Step 4: Bypassing JIT allocator CFI checks

ThreadIsolation maps every live JIT allocation and checks new registrations. No flag, no hardware gate:

// [*] src/common/code-memory-access.cc, from JitPageReference::RegisterAllocation
CheckForRegionOverlap(jit_page_->allocations_, addr, size);

The map is per JitPage, one per code-space reservation. Which page a registration hits depends on a size the attacker controls:

constexpr size_t kSplitThreshold = 0x40000;                 // [*] 262,144
JitPageReference page_ref = total_size >= kSplitThreshold
                                ? SplitJitPage(start, total_size)
                                : LookupJitPage(start, total_size);
for (auto size : sizes) { page_ref.RegisterAllocation(start, size, type); start += size; }
Size of next functionPathResult
Under 262,144 BLookupJitPageLands on big’s page; CheckForRegionOverlap sees the oversized allocation; abort
262,144 B or moreSplitJitPage → Shrink()Registration succeeds, leaving two live overlapping RWX WasmCode objects
Split threshold. Source: original article.

The difference is one lower_bound:

// [*] src/common/code-memory-access.cc, JitPageReference::Shrink()
void ThreadIsolation::JitPageReference::Shrink(class JitPage* tail) {
  jit_page_->size_ -= tail->size_;
  // Move all allocations that are out of bounds.
  auto it = jit_page_->allocations_.lower_bound(End());   // [!] keyed by START address
  tail->allocations_.insert(it, jit_page_->allocations_.end());
  jit_page_->allocations_.erase(it, jit_page_->allocations_.end());
}
Shrink partitions the map by start address
Reconstruction of QED Figure 3. A straddling allocation stays in the head; the new page’s map is empty.

WasmCodeAllocator merges free space across adjacent reservations, so one WasmCode can straddle two, and ThreadIsolation merges JitPages on lookup. Shrink is how a merged page splits; ordinary teardown calls it. It cannot reject a cut inside an allocation. UnregisterRange refuses to cut across a live allocation; shrink/split do not. QED reported that as a CFI weakness with the bug.

Step 5: Jumping into constants

Need a branch inside big whose target is after B (end of the 102.4 MiB space), in victim-written bytes. The branch itself must sit before B or victim overwrites it:

// [*] big = block(result i32){ i32.const 0 ; br_if(arg) ; drop ; BULK ; i32.const 0 }
t.push(kExprBlock, kWasmI32);
  t.push(kExprI32Const, 0);        // [*] block result placeholder
  t.push(kExprLocalGet, 0);        // [*] cond = arg
  t.push(kExprBrIf, 0);            // [!] arg != 0 -> break FORWARD to the block end
  t.push(kExprDrop);
  ... N x call_indirect ...        // [*] the 102 MiB bulk
  t.push(kExprI32Const, 0);
t.push(kExprEnd);                  // [!] block end == br_if target, after B
br_if before B, target after B in the victim sled
Reconstruction of QED Figure 4.

Victim is a chain of i64.const K; i64.xor. Liftoff emits movabs r64, imm64 — 8 verbatim attacker bytes in a 13-byte group. Most of the victim is a sled of groups whose six chosen bytes are one-byte no-ops, shellcode at the end. Land anywhere in the sled, reach the end. Cannot all be nop: CSE would fold identical immediates. Each group encodes its index in base 7 over xchg r32,eax 0x90–0x97 except 0x94. Shellcode on V8CTF: open/read/write/exit_group syscalls against –no-sandbox. We are not reprinting a syscall blob.

13-byte Liftoff group with 8-byte immediate
Reconstruction of QED Figure 5. Mid-instruction entry: six payload bytes plus eb 05.
For operators: If you only remember one exploit fact: the primitive is overlapping RWX WasmCode, not a V8 heap OOB. Heap-sandbox bypasses are the wrong ticket. Patch Chrome. ARM64 was never in the blast radius of this multiply.

Where’s the V8 heap sandbox?

Usual renderer exploit: one bug for corruption inside the sandbox, one to leave it. Here the primitive is arbitrary native code through the code allocator, so there is no second stage. QED distinguish “arbitrary code execution” from “out-of-sandbox corruption” because PK-based sandboxed execution (in progress) stops data writes outside the sandbox. This forge starts on the far side of that line.

Kitchen table: Most break-ins pick the lock on the filing cabinet, then pick the lock on the building. This one is allowed to write the fire-exit signs in the hallway because that is how the compiler parks machine code. There is no second door.

Postmortem: how did this survive 3.5+ years?

1. No traditional analysis or testing flagged it

A V8 flag is technically runtime-mutable, so the expression is not a constant. No compile-time overflow diagnostic. QED call it a pseudo-constant: fixed in every practical deployment, invisible to tools that reason about constants. Operands are unsigned, wrap is defined, sanitizers stay quiet. V8 knew the hazard elsewhere: kMaxCommittedWasmCodeMB is 4095 not 4096, comment “just below 4GB, such that kMaxWasmCodeMemory fits in a 32-bit size_t.” Same hazard, one place commented, this place not, nothing in CI.

2. It reads like a statement of intent

flag * MB * 9 / 10 reads as “90% of a code space,” which is what it should compute, so nobody multiplies it out. LLMs may inherit that confirmation habit. Until a human asked specifically about integer overflow.

Food for thought: is the failure that a model cannot do the arithmetic (likely not), or that it never decides to (likely so)?

QED

3. It sits on a rarely tested boundary

The bug is in V8; reachability is Blink’s code cache. V8-only fuzzers never run ReadCode on a real serialized blob. Cross-boundary invariant bugs are a class:

  • CVE-2024-9602 — streaming decoder checked each section vs module limit, never the total. d8’s callback sees the whole buffer; Blink’s does not.
  • CVE-2025-8880 — Blink handed a SharedArrayBuffer view a worker could mutate; validated bytes ≠ kept bytes.
  • b/452605804 — V8 called Blink’s streaming callback twice; Blink assumed that could not happen.
  • b/439380004 — natives syntax while parsing embedder extensions; source string lives in the sandbox; a worker can swap it.

4. Code serialization was already muddied ground

DeserializeNativeModule takes a blob and wire bytes that must match. Forge the blob and you can make it fail arbitrarily — correctly not a vuln, because embedders must hand back what V8 produced. d8.wasm.deserializeModule even prints that it is not VRP eligible. False positives piled up. b/441330944 became a catch-all with ≥13 dupes Aug–Sep 2025. ClusterFuzz, externals, two Google agents, all asked “can we break the deserializer with attacker bytes?” Always no. Every fix hardened the harness and left the deserializer. Nobody asked if it is safe on a blob V8 produced itself.

  • b/441330944 Aug 2025 — ClusterFuzz; closed by no-op under –fuzzing.
  • b/447317861 Sep 2025 — external “OOB write, attacker-controlled code_size trusted in release.” Nearly QED’s words ten months early. Closed: not a vuln; hide API without –enable-wasm-serialization.
  • b/498816449 Apr 2026 — Big Sleep CHECK; closed, strengthen hash.
  • b/511325706 May 2026 — Fortify OOB only by tampering; WontFix, not reachable from web content.

Automated triage of QED’s own report made the same mistake. Comment 6 on b/538378084 is “AI-generated using the v8-security-triaging skill,” reproduces the crash, rates impact none because it “relies on d8.wasm.deserializeModule()” and a manipulated module. The first paragraph of the report said the blob is V8’s own output. Humans corrected it.

So how do you find these?

Pointing an agent at a tree and asking for bugs sometimes works. Finding nothing proves nothing. Scoped to wasm serialization, trusted deserialized bytes, a model returned in five minutes four ruled-out breaks and listed integer overflow as worth checking — then skipped it. Forcing it to actually check the classes it listed found the bug in about a minute at wasm-serialization.cc:1019-1021.

  • State the boundary and the vector. “The blob is trusted” is an assumption. Ask who makes V8 serialize what, and who reads it back.
  • Don’t skip traditional methods. A lint over flag-derived size arithmetic in 32 bits would have found this in 2022.
  • Cross the component boundaries your tests do not. Bug in V8, trigger in Blink. d8 reproduction uses a non-VRP API; only Chrome’s cache showed web reachability.

Reading code at volume is cheap for both sides. Choosing which code, which threat model, which seam — that is still human. Review follows component boundaries. This bug sat where two components meet.

Appendix

Affected versions

Wrap introduced 8dc30ad2f455 2022-11-14, first in Chrome M110 / V8 11.0. Last affected stable 151.0.7922.75. Fix 151.0.7922.108 on 2026-08-06, merged to M150/M151/M152 on 2026-07-31. Every arch except ARM64, Loong64, PPC64. Chrome: desktop Windows, Linux, ChromeOS, Intel Mac. Apple Silicon and 64-bit Android: not affected.

Upstream fix

Landed 2026-07-28 as c3ea7757b190, “[wasm] Fix integer overflow in deserializer”, main@#108914. Three parts:

// Allocate the next code space. Don't allocate more than 90% of
// {kMaxCodeSpaceSize}, to leave some space for jump tables.
// Perform the division first to avoid overflow.
size_t max_reservation = RoundUp<kCodeAlignment>(
    v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);
    v8_flags.wasm_max_code_space_size_mb * MB / 10 * 9);
size_t code_space_size = std::min(max_reservation, remaining_code_size_);
std::tie(current_code_space_, current_jump_tables_) =
    native_module_->AllocateForDeserializedCode(code_space_size);
DCHECK_EQ(current_code_space_.size(), code_space_size);
CHECK_LE(code_size, current_code_space_.size());
CHECK(current_jump_tables_.is_valid());
// Defense in depth: the cut should not be in the middle of a code object.
CHECK(jit_page.EndOfLastAllocation() <= jit_page.End());

Regression test regress-538378084.js is QED’s minimal PoC, pad functions and all.

Mitigation

Update to Chrome 151.0.7922.108 or later.

Timeline

  • 2026-07-23 — discovered in the wasm deserializer.
  • 2026-07-24 — reported b/538378084 with a PoC of two overlapping live executable allocations.
  • 2026-07-24 — full renderer RCE submitted to V8CTF as b/538501386.
  • 2026-07-24 — automated triage: Not a Bug, impact None, WontFix; humans overrode.
  • 2026-07-28 — fixed on V8 main.
  • 2026-07-31 — merged M150/M151/M152.
  • 2026-08-06 — Chrome 151.0.7922.108.
  • 2026-09-05 — this blog.

Acknowledgements

QED thank the V8 and Chrome security teams for prompt triage, fix, and coordinated disclosure.

The arithmetic on paper, once

1024u * (1024*1024) is 230, still inside 32 bits. Times 9 is 9×230 = 233+230. 233 is 2×232, gone. Remainder 230 = 1,073,741,824. /10 = 107,374,182.4, RoundUp to 64 → 107,374,208. Intended: promote to size_t first, or divide first: flag * MB / 10 * 9. That is the fix. A one-character order change. The CL that introduced the flag did it to help tests. Tests never set the flag to 1024 on a 32-bit multiply and asked for 90%.

For operators: Lint idea QED are pointing at: any size_t reservation of the form uint32_flag * MB * k / d evaluated in 32-bit is a finding. kMaxCommittedWasmCodeMB’s comment is the template for the diagnostic.

Why the AI triage failed in public

The v8-security-triaging skill reproduced the crash and still said WontFix because the reproduction used d8.wasm.deserializeModule. That API is the lab harness. The report’s summary was the threat model. This is the same skip the five-minute agent did: integer overflow listed, not multiplied. Skills that pattern-match “deserialize + wasm = not VRP” will keep closing the only bugs that matter in that file. Humans unblocked it in hours. That is the working process. The skill is not.

CISO notes that are not “update Chrome”

  • ARM64 Chrome (most phones, M-series Macs) never wrapped. Inventory x86_64 desktops and ChromeOS Intel first.
  • M110–M151.0.7922.75. If you freeze enterprise Chrome, this is a freeze-risk CVE, not a zero-day-of-the-week.
  • No extra sandbox bug required. Treat as renderer RCE from the web, not “needs a sandbox escape to be interesting.”
  • 20 GiB free disk is the desktop cache gate; do not comfort yourself that kiosks with tiny disks are safe if they use PersistentCache field trials.

Why 102.4 MiB is not a fuzzer-shaped number

Fuzzers love lengths they can grow. This reservation does not take a length. It takes a flag that is 1024 on every shipping x86 Chrome, an MB constant, and a 9. ClusterFuzz mutating the serialized blob was the wrong experiment, which is why b/441330944 exists. The right experiment is: serialize what V8 actually emits for a huge function, then deserialize it after GC. That experiment lives in Blink’s code cache, not in d8. A wasm module with one 102 MiB Turboshaft function is also a terrible fuzzer seed: slow, huge, and it needs ~20 GiB free disk on the simple cache backend. Fuzzing economics skipped the only input that hits the wrap.

The 20 GiB free-disk gate is easy to misread as “kiosks are safe.” PreferredCacheSizeInternal scales the per-entry cap with free space; a desktop with a large disk is the intended customer of a 102 MiB cache entry. V8CTF’s 8 GB tmpfs would have blocked the simple backend — and still ran the exploit because field-trial PersistentCache does not use MaxFileSize. Do not copy the CTF disk size into an enterprise exception.

Liftoff immediates as a write-what-where into RWX

Once two WasmCode objects overlap, you still need a controlled branch in the older one and controlled bytes in the newer one. QED’s br_if at the front of big is the branch you do not let victim overwrite. The block end after B is the landing. Liftoff’s movabs is the write: eight little-endian bytes of i64.const sit in the instruction stream. Enter mid-instruction and those eight bytes are code. Six of them payload, two of them eb 05 to the next group. CSE is the only compiler enemy: identical immediates collapse. Base-7 xchg eax encodings (0x90–0x97 minus 0x94) give each group a unique immediate so the chain stays a chain. A sled of those groups means you do not need the exact landing offset. That is why they call it brilliantly simple: the CPU already wanted to run immediates as code if you started two bytes late.

We are not publishing a syscall sequence. V8CTF’s –no-sandbox flag file read is a contest trick, not a product exploit. On a real Chrome renderer you still sit in the renderer process. The point QED stress is you did not need a second bug to get there. Native code is native code.

Kitchen table: Think of machine code as a comic strip. Liftoff draws a caption box (the immediate) inside each panel. If you start reading from inside the caption instead of from the panel border, the caption is the story. The sled is a long hallway of identical-looking caption boxes that all say “keep walking.” The last boxes say something else. You do not need to know which tile you landed on.

The 50% / 90% inequality, drawn as a see-saw

AddCompiledCode refuses a single function over half a code space (512 MiB at the 1024 flag). The deserializer reserved 90% so jump tables fit. 512 < 921.6, so a legal function always fit in the reservation, so the missing release-mode bounds check was “dead.” After the wrap, the reservation is 102.4 MiB and the compiler still allows 512. 512 > 102.4. Dead code becomes the only fence, and the fence is DCHECK. That is an invariant split across two files (wasm-code-manager.cc vs wasm-serialization.cc) and two integer widths. Reviewers of the 2022 reland were looking at test flexibility, not at whether 9/10 of an unsigned flag still sat above 1/2 of the same flag.

For operators: When you change a size_t constexpr to a uint32 flag, re-prove every inequality that mentioned the old constant. 90% of 1024 MB in 64-bit is not 90% of 1024u * MB in 32-bit. A one-line comment next to kMaxCommittedWasmCodeMB already said this in English.

False positives as a vaccine against the real bug

b/447317861 in September 2025 used almost QED’s sentence: attacker-controlled code_size, trusted in release, OOB write, same function. Closed because the threat model was a forged blob. Ten months later the same function, honest blob, same missing check. A catch-all (b/441330944) plus a banner on the d8 API trained humans and agents to stop looking. Project Fortify even agreed with itself that Chrome hashes the blob. All true. All orthogonal to “does V8’s own serializer emit a length its deserializer cannot honor.”

The AI triager on b/538378084 is the public demo of that vaccine. It reproduced the crash and classified impact none. The skill’s prior is “deserializeModule ⇒ not VRP.” QED’s first paragraph is the override. Process that cannot read a summary will keep closing seam bugs. The V8 dev who wrote “the Summary section explains why that rule doesn’t apply” is the actual security boundary.

A tiny lint that would have closed this in 2022

QED’s traditional-methods paragraph is the cheapest lesson. Any Clang plugin or clang-tidy check that flags uint32 * MB * k (or flag * MB * 9 / 10) as a 32-bit size expression destined for size_t would have fired on 8dc30ad2f455. No LLM. No V8CTF. No 20 GiB disk. The comment on 4095 already documents the failure mode. Promoting MB to size_t, or dividing first, is the local fix they shipped. The global fix is making that promotion a rule.

Agents that list integer overflow and then skip the multiply are doing the opposite of lint: they generate a checklist and do not execute it. QED’s one-minute re-prompt — “check the classes you listed” — is a process patch you can apply tomorrow without waiting for a smarter model.

What “no sandbox bypass” means for your threat model

Site isolation still contains the renderer. This is not a browser-wide sandbox escape. It is “malicious site runs native code as the renderer.” That used to require two CVEs for many V8 bugs. Budget it as one. Experimental memory-protection-key sandboxes aimed at data corruption do not see a memcpy into a ThreadIsolation-registered executable span as out-of-sandbox. The allocator invited the write. That is QED’s warning about future hardening: classify primitives, not just “corruption vs escape.”

Reading this if you ship an embedder of V8

If you are Electron, Node, or a custom embedder: you may not have Blink’s GeneratedCodeCache. Then this specific trigger may not fire. You may have your own serialize/deserialize of NativeModule. If you do, you have this bug anywhere you shipped M110–M151 x86_64 V8. d8’s non-VRP API is a red herring for you too. Your cache is the embedder path. Hash the wire bytes if you must; still allocate the reservation in 64-bit arithmetic.

Kitchen table: Chrome is one tenant of V8. Anyone else who stored compiled wasm on disk and handed it back is the same tenant with a different filing cabinet. Updating Chrome is necessary. It is not sufficient for every product that linked V8 this decade.

A glossary for the 32-bit warehouse

NativeModule is V8’s compiled wasm module. ReadCode is the function that copies serialized machine code back into executable memory. GeneratedCodeCache is Blink’s HTTP disk cache for that blob, keyed and hashed on the wire bytes. Liftoff is the baseline compiler; Turboshaft is the optimizing tier whose output gets serialized. ThreadIsolation is V8’s map of JIT pages; CheckForRegionOverlap is supposed to make two executable allocations refuse to share bytes. Shrink() is how a merged JIT page is split, keyed on start address, which is why a straddling allocation does not move. A pseudo-constant is a value that never changes in production but is typed as mutable, so compilers and linters that hunt constant overflow cannot see it. That is the whole bug class.

kCodeAlignment is 64 bytes. RoundUp to that alignment is why 107,374,182.4 becomes 107,374,208. remaining_code_size_ is the serializer’s idea of how much compiled code is left to place; it is always at least code_size, so the only way to under-allocate is one function larger than max_reservation. That is why QED needed a 14× expansion through Turboshaft instead of a 7.6 MB wasm body. call_indirect to a 100-wide identity is the expansion that encodes the same on every x86-64, about 1920 bytes of machine code per call, no CPUID forks.

What QED did not need

They did not need a corrupted code-cache file. They did not need a SharedArrayBuffer race. They did not need a V8 heap OOB, a compressed-pointer dance, or a sandbox-heap leak. They needed Chrome to do its job: compile wasm, cache it, GC the first module, compile again. The malicious page is a large wasm module plus a second compile after collection. That is a website, not a debugger. Site isolation still boxes the renderer. The boxed process now runs attacker native code. That used to be a two-bug chain for many V8 issues. Here it is one line of C++ from 2022.

They also did not need ARM64. Phones and M-series Macs cap a wasm code space at 128 MiB, so 90% never wraps. If your threat model is “we only have Android Chrome,” this CVE is not your renderer RCE. If your threat model is “the CFO’s Windows laptop,” it was, from M110 until 6 August 2026.

For operators: Fleet split: x86_64 Chrome/ChromeOS/Intel Mac → patch 151.0.7922.108 (or M150 merge) as renderer-RCE. ARM64 → this multiply is not your bug; keep patching anyway. Electron/Node on x86_64 with a wasm code cache → treat as in-scope even if the binary is not branded Chrome.

The human question that beat the models

QED’s food-for-thought is the paragraph to steal. Models can multiply 1024×1024×1024×9. They did not decide to. Handing the function verbatim, with literals substituted, or with the prompt “integer overflow” would answer whether the failure is arithmetic or initiative. Their own scoped run listed overflow and skipped it in five minutes, then found line 1019 in one minute when required to execute the list. That is not a model-size problem. It is a review-protocol problem. Put “evaluate every class you named” in the skill, or you will keep publishing five-minute all-clears on files that still wrap.

Kitchen table: A student who writes “I should check the multiply” on the homework and then turns in the paper without checking has not failed math. They have failed the assignment. So did the five-minute agent. The one-minute re-prompt is the teacher saying “do the problems you listed.”

If you only remember five numbers

  • 1024 × MB × 9 wraps on uint32; you wanted 921.6 MiB and got 102.4 MiB.
  • DCHECK-only SubVector / operator+= ; release length_ becomes ~1.8e19.
  • 262,144 bytes is the SplitJitPage threshold that turns overlap into two live RWX objects instead of a CHECK.
  • Chrome 151.0.7922.75 last affected; 151.0.7922.108 the fix (2026-08-06); M110 the first ship.
  • 3 years 8 months from 8dc30ad2f455 (2022-11-14) to the report (2026-07-23).

A last look at the two CLs

e284517ba83c in January 2021 made the deserializer allocate one large code space and slice. That was the shape of the later bug, but the reservation was still a 64-bit constant, so 90% of 1024 MB was computed before any uint32 entered the story. 8dc30ad2f455 in November 2022, a reland of “do not add too much code at once,” swapped the constant for a flag so fuzzing could shrink the space. The CL did what it said. It also quietly changed the width of the multiply. Relands are where reviewers look at the delta against the first landing, not at whether MB is still int. That is a process tell, not a morality tale. The next reland that turns constexpr size_t into unsigned flag arithmetic deserves a 64-bit cast in the same patch, or a test that asserts the 90% reservation is actually 90% at the default flag.

QED’s regression, regress-538378084.js, is that test, shipped with the fix. If you vendor V8, take the CL and the regression, not just the one-line /10 * 9 reorder. The CHECK_LE on code_size and the Shrink() CHECK that the cut is not mid-object are the other two thirds of the report. A multiply fix without those CHECKs leaves the CFI weakness they filed alongside the overflow.

Key Takeaways

  • CVE-2026-19174: 32-bit wrap in wasm deserializer reservation; 102.4 MiB allocated instead of 921.6; debug-only bounds; overlapping RWX via Shrink().
  • Constants only. Pseudo-constant flag. Survived fuzz, audit, LLMs, and a file that was a false-positive magnet.
  • Reachable through Chrome’s code cache, not through d8’s non-VRP deserialize API. Heap sandbox bypass not required.
  • x86/x86-64 Chrome M110–151.0.7922.75. Fixed 151.0.7922.108 (2026-08-06). ARM64 not affected.
  • State the boundary. Multiply the constants. Cross Blink. Lint 32-bit size math.

Defensive Recommendations

  1. Patch. Chrome ≥ 151.0.7922.108 (or the M150 merge). Prioritize x86_64 fleet.
  2. Do not treat d8.wasm.deserializeModule reports as automatically WontFix; read whether the blob is V8’s own.
  3. Lint flag * MB * k / d in 32-bit types. The 4095 comment is the spec.
  4. Test embedder-driven paths (code cache, streaming) not just d8.
  5. When an agent lists a bug class, require it to evaluate it. “Worth checking” is not a check.

Conclusion

The most audited JS engine in the world shipped a 32-bit multiply of three constants for 44 months. Fuzzers could not reach the embedder path. Models read “90%” and moved on. A catch-all bug taught everyone that this file’s failures are harness noise. Then someone multiplied. Update Chrome. Then go find the next pseudo-constant on a seam your tests do not cross.

Original text: “A Brilliantly Simple Bug In V8” by {AUTHOR} at {PUB}.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion