_By Yarden Porat, Check Point Research_
We set out to break **Cloudflare Code Mode**, and ended up breaking **Cloudflare Workers** too. We did both by targeting **workerd**, the runtime beneath both: an in-process sandbox that relies entirely on V8 to isolate untrusted code.
We found five memory-corruption bugs in workerd’s native C++ (the “glue” between JavaScript and the runtime), and turned them into two end-to-end attacks:
`URLPatternlets one Worker reach across the shared process heap and` `node:zlibbreaks out of the sandbox and runs`
Code Mode is Cloudflare’s take on LLM tool use. Instead of a model emitting structured tool calls one at a time, Code Mode exposes the available tools as a **typed TypeScript API** and lets the model **write code** that calls them: loops, conditionals, data shuffling and all.
In the traditional MCP / tool-calling loop, the model emits one `{tool, args}` call, the agent runs it, feeds the result back. The model then emits the next call. Every step is a fresh model invocation, and usually a network round-trip. Code Mode collapses that: the model writes **one program** that orchestrates many tool calls itself (looping, branching, and combining intermediate results locally) and only the final output returns to the model.
Cloudflare’s argument is that LLMs, trained on enormous amounts of real-world code, are simply better at writing a program against a typed API than at emitting long chains of synthetic tool calls. [4]
Figure 1 – Tool calling vs. Code Mode
That code has to run somewhere, and that “somewhere” is **workerd**, the runtime behind Cloudflare Workers.
To understand workerd, start with the product it was built for: **Cloudflare Workers**. Workers is Cloudflare’s serverless platform: you upload a piece of code and Cloudflare runs it at the **edge**, in data centers close to the user, on demand for every request. There’s no server to manage and, ideally, no cold machine to wait for.
That model creates a hard isolation problem. Cloudflare runs code from a huge number of different customers, and to keep latency and cost down it packs many of them onto the same machines, and, as we’ll see, into the same process. The classic answer (a container or VM per tenant) is far too heavy for this: each one adds tens to hundreds of milliseconds of cold start and a real memory footprint, which is exactly what an edge platform serving oceans of short requests cannot afford.
Cloudflare’s answer is to isolate at the **language-runtime** level rather than the OS level, using V8 isolates, the same primitive Chrome uses to separate browser tabs. An isolate is a lightweight, independent JavaScript context. Many can live inside a single process, each starts in single-digit milliseconds, and the isolate is the security boundary between tenants.
The trade-off is that this boundary is a **software boundary** inside one shared address space, not a hardware or kernel one. Untrusted code runs **in-process**, and the whole model rests on the isolate holding.
Figure 2 – Many tenants, one process
**workerd** is the runtime that implements all of this. It was closed-source for years: Workers launched in 2017, but Cloudflare only released workerd as open source in **September 2022**.[5] It’s exactly what Code Mode runs the model’s generated code on.
Code Mode has to run untrusted, model-written code, and it needs that code to reach the declared MCP tools and _nothing else_. workerd answers both at once.
Running untrusted tenant code in-process is its day job, and it lets Code Mode lock the rest down: no filesystem, no arbitrary network ( `fetch()` and `connect()` simply throw) with the tools exposed only through **bindings.**[6] Cloudflare didn’t build a new sandbox for Code Mode. It reused the one it already trusts to isolate millions of Workers.
When you set out to break Code Mode, the obvious place to look is the **seam between Code Mode and workerd.** This is the integration layer: how tools become bindings, how the configuration is wired, how the two interact. Going after the **runtime itself** is the unusual move. It’s a bit like setting out to break an AI coding assistant and then going to audit Docker’s own source code, the container runtime itself, not the agent on top of it.
Five reasons made us decide to do it anyway:
V8 is one of the most heavily attacked pieces of software around, with a long history of memory bugs, so Cloudflare assumes it can break and layers defenses so a compromise of one isolate doesn’t reach the host or other tenants.
**1. The V8 sandbox (“the cage”).** The cage confines JS-reachable objects so a corrupted one can’t forge pointers outside it. Assume arbitrary read/write inside the cage, and stop it reaching memory outside.
**2. Memory protection keys.** As a further layer against V8 vulnerabilities, production also tags isolate-group memory with hardware **memory protection keys (MPK / pkeys)**, so even with arbitrary read/write inside one isolate’s V8, an attacker still can’t read another tenant’s pages.
**3. The L2 process sandbox.** Underneath both sits a **second-layer (“L2”) process sandbox**, so even native code execution inside the process is meant to be contained. Per Cloudflare, the V8 Workers run in a strict **layer-2 sandbox** (Linux namespaces plus seccomp) that blocks all filesystem and direct network access,[8] limiting what a compromised process can reach on the host.
**Node.** Real-world JavaScript assumes Node.js exists, and code constantly reaches for `node:*` modules, so workerd reimplements a large slice of the Node API in C++. This is exposed to JS through **JSG**, its “JavaScript Glue” layer. Node was never designed for a threat model where the _attacker_ writes the JavaScript, so this drops a great deal of extra native code onto the boundary, much of it workerd’s own, and enabled by default (a Worker can just `require(‘node:crypto’)`).
It also means more native objects allocated on the **tcmalloc** **heap**, which is secured by neither the cage nor the memory protection keys.
Putting all of the above together, we did exactly that. We targeted workerd’s **JSG code**, the “JavaScript Glue” that hands native C++ to untrusted JavaScript, whether it is a **Node reimplementation** or one of **workerd’s own API implementations**. It is the code that had a fraction of V8’s scrutiny (§4), and the native objects it allocates sit on the **tcmalloc heap**, memory that lives outside both the cage and the memory-protection keys (§5). So a bug there is not boxed in the way a V8 bug is. It is exactly the surface those mitigations do not cover.
By going after that code we found **five vulnerabilities, all of them in workerd’s own native code**, each covered in the Vulnerabilities section (Part II).
Building on those bugs, we developed **two end-to-end exploits**, covered in the Exploits section (Part III).
But to be explicit, **we did not run the exploit on Cloudflare production ourselves.** Both exploits were verified on the **self-hosted** version of workerd. The cross-tenant idea should work the same way on production, since it runs entirely from the tcmalloc heap that the mitigations do not cover, but we did not test it there. On a shared host, a memory-corruption exploit that crashes the process could take other tenants down with it, and we were not willing to risk that.
**URLPattern** is a Web API for matching a URL against a pattern, essentially what a router does. You build a pattern such as `new URLPattern({ pathname: “/users/:id” })`, call `.exec()` on a URL, and read back the named capture groups ( `{ id: “…” }`). workerd exposes it to Workers, and in our setting the pattern itself is attacker-controlled.
workerd actually ships **two** URLPattern implementations. The first is the original, workerd-native one (the `urlpattern_original` compatibility flag). The second is the newer standard one backed by the **Ada** **URL-parser** library. We found the **same out-of-bounds read** in both implementations, and it **gives the same primitive**.
Under the hood, URLPattern turns your pattern into a regular expression. Matching a URL then produces two parallel lists: the **matched values** (one per capture group in the regex) and the **group names**.
A quick example of the benign case:
Figure 3 – URLPattern: pattern → result
URLPattern also lets you drop raw regex straight into a pattern, with named or unnamed groups. For example, `/(d+)/(?[a-z]+)` has one unnamed group and one named group:
Figure 4 – URLPattern with named group
Here is the implementation. When you call `.exec()`, workerd runs the compiled regex against the URL and builds the `groups` object from the result. The original, workerd-native version does it like this:
“`
// urlpattern.c++: building the groups object from a regex match KJ_IF_SOME(array, regex.getHandle(js)(js, input)) { // run regex vs URL uint32_t index = 1; // [0] is full match, skip uint32_t length = array.size(); // 1 + capture count values kj::Vector fields(length – 1); while (index < length) { // each capture value auto value = array.get(js, index); fields.add(Groups::Field{ .name = kj::str(nameList[index – 1]), // name by position .value = value.isUndefined() ? kj::String() : kj::str(value), }); index++; } // … }
“`
For each capture group, the loop builds one `{ name, value }` field. The value is what the regex matched in the URL. The name is the group’s name (like `id` from earlier), taken from the `nameList` vector.
The two sides of that pairing come from completely different places, and that is the part to hold onto:
`lengthcomes from` `nameListcomes from`
Figure 5 – The group-count mismatch
The loop lines them up position by position, on the assumption that the two counts agree.
So the whole thing rests on those two counts staying equal, and they don’t always. When `URLPattern` parses the pattern to build `nameList`, its own group counting **misses a group nested inside another group**. V8, compiling the real regex, counts every group, nested ones included. So a pattern with one group nested inside another, like `(ab(cde))`, gives V8 two capture groups where URLPattern counted only one, and `length` ends up larger than `nameList`:
“`
const pattern = new URLPattern({ pathname: “/(ab(cde))” }); pattern.exec({ pathname: “/abcde” }); // V8: 2 groups, nameList: 1 name → OOB
“`
Now the loop runs one step too far. For that extra value, `index – 1` points past the end of `nameList`, and `kj::str(nameList[index – 1])` reads from beyond the vector, an out-of-bounds read. That is the bug.
`nameList` is a `kj::Vector`. A `kj::String` is 24 bytes:
Figure 6 – kj::String memory layout
The OOB index makes `kj::str()` read 24 bytes of **whatever follows the vector** and treat it as a `kj::String`, then **dereference** `ptr` to copy out the “string.” So if we control the memory after `nameList`, we control `ptr`, and the returned JS string is the bytes at an **address of our choosing**. OOB read → arbitrary read.
`urlpattern_originalon self-hosted workerd. That exact path did not reproduce on production, because production has a check the open-source build lacked.` `deflateParams()UAF`
**zlib** is the most common compression library around. Node.js ships it as the built-in `node:zlib` module, and to stay Node-compatible workerd reimplemented it in C++. It exposes a handful of APIs. The basic ones compress and decompress via **Gzip**, **Deflate/Inflate**, and **Brotli**. In workerd it comes with the `nodejs_compat` flag (compatibility date 2024-09-23 or later).
Let’s look at a basic use of zlib. You call `write()` with an input buffer and an output buffer, and zlib compresses the input into the output.
“`
const input = Buffer.from(“hello world”); const output = Buffer.alloc(64); handle.write(input, output); // compress input → output
“`
Those three lines already span three distinct layers:
The buffer to watch is `output`. As it moves, its pointer is passed between all three layers, handled differently in each. So let’s take it one layer at a time, starting on the JavaScript side.
On the JavaScript side, `output` is **reference-counted**: it stays alive as long as at least one reference points at it. Follow that count through a single `write()`:
`const output = Buffer.alloc(64). The JS variable holds it:` `handle.write(input, output, …). As the buffer crosses into native code, workerd takes a reference of its own for the duration of the call:` `write()returns, and workerd drops its reference again:` `outputanymore (it goes out of scope, or is reassigned), so the last reference is gone:`
Figure 7 – output refcount lifecycle
Now follow the same buffer into the native side. To hand `output` to zlib, workerd fills in a `z_stream`(zlib’s state struct), copying the buffer’s raw address into its `next_out` field, the pointer zlib writes its compressed output through. That copy happens in `setBuffers`, on every `write()`:
“`
// zlib-util.c++ void ZlibContext::setBuffers(kj::ArrayPtr input, kj::ArrayPtr output) { stream.avail_in = input.size(); stream.next_in = input.begin(); // raw pointer into the JS input buffer stream.avail_out = output.size(); stream.next_out = output.begin(); // raw pointer into the JS output buffer }
“`
And `write()` forgets to clear them. When it returns, it resets nothing in the `z_stream`. `next_out` still holds the raw address of `output`. Clearing it is workerd’s job, and the write path simply doesn’t.
The same sequence, now with `stream.next_out` shown alongside:
Figure 8 – next_out left dangling
Nothing ever clears `next_out` after `setBuffers` sets it. So once `output`’s refcount reaches 0, the buffer becomes garbage, and the next garbage-collection event reclaims its memory, leaving `next_out` pointing into freed memory.
We now have a dangling `next_out`, and the next step is to find who writes through it.
We started in workerd’s own code, but `next_out` is zlib’s field, and it is zlib, not workerd, that writes output through it. So the real question is where, inside the zlib library, `next_out` gets written.
The obvious place is an ordinary compression step: `deflate()` (and `inflate()`), the functions that push output through `next_out`. But in workerd that path is only ever reached through `write()`, and `write()` runs `setBuffers` first, resetting `next_out` to a fresh buffer before `deflate()` runs. The stale pointer is overwritten before it is ever used. No good.
What we found instead is `deflateParams`, reached from `handle.params()`, the call that adjusts the compression parameters, like the level (how hard zlib compresses). It touches the same `z_stream` and, crucially, **does not reset** `next_out` first:
“`
// zlib-util.c++ — ZlibContext::setParams(), reached from handle.params() err = deflateParams(&stream, _level, _strategy);
“`
That hands zlib the same `z_stream`, still carrying the stale `next_out` from the last `write()`. And rather than clearing `next_in`/ `next_out`, `deflateParams` flushes whatever output zlib still has buffered _before_ it applies the new settings:
“`
// zlib – deflate.c, deflateParams() (trimmed) func = configuration_table[s->level].func; if ((strategy != s->strategy || func != configuration_table[level].func) && /* there is data still pending */) { /* flush the last buffer */ deflate(strm, Z_BLOCK); // flush pending output through strm->next_out } s->level = level; // new config applied only after the flush s->strategy = strategy;
“`
If the level or strategy changes and data is still pending, zlib calls `deflate()` to flush it **before** updating the config, and that `deflate()` writes through `strm->next_out`, the dangling pointer.
But there is still a problem. When we called `write()`, zlib already compressed the data we handed it, so how are we supposed to have any bytes still pending for `deflateParams` to flush?
Each zlib `write` takes a _flush mode_ controlling how eagerly output is emitted. Passing `Z_NO_FLUSH` tells zlib to hold compressed output in its internal buffer rather than push it all out through `next_out`, so the `write()` returns with data still pending. That pending data is exactly what `deflateParams` flushes.
The whole use-after-free is a handful of JavaScript calls. Tracking `outBuf`’s refcount and `next_out` across the full cycle, the same way we did on the JavaScript side:
Figure 9 – The zlib use-after-free
`AttributesIteratorUAF`
**HTMLRewriter** is a Workers API for transforming HTML as it streams through. A Worker can rewrite tags, attributes, and text on the fly without buffering the whole document. workerd exposes it on top of **lol-html**, Cloudflare’s Rust streaming HTML rewriter, through a layer of C++ bindings.
The bug is in those bindings, not in lol-html. When you ask an element for an attributes iterator, the C++ binding grabs a **raw pointer into the element’s internal attribute array** and reads through it on each `next()`. Adding attributes with `setAttribute` grows that array, and once it outgrows its capacity the array **reallocates to a new location and the old one is freed**, but the iterator is still pointing at the old, now-freed array. The next `next()` reads from that freed memory:
“`
new HTMLRewriter().on(‘div’, { element(el) { const iter = el.attributes[Symbol.iterator](); // pointer into backing array iter.next(); // reads backing array for (let i = 0; i < 10000; i++) // grow attributes… el.setAttribute(`x${i}`, ‘A’.repeat(100)); // …until it reallocates const leaked = iter.next().value; // iter → freed array: UAF } });
“`
The other four bugs are memory-corruption. This one is a classic that leads to arbitrary deserialization.
Workers are stateless. Each request runs in a fresh, short-lived context, and nothing held in memory survives to the next one. **Durable Objects** are Cloudflare’s answer to that: a Durable Object is a single, uniquely-addressable instance that _stays alive_ and keeps its state across requests, both in memory and in private, strongly-consistent storage. It’s how you hold persistent, coordinated state on the edge: a chat room, a live document, a counter.
That storage has a newer **SQLite** backend, and a Worker can reach the same database in two ways:
`storage.get/` `put), which stores each value serialized with the` `storage.sql.exec), which runs raw SQL against the same database.`
The key/value data lives in a reserved SQLite table, `_cf_KV`, and reading a value back **deserializes** its bytes with V8’s structured-clone deserializer, including workerd’s handlers for internal types.
A SQL _authorizer_ guards those internal tables. It rejects any query that touches a `_cf_`-prefixed table: `CREATE`, `SELECT`, `INSERT`, `UPDATE`, `DROP`, all of it. But we found one operation it forgot to check.
The authorizer validates the tables a query _references_, but not the _destination name_ of a rename. So while every direct query against `_cf_KV` is rejected, nothing stops you from creating an ordinary table under an allowed name and then renaming it with `ALTER TABLE … RENAME TO _cf_KV`. You build the table under a name the authorizer permits, fill it with crafted bytes, and rename it into place:
“`
CREATE TABLE kv_tmp (key TEXT, value BLOB); — allowed INSERT INTO kv_tmp VALUES (‘k’, ); — crafted payload ALTER TABLE kv_tmp RENAME TO _cf_KV; — not checked → now KV
“`
A later key/value read ( `storage.get(‘k’)`) then feeds those attacker-controlled bytes straight into workerd’s internal deserializers, exactly the untrusted input they were never meant to handle.
We didn’t continue from here. The point is the **attack surface**. A malicious Worker can control the bytes fed to **V8’s deserializer**, which will deserialize any object it supports, including workerd’s own internal types. And while we stopped there, the surface is worth stressing: that deserializer was built for trusted, in-process data, and unlike V8’s parser and JIT, it isn’t fuzzed for hostile input. That makes it a very strong attack surface, and a well-worn path to type confusion and memory corruption.
Cloudflare Workers run the same `workerd` and the same many-tenants-one-process model from §2. Different customers’ Workers run as separate V8 isolates inside one OS process, sharing one address space and one native (tcmalloc) heap. The isolate is the only wall between them, and that wall is in V8, not on the native heap.
Figure 10 – Cross-tenant OOB read
So the URLPattern read from §7 isn’t just a crash, it’s a way for a Worker you deploy to read another tenant’s memory out of that shared heap. Here is how that out-of-bounds read becomes a private key read from a different Worker. Everything below **operates on the tcmalloc heap**, outside the cage and the memory-protection keys (§5).
Recall the primitive from §7. The read goes one entry past the end of `nameList`, treats those 24 bytes as a `kj::String { ptr, size, disposer }`, and returns the bytes at `ptr`. So if we control whatever sits right after `nameList`, we control that fake `kj::String`, and reading one attacker-chosen `kj::String` is reading any address we point it at:
Figure 11 – Fake kj::String read primitive
That is the basic primitive. What we actually want is to sweep another tenant’s memory for secrets, to read anywhere in the process, and to do it with as little heap spraying as possible. To get there we need three things:
`ptrof the fake` `kj::String. So we can read the bytes at any address we choose.`
One lever first, because it makes the rest easier. `nameList`’s size is ours to choose. Its length is just the number of capture groups the pattern declares, so padding the pattern with extra groups grows the `kj::Vector` to whatever size we want. tcmalloc places allocations by size class, so choosing `nameList`’s size chooses the neighborhood it lands in, and picking the size class is what makes landing our own allocations right next to it reliable.
A read is only useful once we know _where_ to aim it, and ASLR hides that. To beat it we just need to leak any one real heap address. The out-of-bounds read already returns whatever the fake `kj::String`’s `ptr` points at, so if we arrange for `ptr` to point at a location that itself holds a heap pointer, the read hands that pointer’s bytes back to us as a string:
Figure 12 – Leaking a heap pointer
So we need an object right after `nameList` with two things:
`ptr` `size`
We didn’t find a real object whose layout already satisfies both, so as a last resort we turned to the **tcmalloc free list**, and it has two properties that fit perfectly:
`nextpointer (to the next free chunk), which is requirement #1.` `sizewe wrote there earlier stays put. That is requirement #2.`
So what we can do is allocate a chunk right after `nameList`, write `size = 8` into its bytes 8–15, and free it. The free turns its first 8 bytes into a `next` pointer to the next free chunk, while our `size = 8` survives:
Figure 13 – Freelist next-pointer overwrite
The read hands back that heap pointer as bytes. Since tcmalloc aligns its heap to a 1 GB boundary, one leaked pointer gives us the heap base.
ASLR gives us _an_ address. Now we want to read _many,_ to sweep the heap. The problem is doing that without re-shaping every time. If reading a new address meant a fresh allocation, we’d have to land it next to `nameList` again on each read. What we need instead is an allocation we can keep in place and **change in-place**, so we just rewrite the target pointer and read again.
The best fit we found is a workerd API called **VFS**, a virtual (memory-only) filesystem. A VFS file’s contents are a native `kj::heapArray` on the tcmalloc heap, and crucially we can overwrite those contents at will without reallocating. It also lets us pick the file’s size, so we match `nameList`’s size class and a sprayed file lands right after it.
The idea is to shape the heap once so a VFS file lands right after `nameList`, then read any address by rewriting that file’s bytes in place and calling `exec()` again, with no re-shaping per read:
Figure 14 – Repeatable read via VFS
(This works because `nameList` is allocated when the URLPattern is **constructed**, but the out-of-bounds read only fires later on `exec()`, so the shaped layout persists across reads.)
From here it’s just a sweep. We walk the heap with the repeatable read and look for bytes that look like a secret, in the PoC, `Bearer sk…`-style API tokens, until we find one belonging to a co-located Worker.
The second demo stays inside Code Mode and goes all the way to native code on the host, starting from the zlib use-after-free of §8.
Recall what §8 gives us, broken into the pieces we’ll build on:
`params()flushes, zlib writes through the stale` `next_outinto the output buffer,`
Our primitive, then:
Figure 15 – Reusing the freed buffer
And the write isn’t clean. The first 5 bytes of every flush are compression metadata.
Two improvements make it precise:
**1. The offset of the write.** workerd’s `write()` lets us choose _where in the output buffer_ zlib starts writing. Alongside the buffer it takes an **output offset**, and zlib sets `next_out = buffer + offset`, so the write lands at `freed + offset`, a precise spot inside the reused object instead of always at its start.
**2. The size of the write.** We also keep the flush small, down to a single 8-byte field, so the write overwrites exactly the field we’re aiming at, rather than splattering the whole object around it.
Together that turns a blunt write at the top of the buffer into a small write landing exactly on a field we pick:
Figure 16 – Flush at chosen offset
You might still be wondering how an _imprecise_ write is exploitable at all. We control where it lands, but not the bytes. The trick with this kind of primitive is to stop caring about the bytes. Instead of writing a value, you find a **“strong” object** and overwrite its **size / length field**. You don’t need the exact bytes, you just need to make that length _bigger_. A bloated length turns the object’s own bounded read/write into an **out-of-bounds** read/write, and that you can build on.
The strong object we use is, again, a **VFS file**, but this time we corrupt the file’s **metadata** (the `FileImpl` object that tracks where the file’s data lives and how long it is), not the file’s contents:
Figure 17 – FileImpl metadata layout
With a `FileImpl` in the freed slot, we aim the UAF write at offset `0x20` so it lands on `data.size` and inflates the length.
Why does a bigger `data.size` matter? The file’s data lives at `data.ptr`, and `data.size` is the length workerd treats as its bounds, any read or write through the file API is allowed as long as it stays within `[0, data.size)` of `data.ptr`. Normally `data.size` matches the real buffer, so the file stays in bounds. After we inflate it, that bound now covers the real buffer _and_ whatever heap follows it, so a file read or write past the real buffer still passes workerd’s bounds check and is carried out normally, even though it now reaches into adjacent memory:
Figure 18 – Inflating data.size out-of-bounds
And the file API makes that precise. Node’s `fs` read/write take a **position** argument (the file offset to read or write at, passed straight to the call, no separate seek), plus a length, so we can land exactly on any spot at `data.ptr + position`. To read 8 bytes from an out-of-bounds offset:
Figure 19 – OOB read via readSync
And to write 8 bytes at an out-of-bounds offset. Here the bytes _are_ ours, it’s an ordinary file write:
Figure 20 – OOB write via writeSync
So one inflated length turns the VFS file into an out-of-bounds read _and_ write at any offset across the heap.
OOB across adjacent heap is strong, but it only reaches _forward_ from one buffer and the exact distances depend on the layout. We upgrade it to a clean, anywhere-in-the-process read/write with a second `FileImpl`.
The idea is to use the OOB **write** from the inflated file to reach a **second** `FileImpl` sitting further along the heap, and overwrite _its_ `data.ptr` with any address we want. That second file’s metadata now says “your contents live at `
`”, so an ordinary read or write of the second file reads or writes **that address**:Figure 21 – Arbitrary read/write primitive
And it’s **repeatable.** To hit a new address we just rewrite the second file’s `data.ptr` through the first file again and read/write once more, with no re-triggering the bug. That gives us a stable arbitrary 64-bit read _and_ write across the whole process, the same shape of primitive we built for the cross-tenant read in §11.
On the self-hosted build the V8 sandbox is **off**, which makes the finish almost trivial. Normally turning a memory read/write into code execution means defeating W^X with a ROP chain and chasing per-version gadget offsets. Here we don’t have to. With the sandbox off, workerd reserves V8’s code region as a **256 MB read-write-execute (RWX) mapping at a fixed address,** `0xaaaaf0000000`, present from process startup, no leak required. So we skip ROP entirely.
The finish is simple. Use the arbitrary write to drop ARM64 shellcode (a reverse shell) into that RWX region, then redirect a function pointer to it. The pointer we hijack belongs to the zlib stream itself, the native **write callback** that `handle.write()` invokes (reached through the `z_stream`, which we locate via its `avail_in` field). We overwrite that callback’s target with our shellcode address and then call `handle.write()` once more. Instead of running zlib’s write path, control jumps to the shellcode, native code in the host process, out of the V8 isolate entirely.
**Cage-off caveat.** This chain was built against a **self-hosted** `workerd` **compiled with the V8 sandbox off**, which lets `ArrayBuffer` backing stores and native C++ objects share one heap, exactly what the `FileImpl` overlap relies on (and how Code Mode runs, §5). The underlying UAF is independent of the cage, but with the cage on this specific `FileImpl` technique would not work as-is. Reaching RCE there would need a different post-UAF path.
`kjcontainers live`
All five vulnerabilities were reported to Cloudflare through HackerOne under coordinated disclosure.
Cloudflare’s responses and confirmations:
`urlpattern_original) does`
