Zero to Calc in Six Days: Infacet XBOW’s Chrome Full Chain


The last chain mixed a V8 compiler bug, an animated-WebP decoder bug, and a memory-safety bug in Crashpad. Together they turned JavaScript execution in an peculiar Chrome tab into code execution exterior the renderer sandbox.

Google awarded this chain the primary $250,000 Chrome full-chain reward underneath the VRP guidelines launched in April 2026. Our separate announcement covers the award and what it means for XBOW. This submit is concerning the exploit: the boundaries it crossed, the foundation trigger of every vulnerability, and the interfaces that linked them.

An online web page in signed Chrome Beta launches Calculator by way of XBOW’s full chain exploit.

One factor is speaking about velocity and the way offensive is accelerating and one other is to expertise it first hand. Pre-LLMs a Chrome full chain would take a workforce of 4 round 6-9 months to be produced, our first submission took 2 months. The awarded one? six days. The velocity is difficult even for us making an attempt to grasp how safety is altering.

We constructed the chain in six days, from Sunday, August 2, by Friday, August 7. Rather than ask one agent to supply an exploit finish to finish, we cut up the work into parallel campaigns with narrower targets: broad vulnerability search, V8 reminiscence corruption, a V8 sandbox escape, a renderer sandbox escape, and integration. AI brokers utilizing OpenAI’s gpt-5.6-sol mannequin searched supply code, examined hypotheses, constructed proofs of idea, and developed exploit levels; XBOW researchers selected targets, equipped context, challenged outcomes, redirected searches, and determined which parts to hitch. We additionally developed backup routes and gave every stage a clear interface, in order that we might exchange one element with out rebuilding the remainder of the exploit. Reconstructing utilization from the archived Codex logs places the six-day marketing campaign at roughly $40,000 in API expenses. We will cowl the chronology and orchestration at Unprompted on the finish of October; right here, we concentrate on the vulnerabilities and the exploit mechanics that linked them right into a full chain.

Background: V8 and Renderer Sandboxes

V8 first accommodates JavaScript-engine corruption inside an in-process sandbox. Most JavaScript heap objects stay in a reserved 4 GiB area, and lots of pointers are represented as offsets inside that area. References to native objects and executable code obtain extra safety. A V8 reminiscence corruption bug can subsequently give an attacker intensive management contained in the cage with out instantly yielding native code execution. V8 describes this design in The V8 sandbox.

Chrome then accommodates the renderer itself inside an operating-system course of sandbox. Even an attacker with native code execution within the renderer can not freely entry the remainder of the machine. Chromium’s sandbox design document describes this outer boundary.

The chain crossed each boundaries in three vulnerability levels:

Stage Vulnerability Capability gained
JavaScript engine Maglev reused a mutable boxed quantity throughout deoptimization Read and write entry all through V8’s pointer-compression cage
V8 sandbox Animated WebP mixed cached geometry with modified picture bytes One chosen native write exterior the cage
Renderer sandbox Crashpad handled a failed learn from a supposedly frozen renderer as a steady statement Code execution within the unsandboxed crash handler

The animated-WebP stage was a V8 sandbox escape, however quite than attacking the sandbox itself we exploited a property that the V8 sandbox intentionally doesn’t implement and that Blink incorrectly assumed it might belief. Finally, the exploit used an overflow in Crashpad to cross the ultimate the outer course of boundary and obtain unsandboxed code execution on the host.

Stage 1: Mutating the Immutable

The first vulnerability was in Maglev, V8’s mid-tier optimizing compiler. To perceive it, we have to have a look at deoptimization.

Optimized code operates underneath assumptions concerning the values and objects it handles. When an assumption stops being true at runtime, V8 should reconstruct peculiar JavaScript objects and proceed in much less specialised code. Some of these objects by no means existed in reminiscence whereas the optimized code was working; the compiler had represented them solely as values. Deoptimization should materialize them.

A JavaScript object area with a double illustration wants a HeapNumber when V8 materializes the article. The susceptible Maglev path created one old-space HeapNumber for the digital worth and saved the article itself within 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()->worth());
}

void BuildHeapNumber(Float64 quantity) {
  DirectHandle

That code is from the vulnerable maglev-code-generator.cc. The different half of the bug was within the generic materializer. For a area marked kStoreHeapObject, it wrote the article held by the translated slot immediately into the reconstructed proprietor:

if (marker == kStoreHeapObject) {
  DirectHandle field_value = slot->storage();
  WRITE_FIELD(*object_storage, offset, *field_value);
  WRITE_BARRIER(*object_storage, offset, *field_value);
  proceed;
}

This is the corresponding code from translated-state.cc.

The first block turned a mutable object right into a literal. The second copied that object’s identification, not merely its numeric worth, into every reconstructed proprietor. With nested activations, V8 might subsequently create distinct JavaScript objects whose double fields shared one mutable HeapNumber.

That conduct violated a compiler invariant said elsewhere in V8:

HeapNumberRef is simply created for immutable HeapNumbers.

The susceptible path made a mutable quantity appear like precisely the kind of immutable fixed that the compiler was allowed to reuse.

For very massive values of “2”

The exploit used the disagreement towards Array.prototype.at(). One compiler statement noticed a secure index and accepted an in-bounds entry. Another statement of the nominally identical fixed equipped the precise load offset. Changing one object’s double area might subsequently change the offset utilized by optimized code with out invalidating the sooner bounds reasoning.

Three optimized load features have been sufficient to reveal close by V8 pointers, get well the structure of a packed-double array, and procure a reference to an attacker-described array. That solid array was then used as a bootstrap to rewrite the metadata of an actual Uint8Array, extending peculiar listed entry throughout V8’s total 4 GiB cage.

Finding steady object addresses required yet one more step. The exploit saved a reference in V8’s large-object house to whichever JavaScript object it wanted to find. V8 itself up to date that reference throughout rubbish assortment, so the exploit might get well a present in-cage handle with out pinning movable objects.

At this level, the exploit had dependable reminiscence entry from JavaScript all through the V8 sandbox.

The repair

V8 eliminated the literal-object particular case. Virtual HeapNumbers now observe the peculiar captured-object materialization path, and Maglev explicitly prevents their deduplication. Each deoptimization materializes a contemporary field:

vobj::ObjectType object_type = object->object_type();
DCHECK_NOT_NULL(object->allocation());
// HeapNumbers could also be mutable object fields; every materialization should
// create a contemporary field, so they're by no means deduplicated.
// TODO(victorgomes): Constrain which objects could include mutable
// HeapNumbers. Immutable HeapNumbers may be saved as a literal object
// as an alternative of a captured object.
int dup_id =
    object_type == vobj::ObjectType::kHeapNumber
        ? CreateUnduplicatableId()
        : GetDuplicatedId(reinterpret_cast(object->allocation()));

The code is from the fixed revision. The full patch is V8 change 8255554, and the general public root-cause report is Chromium challenge 547819997.

Stage 2: Two WebPs Diverged in a V8 Cage

When we went searching for V8 sandbox escapes, we made a acutely aware choice to keep away from attacking the sandbox itself. Instead, we thought a bit about its menace mannequin and selected to search for issues that have been “allowed” however appeared more likely to be problematic. We targeted on obligations that the V8 sandbox menace mannequin imposes on downstream shoppers: particularly, that every one such code should guard towards the capabilities an in-cage attacker has, like concurrent modification of in-cage objects, swaps between pointers of the identical sort, and so on.

The vulnerability we discovered is exactly an instance of this: WebCodecs may give an ArrayBuffer to ImageDecoder by transferring it. Under peculiar JavaScript semantics, switch detaches the outdated buffer and its views. Blink can then wrap the backing retailer with out making a duplicate and deal with the bytes as its unique enter.

But the V8 sandbox has a unique menace mannequin: as soon as an attacker controls reminiscence contained in the cage, V8 assumes that the attacker can corrupt objects and bookkeeping there. It doesn’t promise to make a indifferent ArrayBuffer semantically immutable towards that attacker. Enforcing that property would require transferring or copying the backing retailer, or including checks to sizzling paths that the sandbox was designed to keep away from.

In the total chain, the exploit used an in-cage write equipped by the primary stage to take away a stay Uint8Array view from its JSArrayBuffer‘s detach-notification chain. When ImageDecoder transferred the buffer, V8 indifferent the views it nonetheless knew about. Optimized writes by the omitted view remained usable. Blink believed it owned the bytes completely; the attacker might nonetheless change them.

Getting One WebP to TOCTOU Another

That damaged possession assumption manifested in WebP decoding as a time of verify / time of use (TOCTOU) vulnerability.

The proof of idea positioned two same-length, independently legitimate animated WebP information in the identical backing retailer:

  1. WebP A described a 64-by-64 canvas. Its first body was 48 pixels broad, began at x = 16, and was 64 pixels excessive.
  2. ImageDecoder transferred the buffer, and its persistent demuxer cached A’s body rectangle and payload location.
  3. The surviving view changed the bytes with WebP B, whose present payload decoded a full 64-by-64 body.
  4. The decoder learn compressed knowledge from B however described the output buffer with the body geometry cached from A.

WEBPImageDecoder::DecodeSingleFrame() initialized the exterior buffer’s stride and measurement from the cached rectangle. It then superior 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.measurement =
      decoder_buffer_.u.RGBA.stride * frame_rect.peak();
  decoder_buffer_.is_external_memory = 1;
  decoder_ = WebPINewDecoder(&decoder_buffer_);
  if (!decoder_) {
    return SetFailed();
  }
}
decoder_buffer_.u.RGBA.rgba = reinterpret_cast(
    buffer.GetAddr(frame_rect.x(), frame_rect.y()));

The next statement handed that descriptor to WebPIUpdate().

For the ASAN reproducer, the bitmap stride was 256 bytes and the entire allocation was 16,384 bytes. GetAddr(16, 0) superior the output pointer by 64 bytes, leaving 16,320 bytes. That was precisely sufficient for A’s cached rectangle:

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

Blink nonetheless marketed all 16,384 bytes. B’s 64-pixel-wide payload subsequently handed libwebp’s external-buffer measurement verify:

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

WebPIUpdate() wrote the pixels earlier than Blink in contrast the decoded dimensions with the cached rectangle. The decoded footprint prolonged 64 bytes previous the native SkBitmap allocation. ASAN caught a 256-byte conversion write beginning on the first byte past the allocation.

Both WebPs have been legitimate on their very own. The unsafe state existed solely as a result of Blink mixed A’s cached geometry with B’s present bytes.

From a heap overflow to at least one chosen native write

The manufacturing exploit used the identical bug with geometry chosen for PartitionAlloc. A two-pixel body began at x = 124 on a 126-pixel canvas. The 504-byte pixel allocation and a 496-byte WebPIDecoder occupied adjoining slots within the 512-byte bucket. Advancing the output pointer by 496 bytes left solely eight bytes, whereas the decoder nonetheless believed it had 504.

The pixel write changed the stay decoder’s native fields with pointers to 2 WebPDecBuffer information already staged in a local community buffer. When decode accomplished, the real WebPCopyDecBufferPixels() routine adopted the false supply and vacation spot descriptions. It copied one chosen 64-bit worth to at least one chosen native handle.

That single write was the complete interface between the picture exploit and the following stage.

The repair

The first landed repair set rgba, stride, and measurement collectively and restricted the marketed extent to the retained rectangle. A follow-up eliminated a subtler assumption: quite than calculating what the allocation must include, 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.proper() - 1, frame_rect.backside() - 1);
decoder_buffer_.u.RGBA.rgba = reinterpret_cast(frame_rect_front);
decoder_buffer_.u.RGBA.stride = buffer.Bitmap().rowBytes();
decoder_buffer_.u.RGBA.measurement =
    (frame_rect_back - frame_rect_front + 1) * sizeof(ImageFrame::PixelData);

The code seems within the follow-up revision. The root-cause report and ASAN reproducer are in Chromium challenge 548509153. The two landed fixes are Chromium change 8266257 and Chromium change 8352899.

The WASM/WCP Handoff

Stage 2 produced one chosen native write exterior the V8 cage. The chain nonetheless wanted to show that write into arbitrary native code execution with out modifying a protected code-pointer entry.

A sizzling WebAssembly operate resolves its goal by V8’s process-wide Wasm Code Pointer desk. The exploit modified the writable base used to find that desk and equipped a alternative entry with the proper sort.

We tailored an island-hopping method from our previous full chain. That exploit linked instruction fragments encoded in JIT double constants with quick jumps. This time, we used 13 64-bit immediates in Liftoff-generated Wasm code and a Windows loader ABI.

After WebPCopyDecBufferPixels() modified the Wasm Code Pointer desk base, the following Wasm name handed the signature verify and resolved the alternative entry. The entry pointed to a jmp rcx instruction in chrome.dll; RCX pointed to the primary instruction island.

Each island contributed as much as six bytes of the loader, adopted by a brief soar over the compiler-generated directions earlier than the following island. Together, the 13 islands shaped a roughly 70-byte loader. It allotted writable reminiscence, copied the Crashpad payload, modified the mapping to executable, and referred to as 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 reads knowledge buildings from the renderer’s handle house. The handler creates ScopedProcessSuspend, which calls NtSuspendProcess(), earlier than telling the snapshot equipment that the goal is suspended.

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

SIZE_T size_out = 0;
BOOL success = ReadProcessMemory(
    handle_, reinterpret_cast(handle), buffer, measurement, &size_out);
if (success)
  return base::checked_cast(size_out);

if (GetLastError() == ERROR_PARTIAL_COPY) {
  // If we cannot learn the complete part, carry out a brief learn of the primary
  // web page as an alternative. This is critical to help ReadCString().
  size_t short_read =
      base::GetPageSize() - (handle & (base::GetPageSize() - 1));
  success = ReadProcessMemory(handle_,
                              reinterpret_cast(handle),
                              buffer,
                              short_read,
                              &size_out);
  if (success)
    return base::checked_cast(size_out);
}

At first look, the error seems to be merely that short_read is just not restricted to measurement. But the calculation was secure underneath the idea Crashpad believed it had established: that the suspended course of’s handle house couldn’t change.

The fallback was meant for a learn that begins in a single readable web page and crosses into an inaccessible web page. If a request for measurement bytes crosses that boundary, the variety of bytes remaining within the first web page should be smaller than measurement. Retrying solely these bytes subsequently matches within the caller’s vacation spot buffer.

With steady web page protections, there are three peculiar circumstances:

The code subsequently used the primary failure as proof concerning the geometry of the supply mapping. That inference stopped being true when the renderer might change the mapping between the 2 reads.

Defeating the freeze

Windows permits a thread created with the THREAD_CREATE_FLAGS_BYPASS_PROCESS_FREEZE flag to proceed working throughout NtSuspendProcess(). Before requesting a snapshot, the native renderer payload created such a thread. The exploit used flags 0x42: BYPASS_PROCESS_FREEZE along with SKIP_THREAD_ATTACH. Exactly why Windows has this function stays a thriller, nevertheless it gives the important assumption violation we want right here by letting us modify web page protections in between the learn and its retry.

While Crashpad handled the renderer as suspended, that thread repeatedly modified attacker-controlled supply pages between PAGE_NOACCESS and PAGE_READWRITE. The successful sequence was:

  1. Crashpad issued a small learn wholly contained inside one supply web page.
  2. The web page was inaccessible, so ReadProcessMemory() returned ERROR_PARTIAL_COPY.
  3. Crashpad interpreted that failure as proof that the request had crossed a web page boundary.
  4. The freeze-exempt renderer thread made the web page readable.
  5. Crashpad retried all the best way to the tip of the web page, copying extra knowledge than its caller had requested.

For the decision website utilized by the exploit, the numbers have been:

requested size         = 0x0b8
supply offset in web page    = 0x0e98
computed retry size    = 0x1000 - 0x0e98 = 0x168
vacation spot heap slot    = 0x0c0
overwrite into subsequent slot = 0x168 - 0x0c0 = 0x0a8

The unique 0xb8-byte request ended properly earlier than the web page boundary. It might produce the required error solely as a result of the entire web page was inaccessible at that immediate. Once the renderer made the web page readable, the 0x168-byte retry succeeded and overran the allocation.

To make the race dependable, the payload equipped 48 candidate reads in a single snapshot request. One freeze-exempt employee cycled their pages between readable and inaccessible states. The different helper threads remained parked, maintaining the thread inhabitants—and the Crashpad allocations related to it—steady.

Rewiring a stay Crashpad author

The outsized retry might corrupt solely the allocation instantly after its 0xc0 read-buffer slot. The payload organized for that allocation to include a stay SnapshotMinidumpMemoryWriter, which Crashpad would later use to learn a reminiscence vary from the renderer and ship the bytes by its output path.

To create that adjacency, the payload uncovered a readable course of and loader graph containing reminiscence ranges in a selected order. Crashpad allotted a author for every vary, then coalesced contained ranges and freed their writers. The ordering left one freed 0xc0 author slot instantly earlier than a author that remained stay; the following 0xb8-byte learn buffer reused the freed slot. Writers for threads, TEBs, and readable stacks affected the allocation sequence, so the payload counted these inputs and adjusted the variety of ranges to maintain the identical bucket place.

On the CET-capable methods we examined, the handler used a person shadow stack, whereas CFG restricted oblique calls to accepted targets. To keep away from working afoul of those mitigations, we used a traditional name and return sequence and directed the substituted digital calls to current CFG-valid features.

The susceptible retry-read rewrote the primary 0xa8 bytes of the stay author. At offset 0x00 it wrote again the SnapshotMinidumpMemoryWriter vtable; at 0x70 it set the author’s state to writable; and at 0x78 it changed the MemorySnapshot::Delegate vtable with a shifted CopyingOutputStreamAdaptor vtable, so the delegate callback entered the adaptor’s buffered-write path. The overwrite ended instantly earlier than memory_snapshot_ at offset 0xa8.

Crashpad later entered the author’s WriteObject() by its first vtable. WriteObject() referred to as Read() by the preserved snapshot pointer, which learn a managed 0xd0-byte vary from the renderer. The callback by the second vtable copied these bytes to a writable handle in chrome.exe and handled the identical handle because the adaptor’s underlying output stream.

The copied bytes constructed each a one-method output stream and a fallback-launcher object. When the adaptor flushed, the stream’s digital Write() entered Chrome’s FallbackUnhandledExceptionFilter(), a CFG-valid goal. The bytes additionally pointed Chrome’s fallback-launcher world on the copied object and equipped its command and startup fields. The filter referred to as FallbackCrashHandlerLauncher::LaunchAndWaitForHandler(), which handed cmd /ok calc.exe to CreateProcessW. These calls maintained the shadow stack.

Because Windows ASLR randomizes modules as soon as quite than for every course of (a consequence of the choice to make use of relocations quite than PIC/PIE code), it mapped chrome.exe on the identical handle in each the renderer and the handler. This meant that the renderer payload might derive the handler addresses from the picture base in its personal course of, in addition to validating the precise construct utilizing the picture timestamp and measurement with a view to apply any build-specific offsets (given Chrome’s speedy launch schedule, we had to have the ability to rapidly retarget the chain as new variations have been launched).

The repair

The mounted implementation eliminated Crashpad’s dependence on the goal remaining unchanged between the 2 reads. The retry is now restricted by each the source-page boundary and the caller’s unique request:

if (GetLastError() == ERROR_PARTIAL_COPY) {
  // If we cannot learn the complete part, carry out a brief learn of the primary
  // web page as an alternative. This is critical to help ReadCString().
  const size_t page_remainder =
      base::GetPageSize() - (handle & (base::GetPageSize() - 1));
  const size_t short_read = std::min(measurement, page_remainder);
  success = ReadProcessMemory(handle_,
                              reinterpret_cast(handle),
                              buffer,
                              short_read,
                              &size_out);
  if (success)
    return base::checked_cast(size_out);
}

The clamp is critical defensive programming, however it’s best understood as eradicating a temporal assumption: even when the supply modifications between the failed learn and the retry, Crashpad can now not write past the vacation spot.

The identical patch hardened the generic caller by rejecting any ReadUpTo() consequence bigger than the remaining requested rely earlier than advancing the vacation spot pointer. That verify seems in process_memory.cc.

The full repair is Chromium change 8267322, and the general public report is Chromium challenge 545820931.

Swapping Out the Middle

Now that everybody – together with Google – is pointing LLMs at codebases like Chrome, bug collisions are a significant threat. To guard towards this, we designed the exploit with modular levels and tried to have backup vulnerabilities prepared for each bit. This technique paid off when the primary model of our chain, which used a Gin vulnerability for its V8 sandbox escape, turned out to be a replica of challenge 542670566. After a really tense few hours we managed to switch the Gin-based V8 escape with the animated WebP stage described on this submit; this alternative stage rescued the chain and Google confirmed that the chain popped calc.

Conclusions

What ought to we conclude from this chain, which (to the perfect of our data) is the primary rewarded full chain Chrome exploit of the LLM period? We have a couple of ideas:

  1. For the vulnerabilities themselves, we have been struck by the truth that (coincidentally) all three vulnerabilities have been in the end about mutating one thing considered immutable: in stage 1, a HeapNumber; in stage 2, a transferred WebP buffer; and in stage 3, the web page safety of a supposedly frozen course of. These type of invariant violations could also be the place LLMs shine: they will apply their encyclopedic data of working system APIs and open supply code to provide you with conditions the place the invariant fails.
  2. As a VRP reporter, it’s very troublesome to keep away from collisions until you carry one thing new to the desk past the bottom LLM (and infrequently even this isn’t sufficient to save lots of you). Our first full chain (which Andrés Luksenberg will be talking about at OffensiveCon Tokyo) died when its last stage collided with a bug that had been discovered internally by Google months earlier than, and we’ve seen other chains meet the identical destiny since then. Spend your effort on growing your individual understanding of the goal, and be ready to be dissatisfied!
  3. Even with LLMs doing the overwhelming majority of the work, you should have extra success should you give your brokers high-quality examples to observe. In our case, the brokers have been in a position to attract on exploit methods initially authored by members of XBOW’s Native Team, just like the “island hopping” loader, to create a modular and dependable exploit.
  4. Six days and $40k is just not a number of time or cash when the payoff is a Chrome chain! Now, this does not imply that anybody can get a Chrome chain given that quantity of money and time (as mentioned, we did apply a good bit of our personal human experience and judgment), nevertheless it does imply that the bar is considerably decrease. Defend accordingly.



Source link