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:
URLPattern
lets one Worker reach across the shared process heap and swipe another tenant’s secrets.node:zlib
breaks out of the sandbox and runs native code on the host.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:
length
comes from V8. It’s the size of the match array V8 returns after running the compiled regex, i.e. how many capture groups the regex actually produced.nameList
comes from URLPattern’s own implementation. It’s the list of names workerd assembled while parsing the pattern, before the regex ever ran.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_original
on self-hosted workerd. That exact path did not reproduce on production, because production has a check the open-source build lacked.deflateParams()
UAFzlib 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: refcount 1.handle.write(input, output, …)
. As the buffer crosses into native code, workerd takes a reference of its own for the duration of the call: refcount 2. That extra reference is what guarantees the buffer can’t be freed while zlib is mid-compression.write()
returns, and workerd drops its reference again: back to refcount 1, held by the JS variable.output
anymore (it goes out of scope, or is reassigned), so the last reference is gone: refcount 0.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
AttributesIterator
UAFHTMLRewriter 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 structured-clone algorithm, andstorage.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:
ptr
of 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
(first 8 bytes), points at a heap pointer, so dereferencing it leaks a heap address.size
(next 8 bytes), a small, valid length: not zero, not a pointer, just short enough that the read returns a sane string.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:
next
pointer (to the next free chunk), which is requirement #1.size
we 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_out
into the output buffer, after that buffer has been freed and its slot can be reused.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.
kj
containers live outside the cage. If the cage is your isolation story, the things it doesn’t cover are your attack surface.All five vulnerabilities were reported to Cloudflare through HackerOne under coordinated disclosure.
| Date | Event |
|---|---|
| February 1, 2026 | 4 of the 5 vulnerabilities reported via HackerOne (zlib UAF, HTMLRewriter UAF, both URLPattern OOB reads) |
| March 11, 2026 | Cloudflare rated two of them Critical (zlib UAF, HTMLRewriter UAF) |
| March 12, 2026 | The 5th, the KV SQL-bypass → deserialization, reported |
| Aug 5–6, 2026 | Public reveal at Black Hat USA 2026 (Mandalay Bay) |
Cloudflare’s responses and confirmations:
urlpattern_original
) does not trigger there (the Ada-backed standard URLPattern does).
