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

Hacker News by 21 min read 29x views
Zero to Calc in Six Days: Inside XBOW's Chrome Full Chain

Share Post

The final sequence blended a V8 compiler bug, an animated-WebP decoder bug, and a memory-safety bug in Crashpad. Together they turned JavaScript implementation in an average Chrome tab into code implementation exterior the renderer sandbox.

Google awarded this sequence the archetypal $250,000 Chrome full-chain reward under the VRP rules introduced in April 2026. Our distinct announcement covers the award and what it method for XBOW. This article is concerning the exploit: the boundaries it crossed, the base logic of all vulnerability, and the interfaces that connected them.

A web leaf in signed Chrome Beta launches Calculator via XBOW’s complete sequence exploit.

One item is talking concerning speed and how insulting is accelerating and another is to cognition it archetypal hand. Pre-LLMs a Chrome complete sequence would obtain a squad of four about 6-9 months to be produced, our archetypal submission took 2 months. The awarded one? six days. The speed is challenging equal for us trying to comprehend how safety is changing.

We built the sequence in six days, from Sunday, August 2, through Friday, August 7. Rather than ask one delegate to create an utilize end to end, we divided the activity into parallel campaigns alongside narrower goals: broad exposure search, V8 recollection corruption, a V8 sandbox escape, a renderer sandbox escape, and integration. AI agents using OpenAI's gpt-5.6-sol example searched origin code, tested hypotheses, built proofs of concept, and developed utilize stages; XBOW researchers chose targets, supplied context, challenged results, redirected searches, and decided which components to join. We additionally developed backup routes and gave all phase a spotless interface, so that we could substitute one component without rebuilding the remainder of the exploit. Reconstructing use from the archived Codex logs puts the six-day run at approximately $40,000 in API charges. We volition shield the chronology and orchestration at Unprompted at the end of October; here, we concentration on the vulnerabilities and the utilize mechanics that connected them into a complete chain.

Background: V8 and Renderer Sandboxes

V8 archetypal contains JavaScript-engine misconduct inner an in-process sandbox. Most JavaScript heap objects live in a reserved 4 GiB region, and many pointers are represented as offsets inside that region. References to native objects and executable code obtain additional protection. A V8 recollection misconduct bug can hence provision an attacker extended authority inner the cage without immediately yielding native code execution. V8 describes this scheme in The V8 sandbox.

Chrome afterward contains the renderer itself inner an operating-system procedure sandbox. Even an attacker alongside native code implementation in the renderer cannot openly admission the remainder of the machine. Chromium's sandbox scheme document describes this external boundary.

The sequence traversed the two boundaries in three exposure stages:

StageVulnerabilityCapability gained
JavaScript engineMaglev reused a mutable boxed figure during deoptimizationRead and compose admission throughout V8's pointer-compression cage
V8 sandboxAnimated WebP blended cached geometry alongside changed depiction bytesOne chosen native compose exterior the cage
Renderer sandboxCrashpad treated a unsuccessful peruse from a supposedly icy renderer as a stable observationCode implementation in the unsandboxed collision handler

The animated-WebP phase was a V8 sandbox escape, but fairly than attacking the sandbox itself we exploited a asset that the V8 sandbox deliberately does not enforce and that Blink incorrectly assumed it could trust. Finally, the utilize used an overflow in Crashpad to cross the final the external procedure border and accomplish unsandboxed code implementation on the host.

Stage 1: Mutating the Immutable

The archetypal exposure was in Maglev, V8's mid-tier optimizing compiler. To comprehend it, we need to appearance at deoptimization.

Optimized code operates under assumptions concerning the values and objects it handles. When an assumption stops being true at runtime, V8 must rebuild average JavaScript objects and continue in small specialized code. Some of those objects never existed in recollection during the optimized code was running; the compiler had represented them lone as values. Deoptimization must materialize them.

A JavaScript entity site alongside a twice depiction needs a HeapNumber whenever V8 materializes the object. The susceptible Maglev way created one old-space HeapNumber for the virtual value and stored the entity 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_)); come back 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 another fractional of the bug was in the generic materializer. For a site marked kStoreHeapObject, it wrote the entity held by the translated slot immediately 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 archetypal obstacle turned a mutable entity into a literal. The second copied that object's identity, not merely its numeric value, into all reconstructed owner. With nested activations, V8 could hence create distinct JavaScript objects whose twice sectors shared one mutable HeapNumber.

That behavior violated a compiler invariant stated elsewhere in V8:

HeapNumberRef is lone created for immutable HeapNumbers.

The susceptible way made a mutable figure appearance akin exactly the kind of immutable changeless that the compiler was allowed to reuse.

For extremely ample values of “2”

The utilize used the disagreement against Array.prototype.at(). One compiler study saw a harmless indicator and approved an in-bounds access. Another study of the nominally identical changeless supplied the genuine burden offset. Changing one object's twice site could hence alter the offset used by optimized code without invalidating the before limits reasoning.

Three optimized burden functions were adequate to disclose nearby V8 pointers, regain the layout of a packed-double array, and get a citation to an attacker-described array. That forged gathering was afterward used as a bootstrap to rewrite the metadata of a genuine Uint8Array, extending average indexed admission throughout V8's complete 4 GiB cage.

Finding stable entity addresses required one additional step. The utilize stored a citation in V8's large-object area to whichever JavaScript entity it needed to locate. V8 itself updated that citation during refuse collection, so the utilize could regain a current in-cage location without pinning movable objects.

At this point, the utilize had dependable recollection admission from JavaScript throughout the V8 sandbox.

The fix

V8 removed the literal-object particular case. Virtual HeapNumbers now prosecute the average captured-object materialization path, and Maglev explicitly prevents their deduplication. Each deoptimization materializes a caller box:

vobj::ObjectType object_type = object->object_type(); DCHECK_NOT_NULL(object->allocation()); // HeapNumbers may be mutable entity fields; all materialization must // create a caller box, so they are never deduplicated. // TODO(victorgomes): Constrain which objects may merge mutable // HeapNumbers. Immutable HeapNumbers can be stored as a literal object // alternatively 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 place is V8 alter 8255554, and the community root-cause study is Chromium matter 547819997.

Stage 2: Two WebPs Diverged in a V8 Cage

When we went looking for V8 sandbox escapes, we made a conscious decision to evade attacking the sandbox itself. Instead, we idea a bit concerning its danger example and chose to appearance for things that were “allowed” but seemed apt to be problematic. We focused on obligations that the V8 sandbox danger example imposes on downstream consumers: namely, that all specified code must defender against the capabilities an in-cage attacker has, akin concurrent modification of in-cage objects, swaps between pointers of the identical type, etc.

The exposure we established is exactly an example of this: WebCodecs can provision an ArrayBuffer to ImageDecoder by transferring it. Under average JavaScript semantics, transfer detaches the old buffer and its views. Blink can afterward cover the assistance shop without making a copy and treat the bytes as its exclusive input.

But the V8 sandbox has a distinct danger model: formerly an attacker controls recollection inner the cage, V8 assumes that the attacker can corrupt objects and bookkeeping there. It does not commitment to create a detached ArrayBuffer semantically immutable against that attacker. Enforcing that asset would necessitate moving or copying the assistance store, or adding checks to hot paths that the sandbox was designed to avoid.

In the complete chain, the utilize used an in-cage compose supplied by the archetypal phase to eliminate a live Uint8Array perspective from its JSArrayBuffer's detach-notification chain. When ImageDecoder transferred the buffer, V8 detached the views it motionless knew about. Optimized writes through the omitted perspective remained usable. Blink believed it owned the bytes exclusively; the attacker could motionless alter them.

Getting One WebP to TOCTOU Another

That damaged ownership assumption manifested in WebP decoding as a period of inspect / period of use (TOCTOU) vulnerability.

The evidence of idea placed two same-length, independently valid animated WebP records in the identical assistance store:

  1. WebP A described a 64-by-64 canvas. Its archetypal example was 48 pixels wide, started at x = 16, and was 64 pixels high.
  2. ImageDecoder transferred the buffer, and its persistent demuxer cached A's example rectangle and payload location.
  3. The living perspective replaced the bytes alongside WebP B, whose current payload decoded a complete 64-by-64 frame.
  4. The decoder peruse compressed data from B but described the output buffer alongside the example geometry cached from A.

WEBPImageDecoder::DecodeSingleFrame() initialized the external buffer's stride and size from the cached rectangle. It afterward 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_ alongside output mode if (!WebPInitDecBuffer(&decoder_buffer_)) { come back 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_) { come back 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 adequate 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 hence passed libwebp's external-buffer size check:

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

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

Both WebPs were valid on their own. The unsafe province existed lone since Blink blended A's cached geometry alongside B's current bytes.

From a heap overflow to one chosen native write

The manufacturing utilize used the identical bug alongside geometry selected for PartitionAlloc. A two-pixel example started at x = 124 on a 126-pixel canvas. The 504-byte pixel allocation and a 496-byte WebPIDecoder occupied neighboring slots in the 512-byte bucket. Advancing the output pointer by 496 bytes remaining lone eight bytes, during the decoder motionless believed it had 504.

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

That sole compose was the complete interface between the depiction utilize and the next stage.

The fix

The archetypal landed fix set rgba, stride, and size together and constricted the advertised degree to the retained rectangle. A follow-up removed a subtler assumption: fairly than calculating what the allocation ought to contain, the decoder now takes its stride and degree 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 study and ASAN reproducer are in Chromium matter 548509153. The two landed fixes are Chromium alter 8266257 and Chromium alter 8352899.

The WASM/WCP Handoff

Stage 2 produced one selected native compose exterior the V8 cage. The sequence motionless needed to rotate that compose into arbitrary native code implementation without modifying a protected code-pointer entry.

A hot WebAssembly function resolves its mark through V8's process-wide Wasm Code Pointer table. The utilize changed the writable basis used to find that array and supplied a replacement admission alongside the accurate type.

We adapted an island-hopping method from our previous complete chain. That utilize connected education fragments encoded in JIT twice constants alongside abbreviated 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 array base, the next Wasm call passed the signature inspect and resolved the replacement entry. The admission pointed to a jmp rcx education in chrome.dll; RCX pointed to the archetypal education island.

Each isle contributed up to six bytes of the loader, followed by a abbreviated jump complete the compiler-generated instructions before the next island. Together, the 13 islands cast a approximately 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 exterior the renderer sandbox. When it receives a snapshot request, it suspends the renderer and says data structures from the renderer’s location space. The handler creates ScopedProcessSuspend, which calls NtSuspendProcess(), before telling the snapshot machinery that the mark is suspended.

In the revision immediately before the fix, one of Crashpad’s remote-memory helpers, ProcessMemoryWin::ReadUpTo(), contained the following improvement path:

SIZE_T size_out = 0; BOOL achievement = ReadProcessMemory( handle_, reinterpret_cast<void*>(address), buffer, size, &size_out); if (success) come back base::checked_cast<ssize_t>(size_out); if (GetLastError() == ERROR_PARTIAL_COPY) { // If we can not peruse the complete section, execute a abbreviated peruse of the first // leaf instead. This is necessary to assistance ReadCString(). size_t short_read = base::GetPageSize() - (address & (base::GetPageSize() - 1)); achievement = ReadProcessMemory(handle_, reinterpret_cast<void*>(address), buffer, short_read, &size_out); if (success) come back base::checked_cast<ssize_t>(size_out); }

At archetypal glance, the error appears to be merely that short_read is not constricted to size. But the calculation was harmless under the assumption Crashpad believed it had established: that the suspended process’s location area could not change.

The fallback was intended for a peruse that starts in one readable leaf and crosses into an inaccessible page. If a petition for size bytes crosses that boundary, the figure of bytes remaining in the archetypal leaf must be smaller than size. Retrying lone those bytes hence fits in the caller’s destination buffer.

With stable leaf protections, there are three average cases:

  • A petition contained in a readable leaf succeeds immediately.
  • A petition contained in an inaccessible leaf fails again whenever retried.
  • A petition crossing from a readable leaf into an inaccessible leaf produces a retry shorter than the first request.

The code hence used the archetypal nonaccomplishment as evidence concerning the geometry of the origin mapping. That conclusion stopped being true whenever the renderer could alter the mapping between the two reads.

Defeating the freeze

Windows allows a thread created alongside the THREAD_CREATE_FLAGS_BYPASS_PROCESS_FREEZE emblem to continue operating throughout NtSuspendProcess(). Before requesting a snapshot, the native renderer payload created specified a thread. The utilize used flags 0x42: BYPASS_PROCESS_FREEZE together alongside SKIP_THREAD_ATTACH. Exactly why Windows has this characteristic remains a mystery, but it provides the essential assumption infringement we need current by letting us modify leaf protections in between the peruse and its retry.

While Crashpad treated the renderer as suspended, that thread often changed attacker-controlled origin pages between PAGE_NOACCESS and PAGE_READWRITE. The winning sequence was:

  1. Crashpad issued a small peruse entirely contained inside one origin page.
  2. The leaf was inaccessible, so ReadProcessMemory() returned ERROR_PARTIAL_COPY.
  3. Crashpad interpreted that nonaccomplishment as evidence that the petition had traversed a leaf boundary.
  4. The freeze-exempt renderer thread made the leaf readable.
  5. Crashpad retried all the way to the end of the page, copying additional data than its visitant had requested.

For the call location used by the exploit, the numbers were:

requested dimension = 0x0b8 source offset in leaf = 0x0e98 computed retry dimension = 0x1000 - 0x0e98 = 0x168 destination heap slot = 0x0c0 overwrite into next slot = 0x168 - 0x0c0 = 0x0a8

The first 0xb8-byte petition ended fine before the leaf boundary. It could create the required error lone since the entire leaf was inaccessible at that instant. Once the renderer made the leaf readable, the 0x168-byte retry succeeded and overran the allocation.

To create the competition reliable, the payload supplied 48 applicant says in one snapshot request. One freeze-exempt employee cycled their pages between readable and inaccessible states. The another helper threads remained parked, keeping the thread population—and the Crashpad allocations connected alongside it—stable.

Rewiring a live Crashpad writer

The oversized retry could corrupt lone the allocation immediately following its 0xc0 read-buffer slot. The payload arranged for that allocation to merge a live SnapshotMinidumpMemoryWriter, which Crashpad would afterward use to peruse a recollection range from the renderer and dispatch the bytes through its output path.

To create that adjacency, the payload exposed a readable procedure and loader chart containing recollection ranges in a chosen order. Crashpad allocated a author for all range, afterward coalesced contained ranges and freed their writers. The ordering remaining one freed 0xc0 author slot immediately before a author that remained live; the next 0xb8-byte peruse 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 figure of ranges to keep the identical bucket position.

On the CET-capable systems we tested, the handler used a person shade stack, during CFG restricted indirect calls to approved targets. To evade operating afoul of these mitigations, we used a normal call and come back sequence and directed the substituted virtual calls to existing CFG-valid functions.

The susceptible retry-read rewrote the archetypal 0xa8 bytes of the live writer. At offset 0x00 it wrote rear the SnapshotMinidumpMemoryWriter vtable; at 0x70 it set the writer’s province to writable; and at 0x78 it replaced the MemorySnapshot::Delegate vtable alongside a shifted CopyingOutputStreamAdaptor vtable, so the assign recall entered the adaptor’s buffered-write path. The overwrite ended immediately before memory_snapshot_ at offset 0xa8.

Crashpad afterward entered the writer’s WriteObject() through its archetypal vtable. WriteObject() called Read() through the preserved snapshot pointer, which peruse a controlled 0xd0-byte range from the renderer. The recall through the second vtable copied those bytes to a writable location in chrome.exe and treated the identical location as the adaptor’s underlying output stream.

The copied bytes constructed the two 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 additionally pointed Chrome’s fallback-launcher earth at the copied entity and supplied its command and startup fields. The display called FallbackCrashHandlerLauncher::LaunchAndWaitForHandler(), which passed cmd /k calc.exe to CreateProcessW. These calls maintained the shade stack.

Because Windows ASLR randomizes modules formerly fairly than for all procedure (a consequence of the decision to use relocations fairly than PIC/PIE code), it mapped chrome.exe at the identical location in the two the renderer and the handler. This meant that the renderer payload could get the handler addresses from the depiction basis in its own process, as fine as validating the exact build using the depiction timestamp and size in command to use any build-specific offsets (given Chrome’s fast publish schedule, we had to be capable to quickly retarget the sequence as new versions were released).

The fix

The fixed implementation removed Crashpad’s dependence on the mark remaining unchanged between the two reads. The retry is now constricted by the two the source-page border and the caller’s first request:

if (GetLastError() == ERROR_PARTIAL_COPY) { // If we can not peruse the complete section, execute a abbreviated peruse of the first // leaf instead. This is necessary to assistance ReadCString(). const size_t page_remainder = base::GetPageSize() - (address & (base::GetPageSize() - 1)); const size_t short_read = std::min(size, page_remainder); achievement = ReadProcessMemory(handle_, reinterpret_cast<void*>(address), buffer, short_read, &size_out); if (success) come back base::checked_cast<ssize_t>(size_out); }

The clamp is necessary protective programming, but it is finest understood as removing a temporal assumption: equal if the origin changes between the unsuccessful peruse and the retry, Crashpad can no longer compose beyond the destination.

The identical place hardened the generic visitant by rejecting any ReadUpTo() outcome larger than the remaining requested figure before advancing the destination pointer. That inspect appears in process_memory.cc.

The complete fix is Chromium alter 8267322, and the community study is Chromium matter 545820931.

Swapping Out the Middle

Now that everyone – including Google – is pointing LLMs at codebases akin Chrome, bug collisions are a important risk. To defender against this, we designed the utilize alongside standardized stages and tried to have backup vulnerabilities prepared for all piece. This scheme break even whenever the archetypal type of our chain, which used a Gin exposure for its V8 sandbox escape, turned out to be a copy of matter 542670566. After a extremely stressful few hours we managed to substitute the Gin-based V8 escape alongside the animated WebP phase described in this post; this replacement phase rescued the sequence and Google confirmed that the sequence popped calc.

Conclusions

What should we decide from this chain, which (to the finest of our knowledge) is the archetypal rewarded complete sequence Chrome utilize of the LLM era? We have a few thoughts:

  1. For the vulnerabilities themselves, we were struck by the fact that (coincidentally) all three vulnerabilities were ultimately concerning mutating item idea to be immutable: in phase 1, a HeapNumber; in phase 2, a transferred WebP buffer; and in phase 3, the leaf safety of a supposedly icy process. These benevolent of invariant violations may be anywhere LLMs shine: they can use their encyclopedic cognition of functioning scheme APIs and open origin code to arrive up alongside situations anywhere the invariant fails.
  2. As a VRP reporter, it is very difficult to evade collisions unless you bring item new to the array beyond the basis LLM (and frequently equal this isn’t adequate to preserve you). Our archetypal complete sequence (which Andrés Luksenberg volition be talking concerning at OffensiveCon Tokyo) died whenever its final phase wrecked alongside a bug that had been established internally by Google months before, and we’ve seen other chains encounter the identical destiny since then. Spend your attempt on evolving your own understanding of the target, and be prepared to be disappointed!
  3. Even alongside LLMs doing the huge bulk of the work, you volition have additional achievement if you provision your agents high-quality examples to follow. In our case, the agents were capable to diagram on utilize techniques initially authored by members of XBOW’s Native Team, akin the “island hopping” loader, to create a standardized and dependable exploit.
  4. Six days and $40k is not a lot of period or prosperity whenever the payoff is a Chrome chain! Now, this does not average that anyone can get a Chrome sequence stated that amount of period and prosperity (as discussed, we did use a fair bit of our own individual ability and judgment), but it does average that the bar is significantly lower. Defend accordingly.
Other Article Hacker News
↑
Close Right Ads
Close Left Ads