XBOW 用 AI 智能体 6 天完成 Chrome 三段式全链漏洞利用,拿下 Google 25 万美元奖励
Zero to Calc in Six Days: Inside XBOW's Chrome Full Chain
XBOW 用 OpenAI 的 gpt-5.6-sol 智能体在 6 天内(8 月 2 日至 7 日)完成 Chrome 三段式全链漏洞利用,凭此拿到 Google VRP 25 万美元全链奖励。
XBOW 用 AI 智能体 6 天拼出 Chrome 三段式全链漏洞利用,模块化拆分与并行子任务的设计对攻防节奏与团队配置都有直接参考价值。
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 Sandboxes
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:
| Stage | Vulnerability | Capability gained |
|---|---|---|
| JavaScript engine | Maglev reused a mutable boxed number during deoptimization | Read and write access throughout V8's pointer-compression cage |
| V8 sandbox | Animated WebP combined cached geometry with changed image bytes | One chosen native write outside the cage |
| Renderer sandbox | Crashpad treated a failed read from a supposedly frozen renderer as a stable observation | Code execution in the unsandboxed crash handler |
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.
Stage 1: Mutating the Immutable
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()->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:
HeapNumberRefis only created for immutableHeapNumbers.
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 fix
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.
Stage 2: Two WebPs Diverged in a V8 Cage
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.
Getting One WebP to TOCTOU Another
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:
- WebP A described a 64-by-64 canvas. Its first frame was 48 pixels wide, started at
x = 16, and was 64 pixels high. ImageDecodertransferred the buffer, and its persistent demuxer cached A's frame rectangle and payload location.- The surviving view replaced the bytes with WebP B, whose current payload decoded a full 64-by-64 frame.
- The decoder read compressed data from B but described the output buffer with the frame geometry cached from A.
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,320Blink 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,384WebPIUpdate() 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.
From a heap overflow to one chosen native write
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.
The fix
The first landed fix set rgba, stride, and size together and limited the advertised extent to the retained rectangle. A follow-up removed a subtler assumption: rather than calculating what the allocation ought to contain, the decoder now takes its stride and extent from the ImageFrame bitmap itself:
const gfx::Rect& frame_rect = buffer.OriginalFrameRect();
CHECK(gfx::Rect(Size()).Contains(frame_rect));
CHECK(!frame_rect.IsEmpty());
// The top-left pixel of frame_rect.
ImageFrame::PixelData* frame_rect_front =
buffer.GetAddr(frame_rect.x(), frame_rect.y());
// The bottom-right pixel of frame_rect.
ImageFrame::PixelData* frame_rect_back =
buffer.GetAddr(frame_rect.right() - 1, frame_rect.bottom() - 1);
decoder_buffer_.u.RGBA.rgba = reinterpret_cast<uint8_t*>(frame_rect_front);
decoder_buffer_.u.RGBA.stride = buffer.Bitmap().rowBytes();
decoder_buffer_.u.RGBA.size =
(frame_rect_back - frame_rect_front + 1) * sizeof(ImageFrame::PixelData);The code appears in the follow-up revision. The root-cause report and ASAN reproducer are in Chromium issue 548509153. The two landed fixes are Chromium change 8266257 and Chromium change 8352899.
The WASM/WCP Handoff
Stage 2 produced one selected native write outside the V8 cage. The chain still needed to turn that write into arbitrary native code execution without modifying a protected code-pointer entry.
A hot WebAssembly function resolves its target through V8's process-wide Wasm Code Pointer table. The exploit changed the writable base used to locate that table and supplied a replacement entry with the correct type.
We adapted an island-hopping technique from our previous full chain. That exploit linked instruction fragments encoded in JIT double constants with short jumps. This time, we used 13 64-bit immediates in Liftoff-generated Wasm code and a Windows loader ABI.
After WebPCopyDecBufferPixels() changed the Wasm Code Pointer table base, the next Wasm call passed the signature check and resolved the replacement entry. The entry pointed to a jmp rcx instruction in chrome.dll; RCX pointed to the first instruction island.
Each island contributed up to six bytes of the loader, followed by a short jump over the compiler-generated instructions before the next island. Together, the 13 islands formed a roughly 70-byte loader. It allocated writable memory, copied the Crashpad payload, changed the mapping to executable, and called it.
Stage 3: The Renderer Refuses to Chill Out
Chrome’s Crashpad handler runs outside the renderer sandbox. When it receives a snapshot request, it suspends the renderer and reads data structures from the renderer’s address space. The handler creates ScopedProcessSuspend, which calls NtSuspendProcess(), before telling the snapshot machinery that the target is suspended.
In the revision immediately before the fix, one of Crashpad’s remote-memory helpers, ProcessMemoryWin::ReadUpTo(), contained the following recovery path:
SIZE_T size_out = 0;
BOOL success = ReadProcessMemory(
handle_, reinterpret_cast<void*>(address), buffer, size, &size_out);
if (success)
return base::checked_cast<ssize_t>(size_out);
if (GetLastError() == ERROR_PARTIAL_COPY) {
// If we can not read the entire section, perform a short read of the first
// page instead. This is necessary to support ReadCString().
size_t short_read =
base::GetPageSize() - (address & (base::GetPageSize() - 1));
success = ReadProcessMemory(handle_,
reinterpret_cast<void*>(address),
buffer,
short_read,
&size_out);
if (success)
return base::checked_cast<ssize_t>(size_out);
}At first glance, the error appears to be simply that short_read is not limited to size. But the calculation was safe under the assumption Crashpad believed it had established: that the suspended process’s address space could not change.
The fallback was intended for a read that begins in one readable page and crosses into an inaccessible page. If a request for size bytes crosses that boundary, the number of bytes remaining in the first page must be smaller than size. Retrying only those bytes therefore fits in the caller’s destination buffer.
With stable page protections, there are three ordinary cases:
- A request contained in a readable page succeeds immediately.
- A request contained in an inaccessible page fails again when retried.
- A request crossing from a readable page into an inaccessible page produces a retry shorter than the original request.
The code therefore used the first failure as evidence about the geometry of the source mapping. That inference stopped being true when the renderer could change the mapping between the two reads.
Defeating the freeze
Windows allows a thread created with the THREAD_CREATE_FLAGS_BYPASS_PROCESS_FREEZE flag to continue running across NtSuspendProcess(). Before requesting a snapshot, the native renderer payload created such a thread. The exploit used flags 0x42: BYPASS_PROCESS_FREEZE together with SKIP_THREAD_ATTACH. Exactly why Windows has this feature remains a mystery, but it provides the essential assumption violation we need here by letting us modify page protections in between the read and its retry.
While Crashpad treated the renderer as suspended, that thread repeatedly changed attacker-controlled source pages between PAGE_NOACCESS and PAGE_READWRITE. The winning sequence was:
- Crashpad issued a small read wholly contained within one source page.
- The page was inaccessible, so
ReadProcessMemory()returnedERROR_PARTIAL_COPY. - Crashpad interpreted that failure as evidence that the request had crossed a page boundary.
- The freeze-exempt renderer thread made the page readable.
- Crashpad retried all the way to the end of the page, copying more data than its caller had requested.
For the call site used by the exploit, the numbers were:
requested length = 0x0b8
source offset in page = 0x0e98
computed retry length = 0x1000 - 0x0e98 = 0x168
destination heap slot = 0x0c0
overwrite into next slot = 0x168 - 0x0c0 = 0x0a8The original 0xb8-byte request ended well before the page boundary. It could produce the required error only because the whole page was inaccessible at that instant. Once the renderer made the page readable, the 0x168-byte retry succeeded and overran the allocation.
To make the race reliable, the payload supplied 48 candidate reads in one snapshot request. One freeze-exempt worker cycled their pages between readable and inaccessible states. The other helper threads remained parked, keeping the thread population—and the Crashpad allocations associated with it—stable.
Rewiring a live Crashpad writer
The oversized retry could corrupt only the allocation immediately after its 0xc0 read-buffer slot. The payload arranged for that allocation to contain a live SnapshotMinidumpMemoryWriter, which Crashpad would later use to read a memory range from the renderer and send the bytes through its output path.
To create that adjacency, the payload exposed a readable process and loader graph containing memory ranges in a chosen order. Crashpad allocated a writer for each range, then coalesced contained ranges and freed their writers. The ordering left one freed 0xc0 writer slot immediately before a writer that remained live; the next 0xb8-byte read buffer reused the freed slot. Writers for threads, TEBs, and readable stacks affected the allocation sequence, so the payload counted those inputs and adjusted the number of ranges to keep the same bucket position.
On the CET-capable systems we tested, the handler used a user shadow stack, while CFG restricted indirect calls to approved targets. To avoid running afoul of these mitigations, we used a normal call and return sequence and directed the substituted virtual calls to existing CFG-valid functions.
The vulnerable retry-read rewrote the first 0xa8 bytes of the live writer. At offset 0x00 it wrote back the SnapshotMinidumpMemoryWriter vtable; at 0x70 it set the writer’s state to writable; and at 0x78 it replaced the MemorySnapshot::Delegate vtable with a shifted CopyingOutputStreamAdaptor vtable, so the delegate callback entered the adaptor’s buffered-write path. The overwrite ended immediately before memory_snapshot_ at offset 0xa8.
Crashpad later entered the writer’s WriteObject() through its first vtable. WriteObject() called Read() through the preserved snapshot pointer, which read a controlled 0xd0-byte range from the renderer. The callback through the second vtable copied those bytes to a writable address in chrome.exe and treated the same address as the adaptor’s underlying output stream.
The copied bytes constructed both a one-method output stream and a fallback-launcher object. When the adaptor flushed, the stream’s virtual Write() entered Chrome’s FallbackUnhandledExceptionFilter(), a CFG-valid target. The bytes also pointed Chrome’s fallback-launcher global at the copied object and supplied its command and startup fields. The filter called FallbackCrashHandlerLauncher::LaunchAndWaitForHandler(), which passed cmd /k calc.exe to CreateProcessW. These calls maintained the shadow stack.
Because Windows ASLR randomizes modules once rather than for each process (a consequence of the decision to use relocations rather than PIC/PIE code), it mapped chrome.exe at the same address in both the renderer and the handler. This meant that the renderer payload could derive the handler addresses from the image base in its own process, as well as validating the exact build using the image timestamp and size in order to apply any build-specific offsets (given Chrome’s rapid release schedule, we had to be able to quickly retarget the chain as new versions were released).
The fix
The fixed implementation removed Crashpad’s dependence on the target remaining unchanged between the two reads. The retry is now limited by both the source-page boundary and the caller’s original request:
if (GetLastError() == ERROR_PARTIAL_COPY) {
// If we can not read the entire section, perform a short read of the first
// page instead. This is necessary to support ReadCString().
const size_t page_remainder =
base::GetPageSize() - (address & (base::GetPageSize() - 1));
const size_t short_read = std::min(size, page_remainder);
success = ReadProcessMemory(handle_,
reinterpret_cast<void*>(address),
buffer,
short_read,
&size_out);
if (success)
return base::checked_cast<ssize_t>(size_out);
}The clamp is necessary defensive programming, but it is best understood as removing a temporal assumption: even if the source changes between the failed read and the retry, Crashpad can no longer write beyond the destination.
The same patch hardened the generic caller by rejecting any ReadUpTo() result larger than the remaining requested count before advancing the destination pointer. That check appears in process_memory.cc.
The complete fix is Chromium change 8267322, and the public report is Chromium issue 545820931.
Swapping Out the Middle
Now that everyone – including Google – is pointing LLMs at codebases like Chrome, bug collisions are a major risk. To guard against this, we designed the exploit with modular stages and tried to have backup vulnerabilities ready for each piece. This strategy paid off when the first version of our chain, which used a Gin vulnerability for its V8 sandbox escape, turned out to be a duplicate of issue 542670566. After a very stressful few hours we managed to replace the Gin-based V8 escape with the animated WebP stage described in this post; this replacement stage rescued the chain and Google confirmed that the chain popped calc.
Conclusions
What should we conclude from this chain, which (to the best of our knowledge) is the first rewarded full chain Chrome exploit of the LLM era? We have a few thoughts:
- For the vulnerabilities themselves, we were struck by the fact that (coincidentally) all three vulnerabilities were ultimately about mutating something thought to be immutable: in stage 1, a
HeapNumber; in stage 2, a transferred WebP buffer; and in stage 3, the page protection of a supposedly frozen process. These kind of invariant violations may be where LLMs shine: they can apply their encyclopedic knowledge of operating system APIs and open source code to come up with situations where the invariant fails. - As a VRP reporter, it is very difficult to avoid collisions unless you bring something new to the table beyond the base LLM (and often even this isn’t enough to save you). Our first full chain (which Andrés Luksenberg will be talking about at OffensiveCon Tokyo) died when its final stage collided with a bug that had been found internally by Google months before, and we’ve seen other chains meet the same fate since then. Spend your effort on developing your own understanding of the target, and be prepared to be disappointed!
- Even with LLMs doing the vast majority of the work, you will have more success if you give your agents high-quality examples to follow. In our case, the agents were able to draw on exploit techniques originally authored by members of XBOW’s Native Team, like the “island hopping” loader, to create a modular and reliable exploit.
- Six days and $40k is not a lot of time or money when the payoff is a Chrome chain! Now, this does not mean that anyone can get a Chrome chain given that amount of time and money (as discussed, we did apply a fair bit of our own human expertise and judgment), but it does mean that the bar is significantly lower. Defend accordingly.
来源:Hacker News · xbow.com