The AI Triage Called It 'Not a Bug.' It Was a Chrome RCE.
On July 24, QED Audit filed a vulnerability with Google: an integer overflow in V8’s WebAssembly deserializer that lets a malicious web page corrupt JIT code and execute native code in the Chrome renderer. They included a proof of concept demonstrating two live, overlapping executable allocations. Google’s automated triage — the comment announces itself as “AI-generated using the v8-security-triaging skill” — reproduced the crash and rated its security impact as none, recommending WontFix.
The humans overrode the bot within days. The fix landed on V8 main on July 28, shipped to stable in Chrome 151.0.7922.108 on August 6, and on September 5 QED published the writeup, CVE-2026-19174.
Here is the entire bug:
size_t max_reservation = RoundUp<kCodeAlignment>(
v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);
Three constants. wasm_max_code_space_size_mb is a 32-bit unsigned integer holding the default 1024. MB is constexpr int 1024 * 1024. Nine is nine. There is no attacker-controlled length, no tainted size, no input anywhere in the expression. Multiply in 32-bit and 2^33 + 2^30 wraps to 2^30. The deserializer has been reserving 102.4 MiB where it intended 921.6 MiB — 10% of one code space instead of 90% — on every x86/x86-64 Chrome since M110, because of a line that computes the wrong number no matter what bytes it is fed.
I checked the arithmetic the way you’d check any claim about a constant: in 32-bit.
$ python3 -c "import numpy as np; print(int(np.uint32(1024) * np.uint32(1024*1024) * np.uint32(9)))"
1073741824 # 2^30 — intended: 9663676416 (2^33 + 2^30)
That wrap is the whole vulnerability. This is not a story about a clever exploit. It is a story about what every automated layer — fuzzers, LLM code review, AI triage — did and did not do while that line sat wrong for three years and eight months.
The strongest case for the AI era
If any codebase should be beyond a bug like this, it is V8. Continuous fuzzing from Google’s fleet. A dedicated security team, a VRP, V8CTF. External researchers, human and AI-driven, dissecting the code. Agent-assisted development and code review that is now routine upstream — Google has said it uses AI internally for vulnerability discovery, triage, and patching. The Chrome VRP has already cut standard rewards, refocusing on bugs “more challenging for AI tooling to discover” (QED characterizes the drop as over an order of magnitude), on the theory that the easy classes are now enumerated by machines.
The WebAssembly deserializer is not a corner of that system. Google’s own fuzzers and review systems, traditional and AI-driven, have been hammering it since 2025. External researchers have filed bugs on the exact code QED found the overflow in. The file has been through LLM-driven review, repeatedly. And the one line that was wrong the whole time — anyone who asks whether the multiply overflows can answer in seconds — sat there because nobody asked whether the multiply overflows. The question is the vulnerability.
Why the overflow is a vulnerability at all
The deserializer reads back a serialized, compiled WebAssembly module from Chrome’s on-disk code cache. When a function’s compiled size exceeds what is left of the current code space, it allocates a fresh space capped by that max_reservation, and carves each function out of it. The only bounds checks on an oversized carve are DCHECKs — debug-only:
Vector<T> SubVector(size_t from, size_t to) const {
DCHECK_LE(from, to);
DCHECK_LE(to, length_); // debug only — release hands back an oversized span
return Vector<T>(begin() + from, to - from);
}
Vector<T> operator+=(size_t offset) {
DCHECK_LE(offset, length_); // debug only
start_ += offset;
length_ -= offset; // wraps to ~1.8e19 in release
return *this;
}
That is safe only because of an invariant held in a different function: the compiler refuses to add a single compiled function larger than half a code space (wasm_max_code_space_size_mb * MB / 2 — 512 MiB), and the deserializer reserves 90% (an intended 921.6 MiB). 50% is below 90%, so the debug-only checks could never fire. The overflow dropped the deserializer side of that inequality from 90% to 10% while the compiler side stayed at 50%. The inequality flipped, release builds stopped checking anything, and a single oversized function now overruns the code space into memory the allocator still considers free — with length_ wrapped to ~1.8e19, the guard never fires again and every later function is carved contiguously past the end.
The bug was introduced on 2022-11-14 by a CL that replaced a compile-time size_t constant with the new flag. WasmCodeAllocator::kMaxCodeSpaceSize was a static constexpr size_t, so * 9 / 10 folded in 64-bit arithmetic at compile time. 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 noticed that the width of the arithmetic changed with the source of the value. Two halves of one security argument, in two different functions, compiled at two different widths.
The reachability detail that made it real
The part that made this a Chrome bug rather than a V8 curiosity is the trust boundary around the deserialized blob. The deserializer is not reachable from JavaScript. Both structured-clone paths are closed. The only live path is Chrome’s HTTP GeneratedCodeCache — on by default for every web page — which stores a serialized blob of a tiered-up module and hands it back to a later response for V8 to deserialize.
That matters because V8 treats the blob as trusted input. Chrome stores a SHA-256 of the wire bytes next to the cached blob and checks it before V8 ever sees it, so a manipulated blob never reaches the deserializer. The defense is real — and it is why four earlier reports on this exact code were all closed as not vulnerabilities. Every one of them asked the same question: can the deserializer be broken by bytes the attacker chose? Under that threat model the answer is always no.
| Report | When | What it found | Outcome |
|---|---|---|---|
| b/441330944 (ClusterFuzz) | Aug 2025 | Arbitrary bytes fed to the moved serialization API | Catch-all dupe; API made a no-op under --fuzzing |
| b/447317861 (external) | Sep 2025 | ”Out-of-bounds write… attacker-controlled code_size fields are trusted without validation in release builds” — same function, nearly QED’s words | ”Not a vulnerability”; API hidden behind --enable-wasm-serialization |
| b/498816449 (Big Sleep) | Apr 2026 | CHECK failure in wasm-serialization.cc from invalid bytes passing d8’s pairing hash | ”Not a vulnerability”; hash strengthened |
| b/511325706 (Project Fortify) | May 2026 | OOB write reachable only by tampering with a serialized module | WontFix — “not reachable from web content” |
Nobody asked whether the deserializer is safe on a blob V8 produced itself. The bug is not in the bytes at all. It is constant arithmetic that computes the wrong size regardless of input — the blob is V8’s own unmodified output, delivered through Chrome’s own code cache, and V8’s own deserializer cannot read it back safely. The d8-API disclaimers that protected the harness for years do not apply, because no disclaimer was ever written for “V8 emits a module V8 cannot safely deserialize.”
Exploitation is not trivial, and the writeup is honest about the prerequisites: a single function whose top-tier compiled code exceeds 102.4 MiB (a wasm body is capped at 7,654,321 bytes, so it has to expand ~14x through Turboshaft — QED used a chain of call_indirect calls to a 100-parameter identity function, which encodes identically on every x86-64 CPU); a second compile after the first module is garbage-collected so the deserializer actually runs; and roughly 20 GiB of free disk for the code-cache entry, which the writeup calls a realistic assumption for a desktop install. QED demonstrated the result — arbitrary native code execution in the renderer, with no separate V8 heap-sandbox bypass needed, because the primitive forges native code directly — end to end against Chrome M150 on V8CTF. The demonstration stops at the renderer; this is not a full chain, and there is no indication it was ever exploited in the wild. Affected: every architecture except ARM64, Loong64, and PPC64 — desktop Windows, Linux, ChromeOS, and Intel Macs. Apple Silicon and 64-bit Android are not affected. Update to Chrome 151.0.7922.108 or later; that has been the fix since August 6.
The AI layers did exactly what they were built to do
The uncomfortable part is not that the machines missed the bug. It is how they missed it, because each miss was correct within its frame:
- The fuzzers couldn’t reach it. You cannot fuzz a constant. No input stream varies
1024,MB, or9. The class of bug is invisible to coverage-guided mutation because there is nothing to mutate. - The LLM review listed it and skipped it. QED ran the experiment: point a model at wasm serialization under the threat model where deserialized bytes are trusted, and it comes back in five minutes with four ways the code could break, each ruled out — with integer overflow listed as worth checking and then skipped. The same model, required to actually check the classes it had listed, found the bug in about a minute at
wasm-serialization.cc:1019-1021. - The automated triage recommended WontFix on the real report. The AI triage reproduced the crash and still rated impact none, because it applied the standing rule — manipulated deserializer input is not VRP-eligible — to a report whose first paragraph said the blob was V8’s own unmodified output. A human V8 developer corrected it: the d8 API is usually ineligible “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.”
There is a through-line, and it is not model quality. Every layer was pointed at the same question — can attacker bytes break this code — and every layer answered it correctly. The question was wrong. The codebase’s own trust assumption (“the blob is trusted”) was treated as a property of the code instead of as an assumption to attack. Nobody enumerated who can make V8 serialize what, and who reads it back. The bug sat in the seam between the component everyone was auditing (the deserializer) and the component nobody was (the compiler-and-cache path that produces the blob, and the width of arithmetic the flag introduced).
This is the mirror image of last month’s Core Lightning story. There, AI-generated reports turned out to be real and the triage crisis was volume. Here, the AI systems were present at every step — reviewing the file, filing on the function, triaging the report — and the bug still took a human asking the question that broke the frame. In both cases the bottleneck was never the model. It was the loop around it.
What operators should change
If you run LLM-based code review or AI triage anywhere in a security pipeline, QED’s writeup is a field guide to four failure modes you can fix this week:
- Require the check, not the conclusion. A model that reports “I checked these classes and ruled them out” produces a table that is identical whether the code is clean or the model missed something. QED’s own numbers are the proof: five minutes to list integer overflow as worth checking, one minute to find it once the loop forced the check. Review output should ship the artifact of verification — the command, the trace, the diff — not the self-report.
- State the trust assumption and attack it. “The blob is trusted” is not a fact about code; it is an assumption about who can influence what. For every security boundary in your review scope, enumerate who can make the trusted thing, and who reads it back. The bug was on the far side of that enumeration, in the width of a type, not in the untrusted input.
- Review the seams, not just the components. The overflow was in V8, the trigger was in Blink, and no V8-only harness could reach it. The invariant that made debug-only checks safe lived in a different function than the one that broke it. Review boundaries follow component boundaries — which is exactly why this bug sat where two components met for 3.5 years.
- Triage automation must not be the terminal verifier. Google’s automated triage reproduced a PoC showing two overlapping executable allocations and recommended WontFix. It was overridden by humans who read the summary. If your triage bot has WontFix authority, a report that demonstrates overlapping executable memory should escalate by default, not resolve itself.
- Treat debug-only checks as absent, because in production they are. Every DCHECK in the vulnerable path was a correct check that simply did not exist in release builds. If a security invariant only holds in debug, it does not hold.
And the fix itself is a lesson in how cheap this class is to kill at the source: the upstream patch (c3ea7757b190, “wasm: Fix integer overflow in deserializer”) performs the division first — wasm_max_code_space_size_mb * MB / 10 * 9 — adds a release-mode CHECK_LE(code_size, current_code_space_.size()) where the debug-only check used to be, and hardens the JIT-page split. No model, no fuzzer, no new tooling. A compiler pass or a lint over flag-derived size arithmetic evaluated in 32 bits would have found this in 2022, cheaply, with no AI involved.
The question is the vulnerability
The AI-era expectation is that cheap code reading industrializes bug discovery — that pointing enough agents at a codebase systematically enumerates what is left, and eventually the system is hardened. CVE-2026-19174 is the counterexample, and it is a precise one: not an exotic race or a subtle alias, but a one-line overflow in three constants, in the exact file everyone was already reviewing, dismissed by an automated triage that reproduced it and called it intended behavior.
Reading code at volume is now cheap for both offense and defense. The scarce resource is deciding which code to read and under which threat model — and building the loop that forces a reviewer, human or machine, to verify what it claims to have checked. The model that listed integer overflow and skipped it was not the failure. The loop that accepted the skip was. The AI triage that said WontFix was not the failure. The absence of a human who had to be in the loop — and was, at Google — would have been.
The bug is patched. The lesson is not: the verifier is still the bottleneck, and this time the verifier was a question nobody thought to ask about a number that was wrong for 1,340 days.
Sources
- QED Audit — “A Brilliantly Simple Bug In V8” (September 5, 2026; primary disclosure: bug mechanism, exploit path, prior-report timeline, fix, full disclosure timeline) — all mechanism, exploit, and timeline claims above are from this writeup
- NVD — CVE-2026-19174 (published 2026-08-06; CWE-190 integer overflow, CVSS 3.1 8.8 High, Chromium severity High, Chrome prior to 151.0.7922.109; SSVC exploitation: none)
- Google — Chrome Stable Channel Update for Desktop, Aug 6, 2026 (CVE-2026-19174 reported by Seunghyun Lee of QED Audit, 41 security fixes)
- Chromium issue b/538378084 (QED’s report; the AI-triage comment and the human override are quoted in the QED writeup)
- Google — “Evolving the Android & Chrome VRPs for the AI Era” (reward refocus on bugs “more challenging for AI tooling to discover”)
- Google — “Stronger with every update: How we’re making Chrome and the web safer in the AI Era” (internal AI use for vulnerability discovery, triage, and patching)
- V8 fix CL c3ea7757b190 — “wasm: Fix integer overflow in deserializer” (2026-07-28,
main@{#108914}; division-first arithmetic, releaseCHECK_LE, JIT-page-split hardening) - Local verification of the 32-bit wrap (
1024 * 1048576 * 9in uint32 arithmetic = 1,073,741,824 vs the intended 9,663,676,416), Sep 6, 2026 - X thread by the researcher (@0x10n / QED Audit), Sep 6, 2026 — the radar hit that surfaced the writeup