返回资讯中心
外部精选
软件工程
#社区热议

Zero to Calc in Six Days: Inside XBOW's Chrome Full Chain

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…

Hacker Newsollin2026/10/13 · 15 天前30 分钟阅读

以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。

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.

V8 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:

The 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.

The 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()->NewHeapNumberFromBitsAllocationType::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:

The vulnerable path made a mutable number look like exactly the sort of immutable constant that the compiler was allowed to reuse.

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.

V8 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.

When 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. Blink believed it owned the bytes exclusively; the attacker could still change them.

That broken ownership assumption manifested in WebP decoding as a time of check / time of use (TOCTOU) vulnerability.

The proof of concept placed two same-length, independently valid animated WebP files in the same backing store:

WEBPImageDecoder::DecodeSingleFrame() initialized the external buffer's stride and size from the cached rectangle. It then advanced the output pointer independently to that rectangle's top-left pixel (code):

const gfx::Rect& frame_rect = buffer.OriginalFrameRect(); if (!decoder_) { // Set up decoder_buffer_ with output mode if (!WebPInitDecBuffer(&decoder_buffer_)) { return SetFailed(); } decoder_buffer_.colorspace = RGBOutputMode(); decoder_buffer_.u.RGBA.stride = Size().width() * sizeof(ImageFrame::PixelData); decoder_buffer_.u.RGBA.size = decoder_buffer_.u.RGBA.stride * frame_rect.height(); decoder_buffer_.is_external_memory = 1; decoder_ = WebPINewDecoder(&decoder_buffer_); if (!decoder_) { return SetFailed(); } } decoder_buffer_.u.RGBA.rgba = reinterpret_cast<uint8_t*>( buffer.GetAddr(frame_rect.x(), frame_rect.y()));

The next statement passed that descriptor to WebPIUpdate().

For the ASAN reproducer, the bitmap stride was 256 bytes and the complete allocation was 16,384 bytes. GetAddr(16, 0) advanced the output pointer by 64 bytes, leaving 16,320 bytes. That was exactly enough for A's cached rectangle:

256 * (64 - 1) + 48 * 4 = 16,320

Blink nevertheless advertised all 16,384 bytes. B's 64-pixel-wide payload therefore passed libwebp's external-buffer size check:

256 * (64 - 1) + 64 * 4 = 16,384

WebPIUpdate() wrote the pixels before Blink compared the decoded dimensions with the cached rectangle. The decoded footprint extended 64 bytes past the native SkBitmap allocation. ASAN caught a 256-byte conversion write starting at the first byte beyond the allocation.

Both WebPs were valid on their own. The unsafe state existed only because Blink combined A's cached geometry with B's current bytes.

The production exploit used the same bug with geometry selected for PartitionAlloc. A two-pixel frame started at x = 124 on a 126-pixel canvas. The 504-byte pixel allocation and a 496-byte WebPIDecoder occupied adjacent slots in the 512-byte bucket. Advancing the output pointer by 496 bytes left only eight bytes, while the decoder still believed it had 504.

The pixel write replaced the live decoder's native fields with pointers to two WebPDecBuffer records already staged in a native network buffer. When decode completed, the genuine WebPCopyDecBufferPixels() routine followed the false source and destination descriptions. It copied one chosen 64-bit value to one chosen native address.

That single write was the entire interface between the image exploit and the next stage.

Th

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →