I had some free time, so I tried to pwn V8
Table of Contents
TLDR⌗
I am not working full time right now, which means I have free time which is dangerous. You start by reading one V8 blog post and somehow end up trying to make Google’s Chrome open /flag/flag with a ROP chain.
That is basically what happened.
I chained three public V8 bugs against the exact Chrome build used by Google’s v8CTF. The first bug leaked a compressed object address, second turned a garbage collection mistake into a fake JavaScript array and read/write access inside the V8 cage and the third used a JSPI and JS Dispatch Table mismatch to pivot the native stack outside that cage.
Then I reused code already inside Chrome to open, read and print the flag.
v8CTF{1785916837:02b9910f32b5064c14c693a910748736031da940}
Real Google flag: yes.
V8 sandbox escape: yes.
$10,000 bounty: no.
Getting the flag and winning the challenge turned out to be two completely different things. This is the full story, including the ugly reliability numbers and how much LLM assistance went into the work.
I had time, which is always dangerous⌗
There is a very specific kind of confidence you get after reading three browser exploitation posts. You do not know enough to build an exploit, But you know enough to believe that you probably can.
That is where this started.
I have spent years around application security, old Internet Explorer exploitation, heap sprays, ROP, ASLR, DEP and the usual fun. Modern V8 exploitation still looked like a different planet. Every useful pointer seemed to live behind another table, every object had a compressed representation. Every time I thought I understood the heap, the garbage collector moved it.
So I decided to learn it the only way that works for me: choose one concrete target, define a ridiculous finish line and keep going until either the finish line or my patience breaks.
Google had already provided the finish line.
what exactly is v8CTF?⌗
v8CTF is Google’s continuous V8 exploit challenge. Google runs a pinned Chrome build on its infrastructure. You connect to the service, solve a proof of work and give it an HTTPS URL. Chrome opens your page and A flag exists at /flag/flag.
Your job is to make Chrome print it.
That sounds simple because the sentence has hidden almost all the work.
the protocol is tiny. the amount of browser internals between the first and last box is not.
The official rules say an eligible submission can receive $10,000. But the word eligible is doing a lot of work there.
- The exploit has to recover a real flag from Google’s infrastructure.
- Only the first submission for a given initial memory corruption bug is eligible.
- Normally only the first submission for a deployed V8 version gets that version’s slot.
- A 0 day is exempt from that separate version limit.
- An n-day flag must be captured after Google opens the nday window.
- Average runtime has to stay below five minutes.
- Success rate has to be at least 80%.
If you found and reported the initial bug, Google can treat the chain as a 0 day. If the bug was already public or somebody else found it, the chain is an n-day.
Mine was very much an n-day chain as I did not discover the original bugs. I combined public work, adapted it to the exact target and built the missing pieces between those bugs.
Still difficult but not a 0-day.
the exact Chrome mattered⌗
The target was Chrome for Testing 150.0.7871.46 on Linux x86-64. It contained V8 15.0.245.13 at commit 968f19a8970f8d91702d86f0ec1522f3909781b7.
Browser exploitation is not “Chrome 150-ish”.
A map value, object layout, builtin offset, ROP gadget or pointer table rule can change between nearby builds. An exploit for the wrong minor version is often an expensive way to launch a crash reporter. The challenge disabled the crash reporter too, so sometimes it was only an expensive way to stare at silence.
Chrome was launched with flags including:
chrome \
--headless=new \
--no-sandbox \
--disable-crashpad \
--disable-breakpad \
--enable-logging=stderr \
--user-data-dir=/home/user \
"${url}"
The --no-sandbox flag needs an explanation.
Chrome has more than one security boundarys, The process sandbox was disabled for this challenge, but the in process V8 heap sandbox still existed. The outer kCTF and nsjail isolation also remained. Native code execution in the renderer was enough to read the challenge flag, but getting ordinary corruption inside the V8 heap was not.
I escaped the V8 sandbox into the native renderer. I did not escape Chrome’s process sandbox because the challenge had disabled it and I did not escape the outer jail.
This distinction matters. “Chrome sandbox escape” would make a much better headline. It would also be wrong.
modern V8 exploitation, explained like old IE⌗
If you remember old browser exploitation, the rough plan looked like this:
- Trigger a use-after-free or overflow.
- Spray until controlled data lands where the old object lived.
- replace a pointer or vtable.
- Leak a module address to defeat ASLR.
- Build ROP to work around DEP.
- Jump somewhere useful.
Modern V8 has the same family resemblance. It has also put several locked doors between every step.
| old browser mental model | modern V8 version |
|---|---|
| spray the heap | groom young space, old space and large-object space |
| reclaim a freed object | reclaim a stale tagged value with a fake JS object |
| overwrite a raw pointer | first obtain read/write inside the 4 GB V8 cage |
| leak a module address | cross from caged metadata into a native resource leak |
| replace a code pointer | deal with trusted pointer and dispatch tables |
| run shellcode | often reuse existing code because W^X is still active |
The modern exploit was not one magical bug that immediately produced native code execution. It was a ladder of smaller capabilities.
every box had a test. if a stage did not produce an observable result, it did not exist, no matter how convincing the theory sounded.
a very small amount of V8 architecture⌗
JavaScript values need to represent integers, doubles, strings, objects, arrays and functions. V8 uses tagged values so it can quickly distinguish a small integer from a heap-object reference.
On a 64-bit pointer compressed build, many heap references are stored as 32-bit offsets inside a 4 GB region called the cage. V8 reconstructs the full pointer using the cage base.
compressed heap pointer
31 1 0
+--------------------------------+-+
| offset inside the 4 GB cage |1| object tag
+--------------------------------+-+
small integer (Smi)
+--------------------------------+-+
| signed integer bits |0|
+--------------------------------+-+
This saves memory and limits where corrupted compressed pointers can point. It also means leaking one compressed pointer is useful but does not reveal Chrome’s native image base.
The V8 sandbox goes further. It assumes an attacker may already be able to corrupt memory inside the heap cage and tries to keep important native pointers outside it.
The simplified list of things standing in the way looked like this:
| protection | what it was protecting |
|---|---|
| External Pointer Table | native resources referenced by JavaScript objects |
| Trusted Pointer Table | trusted V8 objects outside the ordinary cage |
| Code Pointer Tables | executable targets |
| JS Dispatch Table | JavaScript call targets and trusted call metadata |
| W^X | pages should not be writable and executable together |
| ASLR | native Chrome addresses |
| CFI | invalid control-flow transfers |
So even after gaining arbitrary read/write inside the cage, the exploit was not finished. It had only earned the right to start fighting the next layer.
choosing three bugs that completed each other⌗
The target build contained several issues fixed later in Chrome 150. I looked at V8, Blink, PDFium, Skia, ANGLE and other routes. Most produced crashes, partial capabilities or technically fascinating ways to waste a week.
The chain that finally worked used three public CVEs.
bug 1: CVE-2026-15903 gave me an address oracle⌗
CVE-2026-15903 was an optimizing compiler bug, A safe-integer assumption was lost while V8 lowered a number-like value to a 32-bit machine word. TurboFan’s reasoning said the value stayed inside a safe range. The generated machine code disagreed.
That disagreement could make String.prototype.charCodeAt read outside its intended string.
This was useful because it affected the exact target, triggered from an ordinary web page and provided a controlled byte-reading oracle. If I placed many references to the same object nearby, the oracle could scan memory and find the repeated compressed pointer.
It could read but It could not give me the write I needed.
bug 2: CVE-2026-15776 gave me the write⌗
CVE-2026-15776 was a RegExp representation mistake involving lastIndex and the largest positive Smi, 1073741823.
The vulnerable path incremented that value across the Smi boundary. The result became a heap-allocated HeapNumber, but one fast path treated it enough like a Smi that the old-to-young reference was not recorded correctly.
After minor garbage collections, the RegExp could still point to an allocation that the collector had already recycled.
Salvatore Gulizia, also known as Serotav, published excellent work showing how to reclaim the stale slot as a fake array. His public exploit depended on layout assumptions that were not stable enough in my target. The address oracle from the first bug supplied the missing location information.
Bug one knew where the object lived but could not write and Bug two could replace the object but needed help finding the right layout.
bug 3: JSPI and the JDT gave me native control⌗
The first two bugs produced arbitrary read/write inside the V8 cage. The V8 sandbox still protected native process memory.
For that boundary I reused the public JSPI and JS Dispatch Table issue tracked as Chromium issue 537948358, fixed by Jihyeon Jeong (p0-tato) in commit 752405a.
The short version is that a hidden fixed-arity WasmResume builtin and its JDT entry could disagree about how many native stack arguments needed cleanup. A disagreement about stack cleanup is not harmless metadata confusion. It can move the return slot into attacker influenced values.
That was the path outside the cage.
act 1: making a browser optimize the vulnerable function⌗
The final exploit ran in a normal web page. It could not use %OptimizeFunctionOnNextCall or other d8 shell helpers.
I trained the vulnerable functions through ordinary DOM event dispatch instead:
async function tierViaNativeEvents(listener, name) {
const dispatcher = document.createElement("span");
const event = new Event(name);
dispatcher.addEventListener(name, listener);
for (let i = 0; i < 2000; i++) dispatcher.dispatchEvent(event);
await sleep(250);
for (let i = 0; i < 30000; i++) dispatcher.dispatchEvent(event);
await sleep(250);
}
The optimized reader believed its last index remained inside a 256-byte string. Generated code could select a 256-byte window outside it. I placed arrays containing 64 references to the same victim nearby and scanned for a repeated odd 32-bit value.
Odd mattered because compressed heap pointers carry the object tag in the low bit. Repetition mattered because random memory contains plenty of odd numbers too. Sixty-four copies of one candidate are a much better signal than “this number feels pointer-ish”.
The live run eventually printed:
V8CTF-CAGE-RW:15903-addrof:victim=0x012c80f9:count=64:page=0x1000
That address belonged to that process. It was evidence, not a reusable magic constant.
act 2: asking garbage collection to betray itself⌗
V8 uses a generational garbage collector. New objects normally start in young space. Objects that survive can move into old space. When an old object points at a young object, a write barrier records the relationship so a minor collection knows the young object is still alive.
The RegExp bug broke that bookkeeping.
The old JSRegExp retained a pointer to the young HeapNumber, but the collector did not remember the edge correctly. A minor collection reclaimed the number. The pointer stayed. I sprayed attacker-shaped allocations and tried to win the freed slot.
same address, completely different object. the collector sees reusable memory. the RegExp still sees lastIndex. I see a fake array.
The trigger looked roughly like this:
RegExp.prototype[Symbol.matchAll].call(pseudoRe, "").next();
sprayFakeArrays(cycle0, cycle1); // scavenge
sprayFakeArrays(cycle0, cycle1); // scavenge again
sprayFakeArrays(cycle0, cycle1); // try to reclaim
const master = re.lastIndex;
if (!Array.isArray(master)) throw new Error("reclaim miss");
The reclaimed bytes described a fake packed-double array:
+0x00 map
+0x04 properties
+0x08 elements
+0x0c length
With an attacker-selected elements pointer and a very large length, ordinary JavaScript indexing could read and write outside the original array.
This was the old heap-spray idea wearing a modern garbage-collector costume.
act 3: proving read/write without lying to myself⌗
A renderer crash is not an arbitrary read/write primitive. A fake object that survives one property access is not a stable primitive either.
I used a sacrificial double and required a complete round trip:
read original value: 6.625
write through primitive: 42.424242
observe from JavaScript: 42.424242
read the same raw bits back
restore original value: 6.625
Only after all of that passed did I call the primitive read/write.
There was also a four-byte alignment problem. Compressed object fields sit on a four-byte grid. JavaScript doubles occupy eight bytes. Some values fit inside one double. Others were split across two neighboring doubles. This sounds like a small implementation detail because the sentence is small.
The debugging time was not small.
The reclaimed master array was also fragile. The rest of the exploit needed Promises, WebAssembly modules, external strings and many allocations. Any collection could inspect or move something I desperately wanted left alone.
The useful fix was V8’s large-object space. Large backing stores are not compacted like ordinary small objects. I prepared three stable allocations:
| allocation | job |
|---|---|
| large double backing | stable, wide caged reads and writes |
| large tagged backing | a legitimate GC-traced slot for addrof and fakeobj |
| large carrier backing | the memory that later became the ROP stack |
The lesson was not “disable GC”. The lesson was “give GC legitimate references it knows how to maintain”.
act 4: leaking a native Chrome address⌗
At this point I controlled memory inside the V8 cage. ASLR still hid Chrome’s native image base.
The External Pointer Table stopped the obvious attack. An ExternalString does not store a raw native resource pointer that caged corruption can simply replace. It stores a protected handle.
But the JavaScript string still had a writable length inside the cage.
I enlarged that length while preserving the legitimate EPT handle. charCodeAt then trusted the corrupted length far enough to read beyond the native external-string allocation. Repeated external resource objects exposed a repeated vtable pointer inside the Chrome binary.
For this exact build:
leaked resource vtable - 0x101c7588 = randomized Chrome image base
That defeated ASLR for the current renderer. It did not provide unrestricted native write. It did not need to. The next bug supplied control flow.
This separation was important: one primitive disclosed native addresses, another primitive controlled the native stack. I kept looking for one perfect native read/write primitive when two narrower tools were enough.
act 5: convincing JSPI to lose the stack⌗
JavaScript Promise Integration or JSPI, lets WebAssembly suspend when a JavaScript import returns a Promise and resume later. V8 preserves native execution state to make that asynchronous trick work.
I created two suspended WebAssembly computations with WebAssembly.Suspending and WebAssembly.promising. Their Promises contained internal reactions pointing to genuine hidden WasmResume handlers. Using caged read/write and fakeobj, I recovered those handlers as JavaScript values.
The vulnerable build allowed the hidden builtin and its JDT metadata to disagree about stack cleanup.
caller / JDT expectation: clean N stack values
WasmResume reality: clean receiver + one argument
-----------------------------
return slot disagreement
I supplied a forged receiver and a deliberately mismatched call frame. At the controlled return, a pop rsp; ret gadget moved the native stack pointer into the large carrier backing I had prepared inside V8.
the caller and callee disagreed about where the call frame ended. the CPU eventually asked my carrier array for directions.
That crossed from corrupted JavaScript objects to native renderer control.
That was the V8 sandbox escape.
act 6: no shellcode, just one file⌗
I did not need a reverse shell, a calculator or an executable memory page. I needed to read one file.
The ROP chain reused code already present in the exact Chrome binary:
open64("/flag/flag", O_RDONLY);
read(fd, writable_buffer, 0x100);
write(2, writable_buffer, bytes_read);
_exit(42);
Open, read, write. ORW.
W^X remained intact. The exploit used short instruction sequences and PLT calls already present in Chrome.
There was one final piece of nonsense. V8 heap pointers are tagged, so the native receiver pointer landed one byte past the aligned location where the caged writer naturally wrote. The ROP stream had to be shifted by one byte without destroying its neighbors.
That is modern browser exploitation in one sentence: after crossing several serious security boundaries, you lose another evening to one byte.
the moment it actually worked⌗
Local testing used the exact Chrome build inside Docker, a read-only synthetic /flag/flag, fresh profiles for every attempt and no Docker networking. I did not touch Google’s service until the complete ORW chain printed the local synthetic flag.
The live client solved the kCTF proof of work, sent the hosted exploit URL and captured Chrome’s output.
The important part was this:
Version: Google Chrome for Testing 150.0.7871.46
V8CTF-CAGE-RW:15903-feedback:ordinary-oob-isNaN=true
V8CTF-CAGE-RW:15903-addrof:victim=0x012c80f9:count=64:page=0x1000
V8CTF-CAGE-RW:jspi-prepared:moduleA=1:moduleB=1:promises=2
v8CTF{1785916837:02b9910f32b5064c14c693a910748736031da940}
I stared at the final line for a while.
A web page had entered headless Chrome. A compiler bug leaked one compressed address. A RegExp bug let me reclaim a stale allocation. A fake array produced caged read/write. An ExternalString leaked the native image. A JSPI mismatch unbalanced the native stack. Existing Chrome code opened one file and printed one line.
I got the flag.
That part was not theoretical.
ChatGPT did not press a pwn V8 button⌗
I did not build this alone and I do not want to pretend otherwise.
I chose the target, ran the experiments, debugged the exact Chrome build and decided what evidence counted. ChatGPT using the Sol model did a large amount of source navigation, experiment design and code drafting. I used other models to research alternative paths and challenge claims before trusting them.
The LLMs helped with:
- Mapping public fixes to the exact vulnerable V8 revision.
- Comparing object layouts and source paths.
- Generating small diagnostic pages and local harnesses.
- Reading long debugger transcripts.
- Suggesting alternatives when a route died.
- Keeping track of which security boundary a primitive had actually crossed.
- Fact-checking this post against the exploit logs and official rules.
There was no prompt that said “pwn V8” and returned a working exploit.
The real loop looked like this:
human chooses a target and decides what evidence means
-> LLM reads source and proposes a small experiment
-> harness runs against the exact Chrome binary
-> experiment usually fails
-> inspect the last trustworthy marker
-> update the theory
-> try again
-> repeat an unreasonable number of times
The models were useful and confidently wrong on a regular basis. They invented object layouts, treated unrelated crashes as progress and proposed beautiful exploit chains that immediately fell apart against the binary.
If an idea did not produce a marker, a controlled value or a successful run, it did not count.
This work made me more optimistic about AI-assisted security research and much less interested in AI-generated exploit claims without logs.
so why was there no $10,000?⌗
Now for the less cinematic part.
This was not a credible $10,000 claim, even though it recovered the flag. Two independent eligibility problems were visible.
First, this was an n-day chain. The initial vulnerabilities came from other researchers. Google’s public sheet already showed a confirmed M150 n-day from July 13, before this flag was captured on August 5. The rules normally allow only the first eligible n-day for a deployed version.
Second, the exploit was nowhere near the required 80% reliability.
The final direct package succeeded in 1 of 5 fresh local runs. A separate tunnel-hosted tuning batch reached 5 of 10. Those were different delivery conditions, so I am keeping the numbers separate rather than combining them into one nicer-looking lie.
one successful flag proves exploitability. it does not magically turn the other failed runs into successes.
Some old notes said 3/5. The preserved raw summary said 1/5. The raw logs win that argument. Even 3/5 would only have been 60% anyway.
The reclaim depended on GC timing, heap occupancy, allocation order, JIT tiering, native resource placement and startup noise. Successful runs were fast, often around four to seven seconds. They simply did not happen often enough.
So the honest scoreboard is:
real Google flag recovered: yes
V8 heap sandbox escaped: yes
native renderer control: yes
Chrome process sandbox escaped: no, disabled by challenge
outer kCTF/nsjail escaped: no
new initial vulnerability found by me: no
80% reliability reached: no
$10,000 bounty received: no
Was I disappointed? Obviously. I am a security researcher, not a monk.
But “got the flag” and “won the competition” are not interchangeable sentences. This project taught me that in a very expensive dialect of JavaScript.
exploit code⌗
The complete exp.html PoC and reproduction notes are available on GitHub.
Everything is tied to Chrome 150.0.7871.46 on Linux x86-64. This is an exact-build historical exploit, not a paste-into-current-Chrome script.
why this matters outside one Chrome challenge⌗
When people hear “V8 exploit”, they normally picture a malicious website opening in Chrome. That is the dramatic version, but V8 exists in more places than one browser tab.
- Chrome and other Chromium-based browsers.
- Node.js server applications.
- Electron desktop applications.
- Multi-tenant systems built around V8 isolates.
- Embedded Chromium environments, CEF applications and WebViews.
The surrounding security boundary changes in every environment. A browser has renderer and process sandboxes. An Electron app may expose privileged preload APIs. A server runtime may hold cloud credentials. An isolate platform may place code from several tenants in one process.
This exact historical chain does not compromise all those systems. The broader questions still travel:
Can untrusted JavaScript corrupt its runtime?
Can caged metadata influence a native resource?
Can one isolate reach another tenant?
Can dispatch metadata disagree with generated code?
What secrets exist after native control?
V8 exploitation is interesting because language semantics, compiler optimization, garbage collection, object representation, WebAssembly, native ABI details and operating-system security all collide in one process.
It is several different security disciplines wearing one trench coat.
what I would do differently next time⌗
Pin the exact binary first. Not the milestone. Not a nearby patch. Exact binary, source revision, platform and launch flags.
Prove one primitive at a time. An address-leak marker is better than a browser crash that might have happened six stages later.
Treat every allocation after corruption as hostile. Logging allocates. Errors allocate. Compilation allocates. First-time typed-array use can allocate. Any of them can trigger the collection that destroys the fake object.
Use legitimate runtime machinery when possible. A real tagged slot that GC knows how to update is much more stable than hoping a malformed interior object survives forever.
Separate disclosure from control. The ExternalString leak defeated ASLR. JSPI supplied native control. I did not need one perfect native arbitrary read/write primitive.
Count every failure. Retrying until one flag appears proves exploitability. It says nothing about reliability.
Make LLMs pass evidence gates. Ask for source paths, hypotheses, harnesses and competing explanations. Do not let a fluent paragraph replace a successful run.
final thoughts⌗
I keep coming back to the live transcript.
JavaScript
-> compiler confusion
-> garbage-collector confusion
-> fake object
-> caged read/write
-> native disclosure
-> stack control
-> ORW ROP
-> v8CTF flag
No bounty No new CVE No Chrome process-sandbox escape.
Still one of the coolest things I have built.
The best part of having free time is finally learning the things you kept postponing. The dangerous part is that occasionally the learning project starts printing signed Google flags.
If you are learning V8 exploitation, do not begin by memorizing every pointer table. Pick one boundary. Build one observable capability. Read the source. Read other researchers’ work. Use the LLMs, but make them show their work. Then keep moving one box to the right.
If you are still reading this, you are awesome. Thanks for sticking with me!
references and further reading⌗
- Official v8CTF overview
- Official v8CTF rules
- Chrome for Testing 150.0.7871.46 manifest
- Exact V8 source snapshot
- CVE-2026-15903 fix
- CVE-2026-15776 fix
- Serotav: From Regex to RCE
- V8 pointer compression
- The V8 sandbox
- V8 sandbox source documentation
- Orinoco garbage collector
- Introducing JSPI
- JSPI/JDT fixed-arity fix
- Start Your Engines: Capturing the First Flag in Google’s v8CTF
- Fuzzing to Zero-Day: Pwning v8CTF
If you want to discuss the exploit, V8 internals or the painful economics of getting a flag without getting a bounty, find me on Twitter/X.
Thanks for reading.