A Brilliantly Simple Bug In V8
Authors
Published
September 5, 2026
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)!
Abstract
QED found CVE-2026-19174 (b/538378084) in the V8 JavaScript engine: an integer overflow in the WebAssembly code space reservation that lets a malicious web page directly corrupt JIT code. It yields arbitrary native code execution in the renderer which we demonstrated with an end-to-end exploit against Chrome M150 on V8CTF.
Most interestingly, the overflowing expression is a multiplication of three constant integers that takes no attacker-derived value. It is a single line of computation with a default V8 flag value, multiplied by two other constants, that has computed the wrong number on every x86/x86-64 Chrome since 2022. It survived three years and eight months of fuzzing, manual audit, and more recently a flurry of LLM-driven review, including reviews of the exact file the bug resides in. The exploit needs no separate V8 heap sandbox bypass as the primitive forges native code directly, allowing immediate arbitrary native code execution.
| CVE | CVE-2026-19174 (b/538378084) |
| Where | v8/src/wasm/wasm-serialization.cc, NativeModuleDeserializer::ReadCode |
| Affects | Chrome M110 up to and including 151.0.7922.75 |
| Fixed in | Chrome 151.0.7922.108, 2026-08-06 |
| Impact | Arbitrary code execution in the renderer from a malicious website |
Background
The prevailing expectation is that LLMs industrialize bug discovery. Anyone with access to the target system can point agents at it. Every announcement and PR of a fancy new security agent finding N bugs is read as evidence that the remaining bugs are being systematically enumerated, and that we could envision a state where the system is hardened. And maybe even “bug-free”!
V8 is one of the strongest cases for that expectation. It has continuous testing including Google’s fleet of fuzzers, a dedicated security team, a VRP, V8CTF, and agent-assisted development and code review that is now routine upstream. Did we talk about all the external researchers, both humans and AI-driven agents, constantly dissecting the code to find even the subtlest of bugs? Such immense efforts and the huge influx of newly discovered bugs even led the Chrome VRP to reduce its rewards by over an order of magnitude.
All of that had already been targeting the exact code this post is about. The bug of our particular interest is in V8’s WebAssembly deserializer, the code that reads back a serialized, compiled module from Chrome’s on-disk code cache. Google’s own fuzzers and code review systems, both traditional and AI-driven, have been hammering it since 2025. External researchers have reported issues in the exact code that we’re looking at. None of these were real vulnerabilities as their attack scenario assumed fully attacker-controlled code cache contents. This was such a frequent issue that V8 developers had to add an explicit warning that the relevant testing API is not VRP eligible!
What all of these efforts missed is a single line of constant integer arithmetic that was wrong the whole time. Yes, the whole time. There is no attacker-controlled length or size or anything involved here, just constant integer multiplication. Anyone who looks at it and asks whether the multiply overflows can get the answer in seconds. For over 3.5 years, nobody asked.
This post is about how we found the bug, why such a trivial bug went undiscovered for so long, and what this implies for both offense and defense now that reading code at scale is cheap.
Bug
The one-line overflow
size_t max_reservation = RoundUp<kCodeAlignment>(v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);Yes, this is the bug in its entirety.
v8_flags.wasm_max_code_space_size_mbis a 32-bit unsigned integer of value1024, that is, .MBis aconstexpr intof1024 * 1024, that is, .9is… nine. Or shall we say, ?
. All three operands are 32-bit, so wraps away modulo . We now have instead of the intended value, which divided by 10 and rounded up to the 64-byte code alignment leaves max_reservation at 102.4 MiB (107,374,208) instead of 921.6 MiB (966,367,680). The code asks for 90% of the max code space size and gets 10% of one.
Let’s dig deeper step by step and contextualize what that costs.
Context
In every snippet below,
[!]marks a line that matters for the bug or the exploit, and[*]marks a locator or context. Both are our annotations; unmarked comments are V8’s own.
// [*] v8/src/wasm/wasm-serialization.cc, NativeModuleDeserializer::ReadCodeuint32_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 boundcurrent_code_space_ += code_size; // [!] DCHECK-only bound, length_ underflowsremaining_code_size_ -= code_size;We see that max_reservation is used to compute code_space_size, the code space size to be allocated for deserialized code. This is passed on to native_module_->AllocateForDeserializedCode(code_space_size) to allocate and create the actual JIT code space, tracked as current_code_space_. Every operand in that computation is a 32-bit integer, and each one is a few static xrefs away from the line itself:
// [*] 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.hkDefaultMaxWasmCodeSpaceSizeMb = 1024 // [!] #else branch, all but ARM64/Loong64/PPC64
// [*] include/v8-internal.hconstexpr int KB = 1024;constexpr int MB = KB * 1024; // [!] int, never widenedIt is now clear that the computation overflows, and that every operand feeding it is either a compile-time constant or a default V8 flag value that does not change at runtime.
So the deserializer allocates 102.4 MiB where V8 intended 921.6 MiB, and carves each function out of it in turn. code_size, one function’s compiled size read from the serialized module, is compared only against what is left of the current space. current_code_space_ starts empty, so the first function carrying real code trips the if immediately, gets a fresh code space that is itself capped at 102.4 MiB, and is carved out of it with no further check.
Debug-only bounds check
The only bounds on an oversized code_size are in SubVector and operator+=. Both are debug-only:
// [*] src/base/vector.hVector<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, current_code_space_ reports a size of roughly 1.8e19, so the if (current_code_space_.size() < code_size) guard never fires again and every later function is carved contiguously further past the end. The carved spans are then handed to a relocation job, where CopyAndRelocate registers each one with ThreadIsolation, V8’s JIT allocation tracker, and memcpys the compiled code into it.
Implicit Invariants: Why 90%?
Reserving 90% of a code space and then not bounds-checking code_size is safe as long as no compiled function is larger than that. AddCompiledCode caps a single function at half a code space, and refuses it fatally otherwise:
// [*] 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 part50% is below 90%, so with correct arithmetic the deserializer’s debug-only check never triggers. The overflow drops the deserializer side of that inequality from 921.6 MiB (90%) to 102.4 MiB (10%) while the compiler’s side stays at 512 MiB (50%). The inequality flips, and now the code is vulnerable.
Affected builds
The wrap needs kDefaultMaxWasmCodeSpaceSizeMb to be above 455 MiB. ARM64 and Loong64 cap a code space at 128 MiB and PPC64 at 32 MiB, so nothing wraps there. Every other target takes the #else branch at 1024 MiB and wraps. Most importantly, this includes x86 and x86-64.
Bisect
e284517ba83c (2021-01-26, “[wasm][serialization] Allocate code in large chunks”) changed the deserializer to allocate one large code space and slice each function out of it. It was not vulnerable then, because the reservation was a compile-time size_t constant.
8dc30ad2f455 (2022-11-14, “Reland [wasm] Do not add too much code at once”) replaced that constant with the new flag:
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_size_);The goal of that CL was to let tests shrink the code space. WasmCodeAllocator::kMaxCodeSpaceSize was a static constexpr size_t of 1024 * MB, so * 9 / 10 was folded at compile time in 64-bit arithmetic. wasm_max_code_space_size_mb is an unsigned int, so the same expression became 32-bit arithmetic evaluated at runtime, and neither review nor tooling caught it for the last several years.
Exploitation
Terminology: big is the oversized function whose compiled code overflows the code space. The overhang is the part of big’s code that lands past the end of that space, in memory the allocator still considers free. victim is compiled later into the overhang, overwriting big’s trailing bytes. The names come from our exploit source: victim is the one carrying the bytes we choose, and big is the code that ends up corrupted. V8 compiles wasm twice, Liftoff first and then Turboshaft, the top tier, for hot functions.
Step 1: Getting the deserializer to run
The deserializer is not reachable from JS directly. Both structured-clone paths are closed: GetWasmModuleTransferId throws DataCloneError when for_storage_ is true, which covers IndexedDB, and postMessage passes the NativeModule by reference instead of serializing. This leaves only Chrome’s HTTP GeneratedCodeCache, which is on by default for every web page. V8 has no on-disk cache of its own here. It exposes a serialization API, and Blink, the renderer engine that embeds V8, drives it. Blink serializes a tiered-up module into a blob of bytes, hands that blob to the browser process to store, and attaches it back to a later response for V8 to deserialize.
Figure 1: The first compile fetches the module, drives it to the top tier and lets Chrome store the serialized blob in the HTTP code cache. Once the first NativeModule is collected, the second compile deserializes the cached blob instead of being handed the live module, and ReadCode runs.
Even on that path, the deserializer only reads the blob if both of these hold.
- The blob has to exist, and its stored SHA-256 has to match the wire bytes being compiled. V8 asks Blink to serialize once tier-up has produced a chunk of top-tier code, and 1 MB of it triggers that request immediately, so a single 102 MiB function passes the threshold the moment the top-tier compiler finishes with it.
- The first module has to be gone. V8 keeps compiled modules in a per-process cache keyed on the wire bytes, so compiling the same bytes again is handed the module already in memory and never looks at the blob. Only once that first module has been garbage collected does the deserializer run at all.
The code cache also has one size precondition. A single entry may not exceed MaxFileSize() = max(cache_size/2, 5 MiB), and for the native code cache cache_size is min(PreferredCacheSizeInternal(free_space), 480 MiB). Computing those limits, caching a ~102 MiB entry requires about 20 GiB of free disk. The cache never uses that space. The per-entry cap just scales with whatever is free. Below that the entry is dropped and the page just recompiles. This is a realistic assumption for a desktop install.
This did not apply on the V8CTF instance, which only has an 8 GB tmpfs.
UsePersistentCacheForCodeCacheis disabled by default, but Chrome’s field-trial testing config turns it on, and that path stores entries throughPersistentCacheCollectionrather than the simple disk cache backend thatMaxFileSizebelongs to.
The blob is V8’s own unmodified serializer output, and Chrome will not accept anything else: Blink stores a SHA-256 of the wire bytes alongside the cached blob and checks it against the bytes being compiled before V8 ever sees it. So d8’s “manipulated input, not VRP eligible” banner does not apply here. V8 emits a module that its own deserializer cannot read back safely.
Step 2: Making a function big enough
The overflow requires a single function whose compiled code exceeds 107,374,208 bytes. remaining_code_size_ is always at least code_size, so the only way to under-allocate is for one function to exceed max_reservation by itself.
A wasm function body is capped at kV8MaxWasmFunctionSize, 7,654,321 bytes, so the construct has to expand at least 14x through Turboshaft, compile in reasonable time, and encode identically on every x86-64 CPU. A chain of call_indirect calls to a 100-parameter, 100-result identity function does all three at roughly 1920 code bytes per call, because the body is argument marshaling and no CPU feature changes how that is encoded:
const pin = []; for (let j = 0; j < P; j++) pin.push(kWasmI64); // [*] P = 100const 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 inlinedfor (let j = 0; j < P; j++) idbody.push(kExprLocalGet, j);const idf = b.addFunction('id', S).addBody(idbody).exportFunc();Step 3: Overhang
Figure 2: code_size exceeds the whole code space, so SubVector hands back an oversized span and operator+= underflows. The compiled code is written into memory the allocator still considers free, and the size guard never fires again.
Only top-tier code is serialized, and victim is never tiered up, so the blob carries a one-byte marker for it and it is compiled after deserialization, into the free space big overhangs into. It has to fit there, or the allocator takes a fresh code space and nothing overlaps. That space exists because V8 reserves address space for a module’s code up front, sized from an estimate that scales with the length of the code section rather than with what actually gets compiled, so declared functions that never run still enlarge it. Six 7 MB nop bodies left about 20 MB behind the code space in our runs.
Step 4: Bypassing JIT space allocator’s CFI hardening checks
V8’s ThreadIsolation keeps a map of every live JIT allocation and checks each new registration against it. Nothing gates that check on a flag or on hardware support:
// [*] src/common/code-memory-access.cc, from JitPageReference::RegisterAllocationCheckForRegionOverlap(jit_page_->allocations_, addr, size);The map it checks is jit_page_->allocations_, which belongs to one JitPage. WasmCodeAllocator registers a JitPage per code space reservation, so the invariant holds per reservation rather than globally. Which one a registration lands on depends on a size we control:
constexpr size_t kSplitThreshold = 0x40000; // [*] 262,144JitPageReference 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; }total_size is the AddCompiledCode batch size, so the same corrupted layout either aborts or succeeds depending on the size of the next function compiled.
| Size of next function | Path taken | Result |
|---|---|---|
| Under 262,144 B | LookupJitPage | Lands on big’s page, CheckForRegionOverlap sees the oversized allocation, and the process aborts |
| 262,144 B or more | SplitJitPage, which calls Shrink() | Registration succeeds, leaving two live overlapping RWX WasmCode objects |
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());}Figure 3: Shrink() partitions the allocation map by start address. An allocation that straddles the cut stays in the head page, and the new page covering its tail is created with an empty map, so the overlap check has nothing to compare against.
None of this needs an exotic layout. WasmCodeAllocator merges free space across adjacent reservations, so one WasmCode can be carved across the boundary between two, and ThreadIsolation merges those JitPages when it looks that allocation up. Shrink is how a merged JitPage gets split back apart, and ordinary teardown calls it whenever only part of a range is freed, so it cannot reject a cut that lands inside an allocation.
Interestingly, V8 enforces this invariant elsewhere. UnregisterRange refuses to cut across a live allocation, but the shrink and split paths do not. We reported this as a CFI weakness along with the bug.
Step 5: Jumping into constants
Turning the overlap into execution needs a branch inside big whose target lands after B, the end of the 102.4 MiB code space, in the bytes the victim wrote. The branch has to sit before B, or the 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 BFigure 4: The branch is emitted at the front of the function, before B, so the victim never overwrites it. Its target is the block end, which lies after B inside the victim’s sled.
The victim is a chain of i64.const K; i64.xor. Liftoff materializes each constant with movabs r64, imm64, which gives 8 verbatim attacker bytes inside a repeating 13-byte group:
Figure 5: Liftoff materializes each i64.const as movabs r64, imm64, giving 8 attacker-chosen bytes in every 13. Entered mid-instruction, each immediate is six bytes of payload plus a two-byte jump over the intervening opcodes, so the groups chain.
Most of the victim is a sled, a long run of groups whose six chosen bytes are no-ops, with the shellcode in the groups at the end. A landing anywhere in the sled therefore reaches the shellcode, which saves computing the exact landing offset.
The no-ops have to be one-byte encodings, so entering at any offset inside a group stays in sync and reaches the eb 05 that ends it, a relative jump over the intervening opcodes onto the next group’s bytes. They cannot all be nop, because CSE folds identical immediates into a single movabs and breaks the chain, so each group encodes its own index in base 7 over the seven usable xchg r32, eax bytes, 0x90 through 0x97 (without 0x94, xchg esp, eax). The shellcode is open("/flag/flag"), read, write(2), exit_group, in raw syscalls, which reads the V8CTF flag under --no-sandbox.
Where’s the V8 heap sandbox?
A renderer exploit usually takes two bugs, one for memory corruption inside the sandbox and one to get out of it. Here the primitive is arbitrary native code execution, so there is nothing left to escape and the exploit carries no separate bypass stage.
We say “arbitrary code execution” rather than “out-of-sandbox corruption” because the two may stop being the same thing under current and future hardening. V8 is building an experimental sandboxed execution mode in which memory protection keys stop code from writing outside the sandbox. That targets attacks which begin by corrupting data. This primitive forges native code through the code allocator itself, so it starts on the far end of that boundary and such mitigations do not apply.
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 and nothing catches it at compile time. The compiler never folds it and no constant-overflow diagnostic fires. We call this a pseudo-constant, fixed in every practical deployment but invisible to every tool that reasons about constants. Runtime does not catch it either. The operands are unsigned, so the wrap is well-defined rather than undefined behavior, and no sanitizer flags it by default.
V8 was aware of the hazard elsewhere. kMaxCommittedWasmCodeMB is set to 4095 rather than 4096, and the comment says why: “Just below 4GB, such that {kMaxWasmCodeMemory} fits in a 32-bit size_t.” The same hazard, known in one place and missed in another, with nothing in CI to catch the miss.
2. It reads like a statement of intent
flag * MB * 9 / 10 reads as “90% of a code space”, which is exactly what it is meant to compute, so nobody multiplies it out. LLMs may have inherited that habit from the code and the reviews they trained on, along with a tendency to read for confirmation that code is safe. That is what happened here, 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)? Handing the same function to a panel of models three ways would answer it, verbatim, with the flag and
MBreplaced by their literal values, and verbatim but with the prompt naming the bug class.
3. It sits on a rarely tested boundary
The bug is in V8 but is only reachable through Blink’s code cache. Anyone testing V8 in isolation, which includes almost every fuzzer pointed at this engine, cannot reach ReadCode with a real serialized blob.
Testing V8 on its own is a reasonable simplification and usually the right one, but it cannot reach the paths only an embedder drives. The class is a cross-boundary invariant violation, where one side relies on an invariant the other does not maintain. It is not an unknown avenue, and has proven a rich source of simple bugs with unusually strong primitives:
- CVE-2024-9602. The streaming decoder checked each section against the module size limit but never the total, so a module could grow past it. Testing in d8 hid this, because d8’s streaming callback receives the whole module as one buffer and checks that size up front, which Blink’s never does.
- CVE-2025-8880. Blink handed that same decoder a view of a
SharedArrayBuffera worker could keep mutating, so the bytes V8 validated were not the bytes it kept, and wasm function body validation could be bypassed entirely. - b/452605804. V8 called Blink’s streaming callback a second time. Blink’s state machine was written to assume that could not happen, and the fix had to land in both repositories.
- b/439380004. V8 enables natives syntax while parsing the extensions Blink registers, treating them as trusted because the embedder supplied them. The source string it compiles from lives in the sandbox, so a worker can swap it mid-compilation and get arbitrary intrinsics.
4. Code serialization was already muddied ground
DeserializeNativeModule takes two inputs that have to correspond: the serialized blob and the wire bytes. Forge the blob and you can trivially make it fail in arbitrary ways, since the blob is trusted (and even contains native code). That is correctly not a vulnerability, because embedders are expected to hand back exactly what V8 produced. In d8 the API is d8.wasm.deserializeModule, which answers with NOTE: d8.wasm.deserializeModule() is not VRP eligible.
Thus, the function carries a long history of false positives, and lately of “AI slop”. Fuzzers alone produced enough of them that b/441330944 was made a catch-all, with at least 13 separate issues duplicated into it between August and September 2025 alone, running from 441127631 to 443814473. ClusterFuzz, external researchers and two of Google’s own AI agents all arrived at this function, and all got the same (wrong) answer.
- b/441330944, Aug 2025. ClusterFuzz starts feeding the newly-moved API arbitrary bytes. A V8 dev: it “is known to be not fuzzer safe… so I’ll make this issue the catch-all and dupe” the rest against it. Closed by making the API do nothing under
--fuzzing. - b/447317861, Sep 2025. An external report of an “out-of-bounds write … attacker-controlled
code_sizefields are trusted without proper validation in release builds”. Same function, and nearly the words we would use ten months later. The answer: “Yeah, this is not a vulnerability. We had multiple such reports already”. Closed by adding--enable-wasm-serialization, which hides the API by default. - b/498816449, Apr 2026. Google’s Big Sleep agent files a CHECK failure in
wasm-serialization.cc, from invalid bytes that still pass d8’s pairing hash. A V8 dev: “this is not a vulnerability (because embedders are expected to pass valid bytes to deserialization)”. Closed by strengthening the hash. - b/511325706, May 2026. Google’s Project Fortify finds an OOB write reachable only by tampering with a serialized module. Closed WontFix as “not reachable from web content”, the agent’s own reproduction agreeing that Chrome hashes the blob and rejects tampered bytes before the deserializer runs.
Every one of these asked the same question, whether the deserializer can be broken by feeding it bytes the attacker chose. Under that threat model the answer is always no, which is why every fix hardened the harness and left the deserializer alone. Nobody asked whether it is safe on a blob V8 produced itself. Our bug is in that same function, and the blob is V8’s own unmodified output delivered through Chrome’s code cache, so none of those disclaimers apply.
The first response to our report was automated, and made the same mistake. Comment 6 on b/538378084 announces itself as “AI-generated using the v8-security-triaging skill”, reproduces the crash, yet rates its security impact as none because “the vulnerability relies on d8.wasm.deserializeModule() to deserialize an artificially manipulated Wasm module”. Our first paragraph says the blob is V8’s own unmodified output. Humans corrected it soon after, with a V8 dev clearly stating: “The reason d8.wasm.deserializeModule() usually isn’t eligible is because fuzzers/AIs like to use that for creating arbitrarily manipulated cached blobs. In this case however, the ‘Summary’ section explains why that rule doesn’t apply.”
So how do you find these?
Pointing an agent at a codebase and asking it to find bugs sometimes works. But when it finds nothing, that proves nothing. It hands back a table of what it checked and ruled out, and that table is the same whether the code is clean or the agent missed something. Scoped to wasm serialization, under a threat model where deserialized bytes are trusted because they come from serialized bytes, the model came back in five minutes with four ways the code could break, each ruled out, and integer overflow listed as worth checking and then skipped. Requiring it to actually check the bug classes it had listed found the bug in about a minute, at wasm-serialization.cc:1019-1021.
State the security boundary and the attack vector. “The blob is trusted” is an assumption. Check it by asking who can make V8 serialize what, and who reads it back.
Don’t skip the traditional methods. A compiler pass or a lint over flag-derived size arithmetic evaluated in 32 bits would have found this in 2022, cheaply, with no model involved.
Cross the component boundaries your tests do not, and verify there. The bug was in V8, the trigger was in Blink, and no V8-only harness could reach it. Our own d8 reproduction runs through an API V8 says is not VRP eligible. Only driving the same bug through Chrome’s code cache showed it was reachable at all.
Reading code at volume is now cheap for both the attacker and the defender. Choosing which code to read, and under which threat model and attack surfaces, is where human judgment still matters. Review boundaries tend to follow component boundaries, and this bug sat where two components meet. That seam is where a defender has the most to gain.
Appendix
Affected versions
The wrap was introduced by 8dc30ad2f455 on 2022-11-14 and first shipped in Chrome M110 / V8 11.0. The last affected stable release is 151.0.7922.75, and the fix shipped in 151.0.7922.108 on 2026-08-06. The fix was also merged to M150, M151 and M152 branches on 2026-07-31.
Every architecture except ARM64, Loong64 and PPC64 is affected. For Chrome that means desktop Windows, Linux, ChromeOS and Intel Macs. Apple Silicon and 64-bit Android run ARM64 and are not affected.
We built and ran the exploit on x86-64 only. The arithmetic wraps identically on 32-bit x86 and 32-bit ARM, but we have not demonstrated exploitability there.
Upstream fix
The fix landed on 2026-07-28 as c3ea7757b190, “[wasm] Fix integer overflow in deserializer”, main@{#108914}. It covers all three parts of the report:
// 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());The upstream regression test regress-538378084.js is our minimal PoC, pad functions and all.
Mitigation
Update to Chrome 151.0.7922.108 or later.
Timeline
- 2026-07-23: We discovered the bug in V8’s wasm deserializer.
- 2026-07-24: We reported it to Google as b/538378084, with a PoC demonstrating two overlapping live executable allocations.
- 2026-07-24: We finished a full renderer RCE and submitted it to V8CTF as b/538501386.
- 2026-07-24: Google’s automated triage classified the report “Not a Bug (Intended Behavior)”, impact “None”, and recommended WontFix, overridden by human triagers and developers.
- 2026-07-28: Google fixed it on V8 main (
c3ea7757b190). - 2026-07-31: The fix was merged to M150, M151 and M152 branches.
- 2026-08-06: The fix was released in Chrome 151.0.7922.108.
- 2026-09-05: We published this blog post.
Acknowledgements
We thank the V8 and Chrome security teams for their prompt triage, fix, and coordinated disclosure.