Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
MixedArticle text · 1,348 words · 8 segments analyzed
The final chain combined a V8 compiler bug, an animated-WebP decoder bug, and a memory-safety bug in Crashpad. Together they turned JavaScript execution in an ordinary Chrome tab into code execution outside the renderer sandbox.Google awarded this chain the first $250,000 Chrome full-chain reward under the VRP rules introduced in April 2026. Our separate announcement covers the award and what it means for XBOW. This post is about the exploit: the boundaries it crossed, the root cause of each vulnerability, and the interfaces that connected them.A web page in signed Chrome Beta launches Calculator via XBOW’s full chain exploit.One thing is talking about speed and how offensive is accelerating and another is to experience it first hand. Pre-LLMs a Chrome full chain would take a team of four around 6-9 months to be produced, our first submission took 2 months. The awarded one? six days.
The speed is challenging even for us trying to understand how security is changing.We built the chain in six days, from Sunday, August 2, through Friday, August 7. Rather than ask one agent to produce an exploit end to end, we split the work into parallel campaigns with narrower goals: broad vulnerability search, V8 memory corruption, a V8 sandbox escape, a renderer sandbox escape, and integration. AI agents using OpenAI's gpt-5.6-sol model searched source code, tested hypotheses, built proofs of concept, and developed exploit stages; XBOW researchers chose targets, supplied context, challenged results, redirected searches, and decided which components to join. We also developed backup routes and gave each stage a clean interface, so that we could replace one component without rebuilding the rest of the exploit. Reconstructing usage from the archived Codex logs puts the six-day campaign at roughly $40,000 in API charges. We will cover the chronology and orchestration at Unprompted at the end of October; here, we focus on the vulnerabilities and the exploit mechanics that connected them into a full chain.Background: V8 and Renderer SandboxesV8 first contains JavaScript-engine corruption inside an in-process sandbox. Most JavaScript heap objects live in a reserved 4 GiB region, and many pointers are represented as offsets within that region. References to native objects and executable code receive additional protection. A V8 memory corruption bug can therefore give an attacker extensive control inside the cage without immediately yielding native code execution. V8 describes this design in The V8 sandbox.Chrome then contains the renderer itself inside an operating-system process sandbox. Even an attacker with native code execution in the renderer cannot freely access the rest of the machine. Chromium's sandbox design document describes this outer boundary.The chain crossed both boundaries in three vulnerability stages:StageVulnerabilityCapability gainedJavaScript engineMaglev reused a mutable boxed number during deoptimizationRead and write access throughout V8's pointer-compression cageV8 sandboxAnimated WebP combined cached geometry with changed image bytesOne chosen native write outside the cageRenderer sandboxCrashpad treated a failed read from a supposedly frozen renderer as a stable observationCode execution in the unsandboxed crash handlerThe animated-WebP stage was a V8 sandbox escape, but rather than attacking the sandbox itself we exploited a property that the V8 sandbox deliberately does not enforce and that Blink incorrectly assumed it could trust.
Finally, the exploit used an overflow in Crashpad to cross the final the outer process boundary and achieve unsandboxed code execution on the host.Stage 1: Mutating the ImmutableThe first vulnerability was in Maglev, V8's mid-tier optimizing compiler. To understand it, we need to look at deoptimization.Optimized code operates under assumptions about the values and objects it handles.
When an assumption stops being true at runtime, V8 must reconstruct ordinary JavaScript objects and continue in less specialized code. Some of those objects never existed in memory while the optimized code was running; the compiler had represented them only as values.
Deoptimization must materialize them.A JavaScript object field with a double representation needs a HeapNumber when V8 materializes the object. The vulnerable Maglev path created one old-space HeapNumber for the virtual value and stored the object itself in the deoptimization literal array:void BuildHeapNumber(const VirtualObject* vobject) { DCHECK_EQ(vobject->object_type(), vobj::ObjectType::kHeapNumber); ValueNode* value_node = vobject->get(offsetof(HeapNumber, value_)); return BuildHeapNumber(value_node->Cast<Float64Constant>()->value()); } void BuildHeapNumber(Float64 number) { DirectHandle<Object> value = local_isolate_->factory()->NewHeapNumberFromBits<AllocationType::kOld>( number.get_bits()); translation_array_builder_->StoreLiteral(GetDeoptLiteral(*value)); }That code is from the vulnerable maglev-code-generator.cc. The other half of the bug was in the generic materializer. For a field marked kStoreHeapObject, it wrote the object held by the translated slot directly into the reconstructed owner:if (marker == kStoreHeapObject) { DirectHandle<HeapObject> field_value = slot->storage(); WRITE_FIELD(*object_storage, offset, *field_value); WRITE_BARRIER(*object_storage, offset, *field_value); continue; }This is the corresponding code from translated-state.cc.The first block turned a mutable object into a literal.
The second copied that object's identity, not merely its numeric value, into each reconstructed owner. With nested activations, V8 could therefore create distinct JavaScript objects whose double fields shared one mutable HeapNumber.That behavior violated a compiler invariant stated elsewhere in V8:HeapNumberRef is only created for immutable HeapNumbers.The vulnerable path made a mutable number look like exactly the sort of immutable constant that the compiler was allowed to reuse.For very large values of “2”The exploit used the disagreement against Array.prototype.at(). One compiler observation saw a safe index and approved an in-bounds access. Another observation of the nominally same constant supplied the actual load offset. Changing one object's double field could therefore change the offset used by optimized code without invalidating the earlier bounds reasoning.Three optimized load functions were enough to disclose nearby V8 pointers, recover the layout of a packed-double array, and obtain a reference to an attacker-described array. That forged array was then used as a bootstrap to rewrite the metadata of a real Uint8Array, extending ordinary indexed access across V8's entire 4 GiB cage.Finding stable object addresses required one more step. The exploit stored a reference in V8's large-object space to whichever JavaScript object it needed to locate. V8 itself updated that reference during garbage collection, so the exploit could recover a current in-cage address without pinning movable objects.At this point, the exploit had reliable memory access from JavaScript throughout the V8 sandbox.The fixV8 removed the literal-object special case. Virtual HeapNumbers now follow the ordinary captured-object materialization path, and Maglev explicitly prevents their deduplication.
Each deoptimization materializes a fresh box:vobj::ObjectType object_type = object->object_type(); DCHECK_NOT_NULL(object->allocation()); // HeapNumbers may be mutable object fields; each materialization must // create a fresh box, so they are never deduplicated. // TODO(victorgomes): Constrain which objects may contain mutable // HeapNumbers. Immutable HeapNumbers can be stored as a literal object // instead of a captured object. int dup_id = object_type == vobj::ObjectType::kHeapNumber ? CreateUnduplicatableId() : GetDuplicatedId(reinterpret_cast<intptr_t>(object->allocation()));The code is from the fixed revision. The complete patch is V8 change 8255554, and the public root-cause report is Chromium issue 547819997.Stage 2: Two WebPs Diverged in a V8 CageWhen we went looking for V8 sandbox escapes, we made a conscious decision to avoid attacking the sandbox itself. Instead, we thought a bit about its threat model and chose to look for things that were “allowed” but seemed likely to be problematic. We focused on obligations that the V8 sandbox threat model imposes on downstream consumers: namely, that all such code must guard against the capabilities an in-cage attacker has, like concurrent modification of in-cage objects, swaps between pointers of the same type, etc.The vulnerability we found is precisely an example of this: WebCodecs can give an ArrayBuffer to ImageDecoder by transferring it.
Under ordinary JavaScript semantics, transfer detaches the old buffer and its views. Blink can then wrap the backing store without making a copy and treat the bytes as its exclusive input.But the V8 sandbox has a different threat model: once an attacker controls memory inside the cage, V8 assumes that the attacker can corrupt objects and bookkeeping there. It does not promise to make a detached ArrayBuffer semantically immutable against that attacker. Enforcing that property would require moving or copying the backing store, or adding checks to hot paths that the sandbox was designed to avoid.In the full chain, the exploit used an in-cage write supplied by the first stage to remove a live Uint8Array view from its JSArrayBuffer's detach-notification chain. When ImageDecoder transferred the buffer, V8 detached the views it still knew about. Optimized writes through the omitted view remained usable.