When Agentic Glue Melts: Exploiting Cloudflare Code Mode and Workers

Wait 5 sec.

By Yarden Porat, Check Point ResearchKey PointsCheck Point Research analyzed Cloudflare Code Mode, a technique that changes how AI agents use MCP by turning tools into a TypeScript API the model can write code against.The research uncovered five vulnerabilities in workerd, the open-source runtime behind Code Mode and Cloudflare Workers. Two were rated Critical by Cloudflare.The blast radius is broad: by Cloudflare’s own numbers, Workers is built by&nbsp;millions of developers,[1] serves&nbsp;millions of requests per second,[2] and carries&nbsp;more than 10% of all traffic on Cloudflare’s network.[3]Because workerd underpins both Code Mode sandboxes and Workers tenant isolation, the findings create sandbox-escape and cross-tenant exposure risk.Cloudflare’s managed Workers environment has been fixed in production. Self-hosted workerd / Code Mode deployments should update to v1.20260619.1.Check Point Research released proof-of-concept code as part of its Black Hat USA 2026 presentation.The short versionWe set out to break&nbsp;Cloudflare Code Mode, and ended up breaking&nbsp;Cloudflare Workers&nbsp;too. We did both by targeting&nbsp;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:Cross-tenant heap swipe.&nbsp;An out-of-bounds read in&nbsp;URLPattern&nbsp;lets one Worker reach across the shared process heap and&nbsp;swipe another tenant’s secrets.Code Mode sandbox escape.&nbsp;Starting from a prompt injection, a use-after-free in&nbsp;node:zlib&nbsp;breaks out of the sandbox and runs&nbsp;native code on the host.Part I &#8211; Understanding the target1. Where this started: Code ModeCode 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&nbsp;typed TypeScript API&nbsp;and lets the model&nbsp;write code&nbsp;that calls them: loops, conditionals, data shuffling and all.In the traditional MCP / tool-calling loop, the model emits one&nbsp;{tool, args}&nbsp;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&nbsp;one program&nbsp;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 &#8211; Tool calling vs. Code ModeThat code has to run somewhere, and that “somewhere” is&nbsp;workerd, the runtime behind Cloudflare Workers.2. The workerd origin storyTo understand workerd, start with the product it was built for:&nbsp;Cloudflare Workers. Workers is Cloudflare’s serverless platform: you upload a piece of code and Cloudflare runs it at the&nbsp;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&nbsp;language-runtime&nbsp;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&nbsp;software boundary&nbsp;inside one shared address space, not a hardware or kernel one. Untrusted code runs&nbsp;in-process, and the whole model rests on the isolate holding.Figure 2 &#8211; Many tenants, one processworkerd&nbsp;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&nbsp;September 2022.[5] It’s exactly what Code Mode runs the model’s generated code on.3. Why workerd was the obvious sandbox for Code ModeCode Mode has to run untrusted, model-written code, and it needs that code to reach the declared MCP tools and&nbsp;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()&nbsp;and&nbsp;connect()&nbsp;simply throw) with the tools exposed only through&nbsp;bindings.[6] Cloudflare didn’t build a new sandbox for Code Mode. It reused the one it already trusts to isolate millions of Workers.4. Why we targeted workerdWhen you set out to break Code Mode, the obvious place to look is the&nbsp;seam between Code Mode and workerd.&nbsp;This is the integration layer: how tools become bindings, how the configuration is wired, how the two interact. Going after the&nbsp;runtime itself&nbsp;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:An in-process sandbox is a bold, inherently risky bet.&nbsp;Isolating untrusted code without an OS-level boundary means no VM, no container, just a V8 isolate inside a shared process. That puts the entire security model on a single software boundary. That kind of ambitious bet is exactly what’s worth stress-testing.workerd had almost no public scrutiny.[7] Despite sitting directly on that boundary, there was barely any prior public vulnerability research on workerd, in stark contrast to V8, which is picked apart continuously.The attack surface is huge. And it’s not just V8. workerd has its own implementation that exposes many Web/Node APIs, each written in C++ and reachable from untrusted JavaScript.The blast radius reaches Cloudflare Workers.&nbsp;workerd isn’t only Code Mode’s runtime. It’s the engine behind Cloudflare Workers, one of the most widely deployed serverless platforms on the internet. A bug here would never have stayed contained to an experimental agent feature.AI security has a low-level side too.&nbsp;Beyond the high-level frameworks, the internal, low-level layers that agents rely on to interact with the world deserve research as well.5. The cage, memory protection keys, and NodeV8 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.Defenses1. The V8 sandbox (“the cage”).&nbsp;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.&nbsp;As a further layer against V8 vulnerabilities, production also tags isolate-group memory with hardware&nbsp;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.&nbsp;Underneath both sits a&nbsp;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&nbsp;layer-2 sandbox&nbsp;(Linux namespaces plus seccomp) that blocks all filesystem and direct network access,[8] limiting what a compromised process can reach on the host.Attack SurfaceNode.&nbsp;Real-world JavaScript assumes Node.js exists, and code constantly reaches for&nbsp;node:*&nbsp;modules, so workerd reimplements a large slice of the Node API in C++. This is exposed to JS through&nbsp;JSG, its “JavaScript Glue” layer. Node was never designed for a threat model where the&nbsp;attacker&nbsp;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&nbsp;require('node:crypto')).It also means more native objects allocated on the&nbsp;tcmalloc&nbsp;heap, which is secured by neither the cage nor the memory protection keys.6. Bottom LinePutting all of the above together, we did exactly that. We targeted workerd&#8217;s&nbsp;JSG code, the &#8220;JavaScript Glue&#8221; that hands native C++ to untrusted JavaScript, whether it is a&nbsp;Node reimplementation&nbsp;or one of&nbsp;workerd&#8217;s own API implementations. It is the code that had a fraction of V8&#8217;s scrutiny (§4), and the native objects it allocates sit on the&nbsp;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&nbsp;five vulnerabilities, all of them in workerd&#8217;s own native code, each covered in the Vulnerabilities section (Part II).Building on those bugs, we developed&nbsp;two end-to-end exploits, covered in the Exploits section (Part III).Code Mode sandbox escape.&nbsp;Starting from a single prompt injection, the model is steered into writing attacker-controlled TypeScript. That TypeScript contains a memory-corruption which leads to native code execution, breaking out of Code Mode and running on the host, fully outside the V8 isolate.Cross-tenant secret leak.&nbsp;Starting from a malicious Worker you deploy into Cloudflare&#8217;s shared pool, we show that one tenant can read another tenant&#8217;s memory and leak its secrets straight out of the shared process. This is the production scenario, and it holds up there because the whole exploit runs from the tcmalloc heap, the memory the cage and MPK do not cover.But to be explicit,&nbsp;we did not run the exploit on Cloudflare production ourselves.&nbsp;Both exploits were verified on the&nbsp;self-hosted&nbsp;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.Part II &#8211; The vulnerabilities7. URLPattern out-of-bounds readURLPattern&nbsp;is a Web API for matching a URL against a pattern, essentially what a router does. You build a pattern such as&nbsp;new URLPattern({ pathname: "/users/:id" }), call&nbsp;.exec()&nbsp;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&nbsp;two&nbsp;URLPattern implementations. The first is the original, workerd-native one (the&nbsp;urlpattern_original&nbsp;compatibility flag). The second is the newer standard one backed by the&nbsp;Ada&nbsp;URL-parser&nbsp;library. We found the&nbsp;same out-of-bounds read&nbsp;in both implementations, and it&nbsp;gives the same primitive.7.1 Root causeUnder the hood, URLPattern turns your pattern into a regular expression. Matching a URL then produces two parallel lists: the&nbsp;matched values&nbsp;(one per capture group in the regex) and the&nbsp;group names.A quick example of the benign case:Figure 3 &#8211; URLPattern: pattern → resultURLPattern also lets you drop raw regex straight into a pattern, with named or unnamed groups. For example,&nbsp;/(\d+)/(?[a-z]+)&nbsp;has one unnamed group and one named group:Figure 4 &#8211; URLPattern with named groupHere is the implementation. When you call&nbsp;.exec(), workerd runs the compiled regex against the URL and builds the&nbsp;groups&nbsp;object from the result. The original, workerd-native version does it like this:// urlpattern.c++: building the groups object from a regex matchKJ_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&nbsp;{ name, value }&nbsp;field. The value is what the regex matched in the URL. The name is the group’s name (like&nbsp;id&nbsp;from earlier), taken from the&nbsp;nameList&nbsp;vector.The two sides of that pairing come from completely different places, and that is the part to hold onto:length&nbsp;comes from&nbsp;V8. It’s the size of the match array V8 returns after running the compiled regex, i.e.&nbsp;how many capture groups the regex actually produced.nameList&nbsp;comes from&nbsp;URLPattern’s own implementation. It’s the list of names workerd assembled while parsing the pattern, before the regex ever ran.Figure 5 &#8211; The group-count mismatchThe 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&nbsp;URLPattern&nbsp;parses the pattern to build&nbsp;nameList, its own group counting&nbsp;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&nbsp;(ab(cde)), gives V8 two capture groups where URLPattern counted only one, and&nbsp;length&nbsp;ends up larger than&nbsp;nameList:const pattern = new URLPattern({ pathname: "/(ab(cde))" });pattern.exec({ pathname: "/abcde" }); // V8: 2 groups, nameList: 1 name → OOBNow the loop runs one step too far. For that extra value,&nbsp;index - 1&nbsp;points past the end of&nbsp;nameList, and&nbsp;kj::str(nameList[index - 1])&nbsp;reads from beyond the vector, an out-of-bounds read. That is the bug.7.2 Why an OOB read is an arbitrary readnameList&nbsp;is a&nbsp;kj::Vector. A&nbsp;kj::String&nbsp;is 24 bytes:Figure 6 &#8211; kj::String memory layoutThe OOB index makes&nbsp;kj::str()&nbsp;read 24 bytes of&nbsp;whatever follows the vector&nbsp;and treat it as a&nbsp;kj::String, then&nbsp;dereference&nbsp;ptr&nbsp;to copy out the “string.” So if we control the memory after&nbsp;nameList, we control&nbsp;ptr, and the returned JS string is the bytes at an&nbsp;address of our choosing. OOB read → arbitrary read.7.3 Two notesThe same bug is in both implementations, and the Ada one reaches production.&nbsp;The standard, Ada-backed URLPattern makes the identical counting mistake, with the same out-of-bounds read. We confirmed the Ada version triggers on&nbsp;Cloudflare production, and reported it to the Ada maintainers in parallel.Our full end-to-end exploit was on the original implementation, self-hosted.&nbsp;Turning the read into a working cross-tenant secret leak was demonstrated against&nbsp;urlpattern_original&nbsp;on self-hosted workerd. That exact path did not reproduce on production, because production has a check the open-source build lacked.8. zlib&nbsp;deflateParams()&nbsp;UAFzlib&nbsp;is the most common compression library around. Node.js ships it as the built-in&nbsp;node:zlib&nbsp;module, and to stay Node-compatible workerd reimplemented it in C++. It exposes a handful of APIs. The basic ones compress and decompress via&nbsp;Gzip,&nbsp;Deflate/Inflate, and&nbsp;Brotli. In workerd it comes with the&nbsp;nodejs_compat&nbsp;flag (compatibility date 2024-09-23 or later).8.1 Dangling buffersLet’s look at a basic use of zlib. You call&nbsp;write()&nbsp;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 → outputThose three lines already span three distinct layers:JavaScript (V8):&nbsp;creates the input and output buffers.workerd’s glue code:&nbsp;the translation layer between JavaScript and native C++, turning those buffers into the raw pointers and lengths the C library expects.zlib:&nbsp;the C compression library that does the actual work.The buffer to watch is&nbsp;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,&nbsp;output&nbsp;is&nbsp;reference-counted: it stays alive as long as at least one reference points at it. Follow that count through a single&nbsp;write():const output = Buffer.alloc(64). The JS variable holds it:&nbsp;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:&nbsp;refcount 2. That extra reference is what guarantees the buffer can’t be freed while zlib is mid-compression.write()&nbsp;returns, and workerd drops its reference again:&nbsp;back to refcount 1, held by the JS variable.nothing holds&nbsp;output&nbsp;anymore (it goes out of scope, or is reassigned), so the last reference is gone:&nbsp;refcount 0.Figure 7 &#8211; output refcount lifecycleNow follow the same buffer into the native side. To hand&nbsp;output&nbsp;to zlib, workerd fills in a&nbsp;z_stream(zlib’s state struct), copying the buffer’s raw address into its&nbsp;next_out&nbsp;field, the pointer zlib writes its compressed output through. That copy happens in&nbsp;setBuffers, on every&nbsp;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&nbsp;write()&nbsp;forgets to clear them. When it returns, it resets nothing in the&nbsp;z_stream.&nbsp;next_out&nbsp;still holds the raw address of&nbsp;output. Clearing it is workerd’s job, and the write path simply doesn’t.The same sequence, now with&nbsp;stream.next_out&nbsp;shown alongside:Figure 8 &#8211; next_out left danglingNothing ever clears&nbsp;next_out&nbsp;after&nbsp;setBuffers&nbsp;sets it. So once&nbsp;output’s refcount reaches 0, the buffer becomes garbage, and the next garbage-collection event reclaims its memory, leaving&nbsp;next_out&nbsp;pointing into freed memory.8.2 The Use in Use-After-FreeWe now have a dangling&nbsp;next_out, and the next step is to find who writes through it.We started in workerd’s own code, but&nbsp;next_out&nbsp;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,&nbsp;next_out&nbsp;gets written.The obvious place is an ordinary compression step:&nbsp;deflate()&nbsp;(and&nbsp;inflate()), the functions that push output through&nbsp;next_out. But in workerd that path is only ever reached through&nbsp;write(), and&nbsp;write()&nbsp;runs&nbsp;setBuffers&nbsp;first, resetting&nbsp;next_out&nbsp;to a fresh buffer before&nbsp;deflate()&nbsp;runs. The stale pointer is overwritten before it is ever used. No good.What we found instead is&nbsp;deflateParams, reached from&nbsp;handle.params(), the call that adjusts the compression parameters, like the level (how hard zlib compresses). It touches the same&nbsp;z_stream&nbsp;and, crucially,&nbsp;does not reset&nbsp;next_out&nbsp;first:// zlib-util.c++ — ZlibContext::setParams(), reached from handle.params()err = deflateParams(&stream, _level, _strategy);That hands zlib the same&nbsp;z_stream, still carrying the stale&nbsp;next_out&nbsp;from the last&nbsp;write(). And rather than clearing&nbsp;next_in/next_out,&nbsp;deflateParams&nbsp;flushes whatever output zlib still has buffered&nbsp;before&nbsp;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 flushs->strategy = strategy;If the level or strategy changes and data is still pending, zlib calls&nbsp;deflate()&nbsp;to flush it&nbsp;before&nbsp;updating the config, and that&nbsp;deflate()&nbsp;writes through&nbsp;strm->next_out, the dangling pointer.But there is still a problem. When we called&nbsp;write(), zlib already compressed the data we handed it, so how are we supposed to have any bytes still pending for&nbsp;deflateParams&nbsp;to flush?8.3 Z_NO_FLUSHEach zlib&nbsp;write&nbsp;takes a&nbsp;flush mode&nbsp;controlling how eagerly output is emitted. Passing&nbsp;Z_NO_FLUSH&nbsp;tells zlib to hold compressed output in its internal buffer rather than push it all out through&nbsp;next_out, so the&nbsp;write()&nbsp;returns with data still pending. That pending data is exactly what&nbsp;deflateParams&nbsp;flushes.8.4 Putting everything togetherThe whole use-after-free is a handful of JavaScript calls. Tracking&nbsp;outBuf’s refcount and&nbsp;next_out&nbsp;across the full cycle, the same way we did on the JavaScript side:Figure 9 &#8211; The zlib use-after-free9. HTMLRewriter&nbsp;AttributesIterator&nbsp;UAFHTMLRewriter&nbsp;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&nbsp;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&nbsp;raw pointer into the element’s internal attribute array&nbsp;and reads through it on each&nbsp;next(). Adding attributes with&nbsp;setAttribute&nbsp;grows that array, and once it outgrows its capacity the array&nbsp;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&nbsp;next()&nbsp;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 }});10. KV SQL bypass → arbitrary deserializationThe other four bugs are memory-corruption. This one is a classic that leads to arbitrary deserialization.10.1 Durable ObjectsWorkers are stateless. Each request runs in a fresh, short-lived context, and nothing held in memory survives to the next one.&nbsp;Durable Objects&nbsp;are Cloudflare’s answer to that: a Durable Object is a single, uniquely-addressable instance that&nbsp;stays alive&nbsp;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&nbsp;SQLite&nbsp;backend, and a Worker can reach the same database in two ways:the&nbsp;key/value API&nbsp;(storage.get&nbsp;/&nbsp;put), which stores each value serialized with the&nbsp;structured-clone algorithm,&nbsp;andthe&nbsp;SQL API&nbsp;(storage.sql.exec), which runs raw SQL against the same database.The key/value data lives in a reserved SQLite table,&nbsp;_cf_KV, and reading a value back&nbsp;deserializes&nbsp;its bytes with V8’s structured-clone deserializer, including workerd’s handlers for internal types.10.2 The authorizer bypassA SQL&nbsp;authorizer&nbsp;guards those internal tables. It rejects any query that touches a&nbsp;_cf_-prefixed table:&nbsp;CREATE,&nbsp;SELECT,&nbsp;INSERT,&nbsp;UPDATE,&nbsp;DROP, all of it. But we found one operation it forgot to check.The authorizer validates the tables a query&nbsp;references, but not the&nbsp;destination name&nbsp;of a rename. So while every direct query against&nbsp;_cf_KV&nbsp;is rejected, nothing stops you from creating an ordinary table under an allowed name and then renaming it with&nbsp;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); -- allowedINSERT INTO kv_tmp VALUES ('k', ); -- crafted payloadALTER TABLE kv_tmp RENAME TO _cf_KV; -- not checked → now KVA 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&nbsp;attack surface. A malicious Worker can control the bytes fed to&nbsp;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.Part III &#8211; The full chain and its impact11. Cross-tenant secret theft (Workers)Cloudflare Workers run the same&nbsp;workerd&nbsp;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 &#8211; Cross-tenant OOB readSo 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&nbsp;operates on the tcmalloc heap, outside the cage and the memory-protection keys (§5).11.1 The strategyRecall the primitive from §7. The read goes one entry past the end of&nbsp;nameList, treats those 24 bytes as a&nbsp;kj::String { ptr, size, disposer }, and returns the bytes at&nbsp;ptr. So if we control whatever sits right after&nbsp;nameList, we control that fake&nbsp;kj::String, and reading one attacker-chosen&nbsp;kj::String&nbsp;is reading any address we point it at:Figure 11 &#8211; Fake kj::String read primitiveThat 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:Break ASLR.&nbsp;Leak a real heap address, so we know&nbsp;where&nbsp;to read.Control the&nbsp;ptr&nbsp;of the fake&nbsp;kj::String. So we can read the bytes at any address we choose.Make it repeatable.&nbsp;Read one address after another without re-shaping the heap each time.11.2 Sizing nameListOne lever first, because it makes the rest easier.&nbsp;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&nbsp;kj::Vector&nbsp;to whatever size we want. tcmalloc places allocations by size class, so choosing&nbsp;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.11.3 Defeating ASLRA read is only useful once we know&nbsp;where&nbsp;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&nbsp;kj::String’s&nbsp;ptr&nbsp;points at, so if we arrange for&nbsp;ptr&nbsp;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 &#8211; Leaking a heap pointerSo we need an object right after&nbsp;nameList&nbsp;with two things:ptr&nbsp;(first 8 bytes), points at a heap pointer, so dereferencing it leaks a heap address.size&nbsp;(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&nbsp;tcmalloc free list, and it has two properties that fit perfectly:The first 8 bytes of a freed chunk are the&nbsp;next&nbsp;pointer (to the next free chunk), which is requirement #1.The rest of the chunk, including bytes 8–15, is left untouched by the free, so a&nbsp;size&nbsp;we wrote there earlier stays put. That is requirement #2.So what we can do is allocate a chunk right after&nbsp;nameList, write&nbsp;size = 8&nbsp;into its bytes 8–15, and free it. The free turns its first 8 bytes into a&nbsp;next&nbsp;pointer to the next free chunk, while our&nbsp;size = 8&nbsp;survives:Figure 13 &#8211; Freelist next-pointer overwriteThe 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.11.4 A repeatable read with VFS filesASLR gives us&nbsp;an&nbsp;address. Now we want to read&nbsp;many,&nbsp;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&nbsp;nameList&nbsp;again on each read. What we need instead is an allocation we can keep in place and&nbsp;change in-place, so we just rewrite the target pointer and read again.The best fit we found is a workerd API called&nbsp;VFS, a virtual (memory-only) filesystem. A VFS file’s contents are a native&nbsp;kj::heapArray&nbsp;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&nbsp;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&nbsp;nameList, then read any address by rewriting that file’s bytes in place and calling&nbsp;exec()&nbsp;again, with no re-shaping per read:Figure 14 &#8211; Repeatable read via VFS(This works because&nbsp;nameList&nbsp;is allocated when the URLPattern is&nbsp;constructed, but the out-of-bounds read only fires later on&nbsp;exec(), so the shaped layout persists across reads.)11.5 Reading another Worker’s secretFrom 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,&nbsp;Bearer sk…-style API tokens, until we find one belonging to a co-located Worker.12. Sandbox escape: from the zlib UAF to host RCEThe 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.12.1 Improving the primitiveRecall what §8 gives us, broken into the pieces we’ll build on:A use-after-free write.&nbsp;When&nbsp;params()&nbsp;flushes, zlib writes through the stale&nbsp;next_out&nbsp;into the output buffer,&nbsp;after&nbsp;that buffer has been freed and its slot can be reused.A controllable allocation size.&nbsp;We choose the size of the output buffer, which decides which freed slot the write targets and what we can spray into it.Our primitive, then:Figure 15 &#8211; Reusing the freed bufferAnd 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.&nbsp;workerd’s&nbsp;write()&nbsp;lets us choose&nbsp;where in the output buffer&nbsp;zlib starts writing. Alongside the buffer it takes an&nbsp;output offset, and zlib sets&nbsp;next_out = buffer + offset, so the write lands at&nbsp;freed + offset, a precise spot inside the reused object instead of always at its start.2. The size of the write.&nbsp;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 &#8211; Flush at chosen offset12.2 From use-after-free to repeatable read/writeYou might still be wondering how an&nbsp;imprecise&nbsp;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&nbsp;“strong” object&nbsp;and overwrite its&nbsp;size / length field. You don’t need the exact bytes, you just need to make that length&nbsp;bigger. A bloated length turns the object’s own bounded read/write into an&nbsp;out-of-bounds&nbsp;read/write, and that you can build on.The strong object we use is, again, a&nbsp;VFS file, but this time we corrupt the file’s&nbsp;metadata&nbsp;(the&nbsp;FileImpl&nbsp;object that tracks where the file’s data lives and how long it is), not the file’s contents:Figure 17 &#8211; FileImpl metadata layoutWith a&nbsp;FileImpl&nbsp;in the freed slot, we aim the UAF write at offset&nbsp;0x20&nbsp;so it lands on&nbsp;data.size&nbsp;and inflates the length.Why does a bigger&nbsp;data.size&nbsp;matter? The file’s data lives at&nbsp;data.ptr, and&nbsp;data.size&nbsp;is the length workerd treats as its bounds, any read or write through the file API is allowed as long as it stays within&nbsp;[0, data.size)&nbsp;of&nbsp;data.ptr. Normally&nbsp;data.size&nbsp;matches the real buffer, so the file stays in bounds. After we inflate it, that bound now covers the real buffer&nbsp;and&nbsp;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 &#8211; Inflating data.size out-of-boundsAnd the file API makes that precise. Node’s&nbsp;fs&nbsp;read/write take a&nbsp;position&nbsp;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&nbsp;data.ptr + position. To read 8 bytes from an out-of-bounds offset:Figure 19 &#8211; OOB read via readSyncAnd to write 8 bytes at an out-of-bounds offset. Here the bytes&nbsp;are&nbsp;ours, it’s an ordinary file write:Figure 20 &#8211; OOB write via writeSyncSo one inflated length turns the VFS file into an out-of-bounds read&nbsp;and&nbsp;write at any offset across the heap.12.3 Arbitrary read/writeOOB across adjacent heap is strong, but it only reaches&nbsp;forward&nbsp;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&nbsp;FileImpl.The idea is to use the OOB&nbsp;write&nbsp;from the inflated file to reach a&nbsp;second&nbsp;FileImpl&nbsp;sitting further along the heap, and overwrite&nbsp;its&nbsp;data.ptr&nbsp;with any address we want. That second file’s metadata now says “your contents live at&nbsp;”, so an ordinary read or write of the second file reads or writes&nbsp;that address:Figure 21 &#8211; Arbitrary read/write primitiveAnd it’s&nbsp;repeatable.&nbsp;To hit a new address we just rewrite the second file’s&nbsp;data.ptr&nbsp;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&nbsp;and&nbsp;write across the whole process, the same shape of primitive we built for the cross-tenant read in §11.12.4 To native codeOn the self-hosted build the V8 sandbox is&nbsp;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&nbsp;256 MB read-write-execute (RWX) mapping at a fixed address,&nbsp;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&nbsp;write callback&nbsp;that&nbsp;handle.write()&nbsp;invokes (reached through the&nbsp;z_stream, which we locate via its&nbsp;avail_in&nbsp;field). We overwrite that callback’s target with our shellcode address and then call&nbsp;handle.write()&nbsp;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.&nbsp;This chain was built against a&nbsp;self-hosted&nbsp;workerd&nbsp;compiled with the V8 sandbox off, which lets&nbsp;ArrayBuffer&nbsp;backing stores and native C++ objects share one heap, exactly what the&nbsp;FileImpl&nbsp;overlap relies on (and how Code Mode runs, §5). The underlying UAF is independent of the cage, but with the cage on this specific&nbsp;FileImpl&nbsp;technique would not work as-is. Reaching RCE there would need a different post-UAF path.Part IV &#8211; Takeaways and disclosure13. Defensive takeawaysThe engine is not the whole boundary.&nbsp;Hardening V8 and shipping the cage is necessary, not sufficient. Every native API reachable from untrusted JS is part of the boundary.Glue layers deserve first-class security review.&nbsp;JSG marshals lifetimes and pointers across the JS/native seam. That’s exactly where UAFs and missing bounds checks live. It had a fraction of V8’s scrutiny.Native allocations need their own threat model.&nbsp;tcmalloc free-list behavior, VFS buffers, and&nbsp;kj&nbsp;containers live&nbsp;outside&nbsp;the cage. If the cage is your isolation story, the things it doesn’t cover are your attack surface.Agent-generated code is normal code.&nbsp;In Code Mode the model writing exploit-shaped TypeScript isn’t an exceptional event, it’s the intended mode of operation. Prompt injection is a code-execution entry point, and should be modeled as one.Disclosure timelineAll five vulnerabilities were reported to Cloudflare through HackerOne under coordinated disclosure.DateEventFebruary 1, 20264 of the 5 vulnerabilities reported via HackerOne (zlib UAF, HTMLRewriter UAF, both URLPattern OOB reads)March 11, 2026Cloudflare rated two of them Critical (zlib UAF, HTMLRewriter UAF)March 12, 2026The 5th, the KV SQL-bypass → deserialization, reportedAug 5–6, 2026Public reveal at Black Hat USA 2026 (Mandalay Bay)Cloudflare’s responses and confirmations:Two rated Critical.&nbsp;Cloudflare rated the&nbsp;zlib use-after-free&nbsp;and the&nbsp;HTMLRewriter use-after-free&nbsp;as Critical.Production reach.&nbsp;Cloudflare confirmed that the bugs reproduce on&nbsp;Cloudflare production, with one exception. The&nbsp;original&nbsp;URLPattern out-of-bounds read (urlpattern_original) does&nbsp;not&nbsp;trigger there (the Ada-backed standard URLPattern does).The cage doesn’t cover the heap we used.&nbsp;Cloudflare confirmed our central claim, that the&nbsp;tcmalloc native heap is outside both the V8 sandbox (cage) and the memory-protection keys.&nbsp;Exactly the memory every primitive in this post operates on.Fix.&nbsp;Cloudflare’s managed Workers were fixed in production, and workerd&nbsp;v1.20260619.1&nbsp;closes all of these bugs for self-hosted deployments. As of now,&nbsp;Cloudflare has not assigned CVEs.LinksCloudflare Q1 2026 earnings call (May 7, 2026), “Developers on Cloudflare’s platform increased to more than 5.5 million…”:&nbsp;https://www.theglobeandmail.com/investing/markets/stocks/NET/pressreleases/1904486/cloudflare-q1-earnings-call-highlights/“go from no traffic at all to millions of requests per second instantly”:&nbsp;https://blog.cloudflare.com/workerd-open-source-workers-runtime/“More than 10% of all requests flowing through our network today use Cloudflare Workers”:&nbsp;https://blog.cloudflare.com/cloudflare-workers-serverless-week/“LLMs are better at writing code to call MCP, than at calling MCP directly” :&nbsp;https://blog.cloudflare.com/code-mode/“workerd is Open Source under the Apache License version 2.0” (post dated 2022-09-27) :&nbsp;https://blog.cloudflare.com/workerd-open-source-workers-runtime/“we prohibit the sandboxed worker from talking to the Internet. The global fetch() and connect() functions throw errors” :&nbsp;https://blog.cloudflare.com/code-mode/only two published security advisories, both Moderate :&nbsp;https://github.com/cloudflare/workerd/security/advisories“The ‘layer 2’ sandbox uses Linux namespaces and seccomp to prohibit all access to the filesystem and network” :&nbsp;https://blog.cloudflare.com/mitigating-spectre-and-other-security-threats-the-cloudflare-workers-security-model/no public link, Cloudflare coordinated-disclosure correspondence. Cloudflare confirmed there are no MPK protection keys on the tcmalloc allocations.The post When Agentic Glue Melts: Exploiting Cloudflare Code Mode and Workers appeared first on Check Point Research.