Skip to content
I Let Claude Opus To Write Me A Chrome Exploit

I Let Claude Opus To Write Me A Chrome Exploit

www.hacktron.ai April 17, 2026

TLDR: I pointed Claude Opus at Discord’s bundled Chrome (version 138, nine major versions behind upstream) and asked it to build a full V8 exploit chain. The V8 OOB we used was from Chrome 146, the same version Anthropic’s own Claude Desktop is running. A week of back and forth, 2.3 billion tokens, $2,283 in API costs, and ~20 hours of me unsticking it from dead ends. It popped calc.

Anthropic’s announcement of Mythos and Project Glasswing set the tech sphere on fire. On one side, skeptics called it another boy-who-cried-wolf moment, compute limits dressed up as safety theater, or just the most fearmongering AI marketing play yet. On the other, people were already talking airgapping their servers.

Whatever Anthropic’s play is, and whatever the armchair experts have to say it, there’s truth underneath it. I think the theater is net positive for cybersecurity, a pretty loud reminder of what’s coming down the line. But I don’t want to make that claim from the same armchair as the thousands of newly minted cybersecurity experts who spawned on Twitter and overnight.

So instead of theorizing, I want to show what happens when you point a frontier model at one of the most complex pieces of software on the internet, Chrome’s V8 engine, and ask it to write a working exploit.

The core question is simple: if models keep getting better at turning patches into exploits, and patching stays slow, what happens to everything running outdated code?

Whether Mythos is overhyped or not doesn’t matter. The curve isn’t flattening. If not Mythos, then the version, or the one after that. Eventually, any script kiddie with enough patience and an API key will be able to pop shells on unpatched software. It’s a question of when, not if.

In 2022, we published research at DEF CON USA on exploiting patch gaps in Electron apps, Discord, Teams, Notion, basically everything built on Electron. The core idea is simple: Electron apps ship their own bundled Chromium, and they lag behind upstream by weeks or months. That gap means known, patched CVEs in V8 are still wide open in every Electron app on your machine. As part of that research, we wrote working n-day exploits targeting Electron apps directly. Here’s a blog post on us getting RCE on Discord using a V8 n-day.

Nothing has changed since 2022. Here’s what’s running on my machine right now:

Note: for apps with sandbox enabled, you need three bugs for a full chain: heap control, V8 cage bypass, and sandbox escape. All of the above have unpatched sandbox escape CVEs sitting in the open. Writing those exploits is doable in theory, I just ran out of patience hand-holding Opus through it. I’m like 60% confident you could pwn Claude Desktop for a few thousand dollars and enough babysitting.

I picked Discord as my target. It only needs two bugs for a full chain since there’s no sandbox on the main window. It’s sitting on Chrome 138, nine major versions behind current. You’d still need an XSS on discord.com to deliver the payload. I’ll leave how hard that is as an exercise for the reader.

This wasn’t a single chat session. It spanned multiple Claude sessions over a week, with subtasks split across multiple threads, scaffolding to validate outputs, LLDB fed back into context, and progress checkpointed across sessions.

At no point did I touch LLDB; it was just me texting. I didn’t teach it how to exploit anything. I didn’t explain V8 internals or walk it through exploitation techniques. My job was purely operational: recognizing when it was stuck in a loop, killing sessions that were going nowhere, and nudging it toward more promising targets. Think of it as driving the car without touching the engine, except the car constantly tries to drive itself into a ditch, and keeping it on the road is exhausting.

Step 1: Find n-day to write exploit

Dump every CVE between Chrome 138 and the latest 147. Ask Opus to pick bugs by reading V8 git log patches and identifying easy candidates for an out-of-bounds heap primitive.

This is where most of the tokens went. Across 22 sessions, Claude tried 27 different approaches that failed before finding a chain that worked. Bugs that looked exploitable kept turning into dead ends.

Some bugs were beyond reach. I know how to exploit CVE-2025-12429, but Claude couldn’t figure it out. CVE-2026-3910, an in-the-wild exploit, was one neither of us could figure out. After enough wasted sessions, I stopped letting it choose and pointed it at a CVE I knew was workable based on its exploration, but it didn’t realize this was the easier one:

CVE-2026-5873: Out of bounds read and write in V8. Fixed in Chrome 147.0.7727.55. Reported 2026-03-25.

Step 2: Write exploit that gives OOB on heap

CVE-2026-5873 is a V8 heap OOB on Chrome 146, a version before latest Chrome. There’s no public exploit for this bug. Using just the git log of the patch, after a day of struggle, Claude built a working OOB read/write primitive from scratch.

Fun story: when I tested the exploit on my actual Chrome, it worked. Turns out I hadn’t clicked the update button. Time-to-patch in action.

Step 3: Heap Cage Bypass

OOB on the heap isn’t enough, you need to escape V8’s sandbox to get arbitrary read/write. That requires a second bug. I picked a disclosed sandbox bypass from the Chromium tracker and pointed Claude at it to chain with the heap OOB:

V8 Sandbox Bypass: WasmCPT handle UAF via import dispatch table corruption (multiple variants of b/446113730)

After four days, the full chain :

A bounds-check elimination bug in V8’s Turboshaft compiler for WebAssembly. When a Wasm function takes an i64 parameter, truncates it to i32 via i32.convert_i64 , shifts the result left (e.g., shl 2 ), and uses it as a memory load/store index, Turboshaft’s optimization pipeline incorrectly eliminates the bounds check after tier-up from Liftoff.

Under Liftoff (baseline), the bounds check is correctly applied. After Turboshaft compiles the function (triggered by sufficient execution), the bounds check is eliminated, allowing arbitrary OOB reads and writes relative to the Wasm linear memory base.

The i32.convert_i64 instruction discards the upper 32 bits. So when calling read(0x100000000n) , the effective index is 0 (truncated). This means:

The bug detection ( checkBug ) exploits the fact that under Liftoff, read(0x100000000n) traps (bounds check sees the full 64-bit value before truncation), but under buggy Turboshaft, it silently reads offset 0 (post-truncation, no bounds check).

64 ArrayBuffers, each 64 KB, filled with 0xCCCC0000 | index . These serve as recognizable landmarks when probing from the Wasm OOB primitive. The backing stores are allocated in the V8 sandbox’s pointer compression cage, and their positions relative to the Wasm linear memory base are what we discover in Phase 3.

Three functions are built:

The Wasm module has exactly 1 page (64 KB) of memory. Under Liftoff, accesses beyond 64 KB trap. After Turboshaft tier-up with the bug, they don’t.

The warmup loop must yield to the event loop ( await setTimeout ) between batches so that:

Typically, the bug activates on batch 1 (immediately after the first yield), meaning Turboshaft compiles the functions during the 500ms sleep.

Actually, the logic is subtler. The catch(e) { return true; } path is hit when Liftoff is still active (the function traps on the large index). But return true here is intentional: it signals “bug might be active” because the throw itself indicates the function is being bounds-checked at the 64-bit level (Liftoff behavior). The real proof is that after Turboshaft, read(0x100000000n) returns 0xDEADBEEF (truncated to index 0), matching v0 , so v1 !== v0 is false . The outer loop only breaks on true , which happens when either:

In practice, the bug reliably activates on the first batch, and the subsequent OOB probes in Phase 3 confirm it.

The Wasm linear memory is at cage offset M * 4GB . The AB backing stores are at cage offset N * 4GB + sub_offset . The OOB primitive reads relative to the Wasm memory base, so to reach an AB backing store at cage offset X , we compute:

The exploit generates probes for M ∈ [5,9] and N ∈ [0,6]:

The 0xa0000 sub-offset is a typical alignment offset where AB backing stores start within their 4GB region.

JavaScript’s >> operator only works on 32-bit integers. For probes where delta is negative and large (e.g., (0 - 9) * 4GB ), using regular JS numbers would silently truncate to 32 bits. BigInt preserves the full 64-bit value.

When a probe returns 0xCCCC00XX , we’ve found AB[XX]‘s backing store.

From the probe result, we know N - M (the difference) but not M and N individually. We determine M by trying candidate values and checking if cage reads at known small offsets (0x40000, 0x80000) return non-zero data (heap objects always exist at low cage offsets).

Once M is known, readCage32 and writeCage32 translate any cage offset to the correct OOB read/write parameters.

This is the most complex phase. We need to find the JSArrayBuffer metadata object (not just its backing store data) in the V8 heap so we can corrupt its backing_store and byte_length fields.

V8 stores the backing_store field as a SandboxedPointer — a cage offset left-shifted by 24 bits:

We scan the low cage region (0x40000 to 0x2000000) for the high 32 bits of the SandboxedPointer. When found, we verify by decoding the full SP back to a cage offset and reading the AB’s marker data:

We write a unique marker through the cage R/W to the backing store, then check which JS ArrayBuffer sees it:

Multiple structures reference the backing store (e.g., BackingStore metadata, ArrayBufferExtension ). To find the actual JSArrayBuffer , we probe byte_length mutation:

With the JSArrayBuffer object located at targetABOff , we can temporarily redirect its backing_store to read/write any cage offset:

Set backing_store to 0 (= cage base) and byte_length to 0xFFFFFFFFFFFFFFFF :

Now new DataView(victim) can read/write any offset within the entire V8 sandbox (up to 34 GB visible via byteLength ).

Place a sentinel array [MARKER_SMI, target_obj, MARKER_SMI] and scan the cage for the SMI marker pattern. The compressed pointer between the two markers is the target object’s cage-relative address:

At this point we have: full in-cage arbitrary R/W + addrof .

This phase escapes the V8 sandbox to achieve full virtual address space R/W . It exploits a use-after-free in V8’s WasmCodePointerTable (WCPT) via dispatch table handle corruption, then forges a CanonicalSig type confusion to reinterpret a wasm ref $s (struct pointer) as a raw i64 .

Origin : This technique is based on chromium issue 452605803 , reported by Seunghyun Lee (@0x10n), which is itself a variant of chromium issue 446113730 . Both demonstrate V8 sandbox bypasses given an in-sandbox corruption primitive (our CVE-2026-5873 OOB provides this).

V8’s sandbox isolates “trusted” (out-of-sandbox) data from “untrusted” (in-sandbox) objects using indirection tables:

When a wasm module imports a JS function, V8 creates a **dispatch_table_for_imports ** — a WasmDispatchTable that holds the import’s WCPT handle and a WasmImportWrapperHandle (a shared_ptr controlling the WCPT entry’s lifetime).

The key insight: WasmTableObject (in-sandbox) has a trusted pointer handle at offset 0x1c pointing to its WasmDispatchTable (out-of-sandbox). With in-sandbox R/W, we can overwrite this handle to point to a different dispatch table — specifically the import dispatch table.

Every WasmTableObject has a TrustedDispatchTable handle at offset kTDTOffset = 0x1c . Consecutive table allocations get consecutive handles in the TPT:

h_stride is the trusted pointer table entry size. h_new tells us how many TPT entries the wasm module instantiation created between dummy_table0 and dummy_table1 — this is used to compute target_ofs .

The first module imports a function with the signature (i64, i64) → (i64, ref $s) :

The call_fn export calls the import via ref.func + call_ref (not call_indirect ):

Why call_ref instead of call_indirect ? Bug 446113730 used kExprCallFunction $fn (direct import call via dispatch_table_for_imports ). Bug 452605803 uses kExprCallRef instead. The ref.func instruction creates a WasmInternalFunction (= WasmFuncRef ) that holds the import wrapper’s WCPT call target. Critically, the WasmInternalFunction holds the WCPT call target without holding the WasmImportWrapperHandle (the reference count holder). It relies on the WasmImportData (its implicit_arg ) to keep things alive. But after we corrupt the dispatch table and free the entry, the WasmInternalFunction still points to the now-freed WCPT slot.

We overwrite a fresh table’s dispatch handle to point at the import dispatch table’s handle, then grow the table. Growing replaces the dispatch table with a new one and drops the shared_ptr for the old entries, which decrements the refcount to 0 and frees the WCPT entry :

After this, the WCPT entry that call_fn ’s import wrapper used is freed but still referenced by the WasmInternalFunction from Step 2.

We instantiate a second module whose functions will reclaim the freed WCPT slots:

The called_fn function serves as both a read and write primitive over its memory64 :

inst2 is instantiated after the free, so its functions’ WCPT entries reclaim the freed slots. Both modules the same CanonicalSig because V8 deduplicates identical wasm signatures.

When call_fn (from inst1) calls the import, it now goes through the freed-and-reclaimed WCPT entry. The call path confuses WasmImportData (for the import) with WasmTrustedInstanceData (for called_fn ). By coincidence, kMemory0StartOffset in WasmTrustedInstanceData equals kWasmImportDataSigOffset in WasmImportData . So when called_fn accesses its memory64 , the base address it reads is actually the **CanonicalSig pointer** from the import data.

This means called_fn ’s memory loads/stores operate on the CanonicalSig structure directly:

Before calling called_fn through the corrupted path, we tier it up to avoid Liftoff-specific code that accesses WasmTrustedInstanceData fields that would crash:

The CanonicalSig structure describes the function’s type signature:

The return types at +0x28 are (i64, ref $s) . The param types at +0x30 are (i64, i64) . We overwrite the return types with the param types:

Now V8 thinks called_fn returns (i64, i64) instead of (i64, ref $s) . When called_fn returns a ref $s (a heap pointer to a wasm struct), the caller interprets it as a raw i64 . This breaks out of the type system entirely.

With the forged signature, we build read64 and write64 helpers:

Because the ref $s is now treated as a raw i64 , the struct’s field access becomes an arbitrary memory dereference at any 64-bit virtual address:

At this point we have full virtual address space R/W — we have escaped the V8 sandbox.

Setting return_count to 0 for the fnx signature causes the caller to read uninitialized stack slots as return values:

leak_rec calls a recursive function (to push frames), then calls the modified fnx import. The “return values” are actually stale stack data, leaking JIT code addresses and stack pointers.

The bug was fixed in two commits:

Fix 1 — Clear old dispatch table entries on grow ( 1d13848 , Clemens Backes, Sep 2025, fixes bug 446113730):

When WasmDispatchTable::Grow creates a new table, the old table’s entries were left intact. With in-sandbox corruption, an attacker could still reach the old table and use its stale entries (pointing to freed WCPT slots). The fix clears all entries in the old table after copying them to the new one:

This makes any subsequent call through the old table crash immediately.

Fix 2 — Un-expose dispatch_table_for_imports ( 9fdddb6 , Jakob Kummerow, Oct 2025, fixes bug 452605803):

Fix 1 was insufficient because bug 452605803 showed the attack still works using ref.func + call_ref (the WasmInternalFunction still holds the WCPT call target after the table is cleared). The definitive fix removes the dispatch_table_for_imports from the trusted pointer table entirely, making it unreachable from any in-sandbox object :

Zap overwrites the TPT handle with an invalid sentinel. Now even if an attacker corrupts a WasmTableObject ’s handle field, they can never point it at the import dispatch table — there is simply no valid handle for it in the TPT. The import dispatch table is still used internally by V8 (via direct C++ pointers), but no in-sandbox JavaScript or wasm object can reach it.

After freeing, the SlotSpanMetadata freelist head (stored in the superpage metadata area) contains the full virtual address of the freed buffer. Subtracting the known cage offset gives SANDBOX_BASE :

The V8 Isolate stores cage_base_ (= SANDBOX_BASE ) at offset 0. We scan forward from sig_addr (the CanonicalSig address, which is in the trusted space near the Isolate):

The Isolate contains pointers into macOS’s shared dyld cache (libsystem_c, etc.). We scan the Isolate area for addresses in the 0x180000000..0x200000000 range (dyld cache region on ARM64 macOS):

The delta 0x49ce8 was determined via LLDB by comparing the addresses of printf and system in the same Chrome process.

The WasmCodePointerTable base address is stored in the ExternalReferenceTable , which lives inside the Isolate. We identify it by scanning for 16KB-aligned addresses in the 1-3 GB range that aren’t known table bases (TPT, CPT, JDT):

Allocate a 16 MB ArrayBuffer in the sandbox and fill every entry with system() :

Each WCPT entry is 16 bytes: [entrypoint (8)] [signature_hash (8)] . The generic JSToWasmWrapperAsm builtin uses CallWasmCodePointerNoSignatureCheck , so the hash is ignored.

A fresh Wasm function with 0 prior calls uses the generic JS-to-Wasm wrapper builtin (not a compiled wrapper). This builtin loads the WCPT base from the ExternalReferenceTable at runtime:

When triggerFn is called:

Restore the original WCPT base to prevent subsequent crashes.

$2,283 in tokens and 20 hours of babysitting to produce one working Chrome exploit chain. That sounds expensive, until you compare it to the weeks of focused human effort it would normally take.

You might wonder if 6 , 283 ( l e t ′ s s a y a n e x t r a 6,283 (let's say an extra 6 , 283 ( l e t ′ ss a y an e x t r a 4000 for my efforts) all-in is worth it. Some context:

$6,283 to produce a working Chrome exploit chain. Already profitable on the legitimate market. On the other market, very profitable.

It is so tiring to manage Claude and keep it on track. It gets stuck all the time, loses context, and if you leave it alone, it just spins. Generally, these are a few issues I noticed:

The chart below is from Anthropic’s red team blog on Firefox exploit generation. It doesn’t say much how good Mythos is at finding vulnerabilities, but it clearly shows a step up in turning known bugs into working exploits, which is literally the task we just ran: give it a set of browser bugs and ask Opus to write a full chain. The difference looks interesting.

I’m speculating here. When I get access to Mythos, I’ll report back on what actually changed. But even if Mythos is overhyped, the direction is obvious. A model that needs this much hand-holding today will need less tomorrow. If Opus can do what I just showed you, extrapolate to Mythos. Then extrapolate again.

A model that’s fast at exploit development compresses time-to-exploit. Time-to-patch doesn’t keep up; there are prioritization backlogs, teams running vulnerable versions they don’t even know , and the simple fact that someone has to actually push the update. When the first shrinks and the second doesn’t, you get a flood of in-the-wild n-day exploits. This applies broadly to all software, but a few things make it especially bad:

I don’t know. Part of me hopes Mythos is a bust and scaling laws hit a wall. Doesn’t look like that’s happening.

It’s easy to say “patch faster” and a lot harder to actually do it. But a few things feel obvious to do: