Back to Blog

Breaking Chrome 152: A Complete V8 Sandbox Escape and Renderer RCE

BackBox AI built a complete exploit chain against the v8CTF Chrome 152 target: an address leak, arbitrary read and write inside the V8 sandbox, a parser overflow that writes outside it, and a ROP chain that opens and reads a file on disk. Here is how each stage works, and what it says about patch lag.

Breaking Chrome 152: A Complete V8 Sandbox Escape and Renderer RCE

v8CTF is Google's standing invitation to attack V8, the JavaScript engine inside Chrome. Google keeps a Chrome instance running for each recent major version; you connect to it, solve a small proof of work, and hand it a URL. The instance opens that URL in headless Chrome, confined by nsjail, and somewhere inside that confinement sits a file called /flag/flag. To read it you need code execution inside the browser process that opened your page.

We pointed BackBox AI at the Chrome 152 target, and it built one: a complete chain that starts from a plain web page, with Chrome's default settings and no special flags, and ends with arbitrary code execution and a file read. It ran end to end in three independent fresh processes.

Live demo: watch the full chain run, from page load to RCE.

This article walks through the chain stage by stage. It is a technical piece, but the mechanisms are worth understanding even if you never write an exploit, because each stage corresponds to a defensive assumption that either held or did not.

The target, and how we chose the bugs

The deployed instance ran Chrome for Testing 152.0.7977.64, with V8 15.2.124.18. By the time we started, Chrome stable for desktop had moved on to 152.0.7977.82, released on 3 September.

Some staleness is inherent to the challenge: a target that updated itself hourly would be unplayable. But the deployed build was also 18 commits behind the tip of its own release branch, which is a different kind of gap: branch-heads/15.2 had moved from 15.2.124.18, the version the target was serving, to 15.2.124.27. Those 18 commits are public. Each one has a description, a diff, and in most cases a regression test written specifically to demonstrate the bug it fixes.

The goal here was to solve the challenge, not to burn weeks hunting for something nobody had ever seen, so the fastest reliable route to working primitives was the right route. That route runs straight through V8's own commit history. We enumerated the branch commits above the deployed version, plus a window of fixes that had landed on V8's main branch and had not yet been carried over to the branch at all, and then did the one thing that separates a reading exercise from a finding: we ran each fix's public regression test against the deployed binary and recorded whether it still misbehaved.

That pass produced the three bugs the final chain is built on. It also produced a triage list of what not to spend time on, which is worth almost as much: graphics bugs that live in a process headless Chrome does not meaningfully exercise, race conditions that needed instrumentation we did not have, and a compiler bug that produced wrong numbers but, after a source audit, provably could not be turned into a memory-safety problem on this build.

Two working rules came out of it, and both generalize beyond this target.

A public fix is a weaponization blueprint. The regression test shipped alongside a fix is a minimal, maintained, officially blessed proof of concept for the bug it closes. The parser bug we used to escape the sandbox is the clearest example: its fix landed on V8's main branch on 10 September, complete with a test that reads like an attack recipe. Three days later it was reproducing on the deployed build.

A fix date is not a bug's biography. It is tempting to reason that if a fix landed after a build was cut, the bug must be present in that build. That inference is wrong often enough to matter, because the commit that introduced the bug may also postdate the cut. The compiler bug that powers the read/write stage below looks, by fix ancestry alone, like a live unfixed issue on the Chrome 153 line. It is not: the regression that created it never reached that branch at all, which we established by running the byte-identical proof of concept against Chrome 153.0.8010.36 three times and watching it behave perfectly. Check both ancestries, and when you already have a working trigger, run it before doing source archaeology.

The three bugs

Bug What goes wrong Role in the chain Upstream fix and first fixed release
CVE-2026-85046 (bug 542403045) The optimizing compilers inline Array.prototype.sort at call sites whose feedback disagrees about the array's element type Address disclosure, the entry point e0562d87ad9c, main branch, 7 August; shipped in Chrome 152.0.7977.82
547936520 A Turboshaft optimization drops the stores that initialize freshly allocated objects inside a loop Arbitrary read and write inside the V8 sandbox 67c8f3a91 on main, 28 August; 32f5419827 on the release branch, 15.2.124.19, 31 August
554034656 The parser overflows a 16 bit parameter count, so a function gets more parameters than its stack frame has room for Write and leak outside the sandbox d7795fe621, main branch, 10 September

Three bugs, one chain. In one line: leak the address of a JavaScript object, turn that into arbitrary read and write inside V8's own heap, use the parser overflow to place a single pointer onto the native stack outside that heap, and let WebAssembly dereference that pointer as if it were a trusted engine object, which yields read and write over the whole process and, from there, code execution.

Everything below ran on the deployed binary, headless, with default settings. V8 has a debugging mode that exposes engine internals to scripts, which is invaluable in a lab and unavailable in the real challenge, so the rule for the whole engagement was simple: if a primitive needed that mode to demonstrate, it stayed in the lab until it worked without it.

Step one: learning where an object lives

Modern exploitation of a JavaScript engine almost always starts with the same question. JavaScript deliberately does not let a script know the memory address of an object. Obtaining that address, a primitive traditionally called addrof, is what turns abstract corruption into aimed corruption.

CVE-2026-85046 gives it away. When V8's optimizer decides to inline Array.prototype.sort, it specializes the generated code to the kind of elements it expects the array to hold: pointers to objects, or small integers, which V8 stores in a packed form. The inlined code takes a snapshot of the array, calls your comparison function, and then copies the snapshot back using that specialization.

The comparison function is attacker code, and it runs in the middle. If it changes the array so that it now matches a different element kind that the same call site has also seen, a single fill(0) is enough to turn an array of object references into an array of small integers, the check performed after the callback still accepts it, and the copy-back writes object pointers into slots that the engine now believes hold small integers. Read one of those slots as an ordinary number and you are reading a pointer. Multiply by two, undoing the shift V8 applies to small integers, and you have the object's exact address, in the form the engine itself uses: a 32 bit offset into V8's heap region, not a machine address. The copy-back also stores those pointers without the write barrier the garbage collector relies on, which is why upstream classifies the bug as a type confusion rather than an information leak. The address is the part our chain needed.

That relationship held for the entire engagement. We verified it under a debugger against the real memory layout, and later cross-checked it against a completely independent leak produced by a different bug, which returned the same address bit for bit.

The upstream fix simply stops the compilers from inlining these iterating builtins when the element kinds at a call site disagree. We went looking for survivors in the same family, toSorted and toReversed in several shapes, and found none: the fix covers the family, not just the one entry point.

Step two: arbitrary read and write, inside the sandbox

Knowing an address is not the same as being able to use it. For that we used bug 547936520, in Turboshaft, one of V8's optimization pipelines.

Turboshaft has a pass that removes redundant stores to memory: if a location is written twice with nothing observing the first write, the first write is dead and can be deleted. The pass identified store locations by the compiler node that produced the base address plus an offset, and it failed to reset that bookkeeping across the back edge of a loop. Inside a loop body that allocates a fresh object on every iteration, the store to this iteration's object looked like the same store as the previous iteration's, and was deleted as redundant. The upstream fix adds exactly the missing step: it invalidates those in-loop bases across the backedge, so that a store to a fresh object can no longer be killed by the store to a different object one iteration later.

The stores being deleted were the ones that initialize the object's fields. The practical consequence is that freshly created objects came into existence with their fields still holding whatever the previous occupant of that memory had left there.

That is an unusually useful primitive, because the attacker controls the previous occupant. Fill the heap with copies of an object, then read a field that should have been initialized and was not, and your own sprayed pointer comes back, confirmable by strict equality. Fill the heap with specific floating point values instead, and they come back verbatim.

The variant we liked most involves array literals. With the initializing store for a one element array of numbers dropped, that single element still contains two adjacent machine words left behind by a dead object, read back as one number. Both halves turned out to be the address of the sprayed object, which gave us a second, entirely independent address leak. It matched the leak from CVE-2026-85046 exactly, in three separate runs.

With an address leak and a way to read uninitialized memory, the rest assembles itself. We forged the headers of a JavaScript array and of its backing storage inside ordinary strings, specifically strings the garbage collector had already promoted out of the nursery, which makes their contents attacker-controlled, byte-stable and fixed in place for the rest of the page's life. The address leak says where those forged headers are; reading back known header words confirms the engine has accepted them as real objects. The result is arbitrary read and write across the whole region, established in roughly 20 milliseconds of page load.

Why that was not the end

At this point a reader unfamiliar with modern browser internals might reasonably assume the job is done. Arbitrary read and write used to mean game over.

It no longer does, because of the V8 sandbox. V8 keeps ordinary JavaScript heap objects in a 4 GB region placed at a per-process random base, and a pointer from one heap object to another is stored as a 32 bit offset into that region rather than as a full machine address. That region is what the previous step gave us read and write over. It sits inside a larger sandboxed address space, which also covers memory such as typed array and WebAssembly buffers.

The design starts from an uncomfortable but realistic premise: memory corruption bugs in a JavaScript engine will keep happening, so instead of pretending otherwise, confine their consequences. A corrupted pointer between heap objects still lands inside the region, because a 32 bit offset cannot name memory outside it. The things the engine must reach that genuinely live outside, native addresses and trusted engine objects among them, are deliberately not stored as pointers in sandboxed memory at all: they are indices into tables that sandboxed memory cannot write. An attacker with full read and write inside the region is therefore an assumed condition rather than a defeat, and the sandbox's job is to keep that capability from reaching the rest of the process.

So the question is not whether you can corrupt V8's heap. It is whether anything reachable from inside it can be made to write somewhere outside.

Step three: leaving the sandbox through the parser

Bug 554034656 answered that with three in-sandbox writes and a function call.

V8 has a mechanism for compiling what it calls a wrapped function: one whose parameter list is supplied separately from its body, which is how the engine handles functions built at runtime out of strings. For such a function the parser takes the parameter names from a wrapped arguments list attached to the script, and records how many there are in a field that is 16 bits wide.

Attach a wrapped arguments list of 65,536 entries. Add the function's own real parameter and the count is 65,537, which does not fit in 16 bits and wraps around to 1. Two parts of the engine now disagree: the interpreter allocates a stack frame sized for one parameter, while the helper that prepares the wrapped arguments still emits all 65,536 parameter names into the function body, and every parameter reference compiles to a frame slot. The upstream fix bounds-checks that count before it is narrowed.

An assignment to a high numbered parameter therefore writes a value of our choosing, at an offset of our choosing, outside the frame it was supposed to stay inside. That is the native stack, which lives outside the sandbox. In the other direction, returning a high numbered parameter reads raw stack memory back into JavaScript as an ordinary number.

Arming it takes exactly three writes with the read and write primitive from step two, all aimed at the internal objects V8 keeps for a function it has not compiled yet:

  1. replace the victim script's source string with a same-length one whose body assigns to a high numbered parameter;
  2. attach a 65,536 element array to that script as its wrapped argument list;
  3. set the function's syntax-kind bits so the parser treats it as the wrapped form.

Then call the function once, and the parser does the rest.

Two details mattered enormously in practice.

The first is geometry. The write lands upward from the crafted function's frame pointer, into the stack of the caller that is still executing: the address is victim_frame_pointer + 0x18 + index * 8, a law we byte-verified in two independent processes rather than deriving on paper. Those corrupted slots are consumed almost immediately by the interpreter's dispatch loop, by the machinery that walks the stack to build an Error object, and by exception handling.

The second is vocabulary. JavaScript values do not sit in memory as raw numbers. Store a small integer and V8 writes it in its own encoded form, so a 4096 reaches memory as 0x2000; store an object and V8 writes a tagged pointer to that object, which on the stack is a full-width machine word. There is no JavaScript value whose in-memory representation is an arbitrary attacker-chosen machine address. So the most obvious move, overwriting a return address on the stack with the address of code we wanted to run, was not available at all. The productive question was not how to widen the write, but which consumer in the engine wants exactly the kind of value this write can already produce.

The bridge: a forged instance where a trusted one was expected

The answer came from reading the machine code that Liftoff, V8's baseline WebAssembly compiler, generates.

A running WebAssembly function keeps a pointer to its module instance in a slot on its own stack frame, and the generated code reloads that pointer from the slot on every iteration of a loop. Three instructions at the head of a loop do the whole thing:

mov -0x10(%rbp),%rsi     ; reload the instance from the frame slot, every iteration
mov 0x17(%rsi),%rcx      ; read the linear memory base out of it, at instance + 0x18
mov (%rcx,%rax,1),%edx   ; raw load: no mask, no tag check, no bounds check

The odd looking 0x17 is 0x18 minus the tag bit that V8 sets on every pointer to a heap object. The missing bounds check is not an oversight either. In this configuration, 64 bit with trap handler based bounds checking, the linear memory is followed by a large guard region and an out of range access faults in hardware, so for this access shape the compiler is entitled to emit a bare load.

The value in that frame slot is a full-width tagged pointer to an object living inside the sandbox, and a tagged pointer to an in-sandbox object is precisely what a JavaScript object assignment writes. The one value our escape write could express turned out to be exactly the value this consumer expects.

So the chain plants a pointer to a counterfeit instance object, built inside the sandbox where we already have full control, into the live frame slot of a WebAssembly function that is still running. Getting the counterfeit accepted took care. Five fields have to hold values the engine will tolerate: raw pointers at offsets +0x10, +0x18, +0x20 and +0x38, where +0x18 is the linear memory base we actually care about and the others only have to survive being dereferenced, plus a compressed handle at +0x7b that indexes a table of trusted objects. That last one cannot be forged, because valid entries are assigned by the engine, so it is copied instead: the page reads it out of the live WebAssembly.Instance object that JavaScript can already see, at offset +0x0c, and transplants it into the counterfeit.

With the counterfeit in place, the loop resumes and reads its memory base from our object. That base is now any address we like, and a WebAssembly load or store through it is an arbitrary read or write anywhere in the process, outside the sandbox. We confirmed it by writing a marker value to a chosen address and reading it back in JavaScript, in independent processes, with no debugger involved.

Step four: a ROP chain, and a file

Two more pieces of engineering turn that into code execution.

The first is finding out where the stack is. The crafted function can read its caller's saved frame pointer and return it to JavaScript as a number, which gives the low half of a stack address. The high half is randomized, but in the target environment we only ever observed four values, 0x7ffc through 0x7fff, so the page carries its guess as a parameter and covers all four across separate attempts.

The second is timing. The stack memory that has to be modified is only meaningful while the WebAssembly frame is still alive, and stack space below a live frame is recycled by the very next function call. Writing it from ordinary top level JavaScript is therefore useless: whatever you put there is gone before anything reads it. The payload has to be written from inside the execution that will consume it.

So the final exploit ships a WebAssembly module that does the whole job itself, in one go, without returning to JavaScript in between: it scans for its own live instance slot, validates the candidate against a known return address value, writes the payload into stack memory that is genuinely dead, and overwrites its own return address, all while its frame is still on the stack.

The payload is a return-oriented programming chain, 27 words long. The deployed Chrome binary carries no GNU_PROPERTY_X86_FEATURE_1_AND note, so it is not marked for indirect branch tracking or a shadow stack, and nothing on the test host was enforcing either. The classic technique therefore applies: instead of injecting code, you chain together fragments of instructions that already exist in the binary, each ending in a return, so that the stack itself drives execution. The fragments were located in the binary offline and byte-verified before use.

The chain performs three system calls against /flag/flag: open, then read, then write, with the path string and the read buffer placed at addresses inside the sandbox that the exploit already controls, and with an xchg moving the descriptor returned by open into the register read expects. It is assembled without a single zero word, because the chain executes what it finds, and a zero word is a return to address zero. The overwritten return address points at a pop rsp; ret fragment, which pivots the stack onto the chain; the final fragment pivots back and lets the WebAssembly call return normally. The write targets file descriptor 1, so the bytes surface in the renderer's own output stream.

It works. Three independent fresh processes, each starting from nothing but a loaded page, ran the full chain and printed back the contents of /flag/flag, which is what the demo video shows. The demonstration ran against a local instance of the exact build the challenge deployed, so the file being read is our own; Google rotated the Chrome 152 target out of service before the same page could be pointed at it.

What defenders should take from this

Branch-level patch lag is an attack surface with a public inventory. Every bug in this chain already had a public upstream fix when we used it, although not all of them had reached the same place: two were fixed on the release branch the target was built from, while the parser fix existed only on V8's main branch at the time. The deployed build was simply behind, and the gap is documented in public, in detail, by the people who closed it. Tracking major version numbers is not enough, because the interesting window is between a build and the security fixes already merged into the branch it came from.

Regression tests are exploit intelligence. A serious attacker reads fix commits and the tests that ship with them. A newly landed regression test for a memory-safety bug should be read as a reason to accelerate deployment, not as background noise.

The sandbox boundary held. The parser handed us a primitive on the other side of it. The sandbox makes a narrow promise and it kept it: nothing we did with arbitrary read and write inside the heap region turned by itself into access outside that region. What changed the picture was an integer overflow in the parser, which produced a write to the native stack, outside the boundary, from its very first use. Components adjacent to that boundary, the parser, frame layout, and any trusted object holding raw pointers, deserve the same audit intensity as the optimizing compilers.

Raw pointer reloads are the soft spots at a boundary. What converted an out-of-sandbox write into full read and write of the process was a single pattern: a trusted pointer re-read from writable stack memory on every loop iteration, then dereferenced without validation. Trusted pointers loaded from memory an attacker might reach are worth either minimizing or validating.

Crash telemetry is a detection gift. On the build we tested, a corrupted value dereferenced outside the region it belongs to produced a ## V8 sandbox violation detected! banner followed by an abort. That banner, wherever a build emits it, and the distinct crash signatures each stage of this chain produces when it misses, are high-signal detection points. So is the behavioral trace: a renderer that optimizes the same handful of functions thousands of times within seconds of a page loading is a JavaScript engine exploit warming up.

Disclosure

All three defects in this chain carry public upstream fixes that predate this write-up, so nothing here discloses a live vulnerability. CVE-2026-85046 shipped in Chrome 152.0.7977.82. Bug 547936520 was fixed on the 15.2 release branch in 15.2.124.19, and bug 554034656 on V8's main branch on 10 September, from where it rode to the later stable lines. A build that carries all three fixes is not affected by the chain described here.

Closing

The interesting result is not that a stale browser build could be exploited. It is how short the distance was between a published fix and a working chain, and how much of that distance was covered by reading material the project publishes on purpose.

That asymmetry is the practical lesson for anyone running software rather than attacking it. A fix becoming public and a fix reaching your deployed build are two different events, and the interval between them is a window in which the attacker has better documentation than the defender does. Measuring that interval, for the software you actually run, is a more useful exercise than most threat models.

If you would like this kind of depth applied to your own systems rather than to a challenge instance, get in touch.