{"slug": "when-agentic-glue-melts-exploiting-cloudflare-code-mode-and-workers", "title": "When Agentic Glue Melts: Exploiting Cloudflare Code Mode and Workers", "summary": "Check Point Research found five memory-corruption bugs in Cloudflare's workerd runtime, which underpins both Cloudflare Workers and Cloudflare Code Mode, and exploited them to break out of the sandbox and run arbitrary code. The attacks, named URLPattern and node:zlib, allow one Worker to reach across the shared process heap and escape the V8 isolate boundary, respectively. Cloudflare has not yet commented on the findings.", "body_md": "*By Yarden Porat, Check Point Research*\n\nWe 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.\n\nWe 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:\n\n`URLPattern`\n\nlets one Worker reach across the shared process heap and `node:zlib`\n\nbreaks 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.\n\nIn the traditional MCP / tool-calling loop, the model emits one `{tool, args}`\n\ncall, 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.\n\nCloudflare’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]\n\nFigure 1 – Tool calling vs. Code Mode\n\nThat code has to run somewhere, and that “somewhere” is **workerd**, the runtime behind Cloudflare Workers.\n\nTo 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.\n\nThat 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.\n\nCloudflare’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.\n\nThe 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.\n\nFigure 2 – Many tenants, one process\n\n**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.\n\nCode 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.\n\nRunning untrusted tenant code in-process is its day job, and it lets Code Mode lock the rest down: no filesystem, no arbitrary network (`fetch()`\n\nand `connect()`\n\nsimply 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.\n\nWhen 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.\n\nFive reasons made us decide to do it anyway:\n\nV8 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.\n\n**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.\n\n**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.\n\n**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.\n\n**Node.** Real-world JavaScript assumes Node.js exists, and code constantly reaches for `node:*`\n\nmodules, 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')`\n\n).\n\nIt also means more native objects allocated on the **tcmalloc** **heap**, which is secured by neither the cage nor the memory protection keys.\n\nPutting 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.\n\nBy 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).\n\nBuilding on those bugs, we developed **two end-to-end exploits**, covered in the Exploits section (Part III).\n\nBut 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.\n\n**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\" })`\n\n, call `.exec()`\n\non a URL, and read back the named capture groups (`{ id: \"…\" }`\n\n). workerd exposes it to Workers, and in our setting the pattern itself is attacker-controlled.\n\nworkerd actually ships **two** URLPattern implementations. The first is the original, workerd-native one (the `urlpattern_original`\n\ncompatibility 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**.\n\nUnder 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**.\n\nA quick example of the benign case:\n\nFigure 3 – URLPattern: pattern → result\n\nURLPattern also lets you drop raw regex straight into a pattern, with named or unnamed groups. For example, `/(\\d+)/(?<slug>[a-z]+)`\n\nhas one unnamed group and one named group:\n\nFigure 4 – URLPattern with named group\n\nHere is the implementation. When you call `.exec()`\n\n, workerd runs the compiled regex against the URL and builds the `groups`\n\nobject from the result. The original, workerd-native version does it like this:\n\n```\n// urlpattern.c++: building the groups object from a regex match\nKJ_IF_SOME(array, regex.getHandle(js)(js, input)) {  // run regex vs URL\n  uint32_t index = 1;                                // [0] is full match, skip\n  uint32_t length = array.size();                    // 1 + capture count values\n  kj::Vector<Groups::Field> fields(length - 1);\n\n  while (index < length) {                           // each capture value\n    auto value = array.get(js, index);\n    fields.add(Groups::Field{\n      .name = kj::str(nameList[index - 1]),           // name by position\n      .value = value.isUndefined() ? kj::String() : kj::str(value),\n    });\n    index++;\n  }\n  // ...\n}\n```\n\nFor each capture group, the loop builds one `{ name, value }`\n\nfield. The value is what the regex matched in the URL. The name is the group’s name (like `id`\n\nfrom earlier), taken from the `nameList`\n\nvector.\n\nThe two sides of that pairing come from completely different places, and that is the part to hold onto:\n\n`length`\n\ncomes from `nameList`\n\ncomes from Figure 5 – The group-count mismatch\n\nThe loop lines them up position by position, on the assumption that the two counts agree.\n\nSo the whole thing rests on those two counts staying equal, and they don’t always. When `URLPattern`\n\nparses the pattern to build `nameList`\n\n, 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))`\n\n, gives V8 two capture groups where URLPattern counted only one, and `length`\n\nends up larger than `nameList`\n\n:\n\n``` js\nconst pattern = new URLPattern({ pathname: \"/(ab(cde))\" });\npattern.exec({ pathname: \"/abcde\" });   // V8: 2 groups, nameList: 1 name → OOB\n```\n\nNow the loop runs one step too far. For that extra value, `index - 1`\n\npoints past the end of `nameList`\n\n, and `kj::str(nameList[index - 1])`\n\nreads from beyond the vector, an out-of-bounds read. That is the bug.\n\n`nameList`\n\nis a `kj::Vector<kj::String>`\n\n. A `kj::String`\n\nis 24 bytes:\n\nFigure 6 – kj::String memory layout\n\nThe OOB index makes `kj::str()`\n\nread 24 bytes of **whatever follows the vector** and treat it as a `kj::String`\n\n, then **dereference** `ptr`\n\nto copy out the “string.” So if we control the memory after `nameList`\n\n, we control `ptr`\n\n, and the returned JS string is the bytes at an **address of our choosing**. OOB read → arbitrary read.\n\n`urlpattern_original`\n\non self-hosted workerd. That exact path did not reproduce on production, because production has a check the open-source build lacked.`deflateParams()`\n\nUAF**zlib** is the most common compression library around. Node.js ships it as the built-in `node:zlib`\n\nmodule, 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`\n\nflag (compatibility date 2024-09-23 or later).\n\nLet’s look at a basic use of zlib. You call `write()`\n\nwith an input buffer and an output buffer, and zlib compresses the input into the output.\n\n``` js\nconst input  = Buffer.from(\"hello world\");\nconst output = Buffer.alloc(64);\nhandle.write(input, output);   // compress input → output\n```\n\nThose three lines already span three distinct layers:\n\nThe buffer to watch is `output`\n\n. 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.\n\nOn the JavaScript side, `output`\n\nis **reference-counted**: it stays alive as long as at least one reference points at it. Follow that count through a single `write()`\n\n:\n\n`const output = Buffer.alloc(64)`\n\n. The JS variable holds it: `handle.write(input, output, …)`\n\n. As the buffer crosses into native code, workerd takes a reference of its own for the duration of the call: `write()`\n\nreturns, and workerd drops its reference again: `output`\n\nanymore (it goes out of scope, or is reassigned), so the last reference is gone: Figure 7 – output refcount lifecycle\n\nNow follow the same buffer into the native side. To hand `output`\n\nto zlib, workerd fills in a `z_stream`\n\n(zlib’s state struct), copying the buffer’s raw address into its `next_out`\n\nfield, the pointer zlib writes its compressed output through. That copy happens in `setBuffers`\n\n, on every `write()`\n\n:\n\n```\n// zlib-util.c++\nvoid ZlibContext::setBuffers(kj::ArrayPtr<kj::byte> input, kj::ArrayPtr<kj::byte> output) {\n  stream.avail_in  = input.size();\n  stream.next_in   = input.begin();    // raw pointer into the JS input buffer\n  stream.avail_out = output.size();\n  stream.next_out  = output.begin();   // raw pointer into the JS output buffer\n}\n```\n\nAnd `write()`\n\nforgets to clear them. When it returns, it resets nothing in the `z_stream`\n\n. `next_out`\n\nstill holds the raw address of `output`\n\n. Clearing it is workerd’s job, and the write path simply doesn’t.\n\nThe same sequence, now with `stream.next_out`\n\nshown alongside:\n\nFigure 8 – next_out left dangling\n\nNothing ever clears `next_out`\n\nafter `setBuffers`\n\nsets it. So once `output`\n\n’s refcount reaches 0, the buffer becomes garbage, and the next garbage-collection event reclaims its memory, leaving `next_out`\n\npointing into freed memory.\n\nWe now have a dangling `next_out`\n\n, and the next step is to find who writes through it.\n\nWe started in workerd’s own code, but `next_out`\n\nis 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`\n\ngets written.\n\nThe obvious place is an ordinary compression step: `deflate()`\n\n(and `inflate()`\n\n), the functions that push output through `next_out`\n\n. But in workerd that path is only ever reached through `write()`\n\n, and `write()`\n\nruns `setBuffers`\n\nfirst, resetting `next_out`\n\nto a fresh buffer before `deflate()`\n\nruns. The stale pointer is overwritten before it is ever used. No good.\n\nWhat we found instead is `deflateParams`\n\n, reached from `handle.params()`\n\n, the call that adjusts the compression parameters, like the level (how hard zlib compresses). It touches the same `z_stream`\n\nand, crucially, **does not reset** `next_out`\n\nfirst:\n\n```\n// zlib-util.c++ — ZlibContext::setParams(), reached from handle.params()\nerr = deflateParams(&stream, _level, _strategy);\n```\n\nThat hands zlib the same `z_stream`\n\n, still carrying the stale `next_out`\n\nfrom the last `write()`\n\n. And rather than clearing `next_in`\n\n/`next_out`\n\n, `deflateParams`\n\nflushes whatever output zlib still has buffered *before* it applies the new settings:\n\n``` php\n// zlib - deflate.c, deflateParams() (trimmed)\nfunc = configuration_table[s->level].func;\nif ((strategy != s->strategy || func != configuration_table[level].func)\n        && /* there is data still pending */) {\n    /* flush the last buffer */\n    deflate(strm, Z_BLOCK);   // flush pending output through strm->next_out\n}\ns->level    = level;          // new config applied only after the flush\ns->strategy = strategy;\n```\n\nIf the level or strategy changes and data is still pending, zlib calls `deflate()`\n\nto flush it **before** updating the config, and that `deflate()`\n\nwrites through `strm->next_out`\n\n, the dangling pointer.\n\nBut there is still a problem. When we called `write()`\n\n, zlib already compressed the data we handed it, so how are we supposed to have any bytes still pending for `deflateParams`\n\nto flush?\n\nEach zlib `write`\n\ntakes a *flush mode* controlling how eagerly output is emitted. Passing `Z_NO_FLUSH`\n\ntells zlib to hold compressed output in its internal buffer rather than push it all out through `next_out`\n\n, so the `write()`\n\nreturns with data still pending. That pending data is exactly what `deflateParams`\n\nflushes.\n\nThe whole use-after-free is a handful of JavaScript calls. Tracking `outBuf`\n\n’s refcount and `next_out`\n\nacross the full cycle, the same way we did on the JavaScript side:\n\nFigure 9 – The zlib use-after-free\n\n`AttributesIterator`\n\nUAF**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.\n\nThe 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()`\n\n. Adding attributes with `setAttribute`\n\ngrows 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()`\n\nreads from that freed memory:\n\n``` js\nnew HTMLRewriter().on('div', {\n  element(el) {\n    const iter = el.attributes[Symbol.iterator](); // pointer into backing array\n    iter.next();                                   // reads backing array\n    for (let i = 0; i < 10000; i++)                // grow attributes...\n      el.setAttribute(`x${i}`, 'A'.repeat(100));   // ...until it reallocates\n\n    const leaked = iter.next().value;              // iter → freed array: UAF\n  }\n});\n```\n\nThe other four bugs are memory-corruption. This one is a classic that leads to arbitrary deserialization.\n\nWorkers 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.\n\nThat storage has a newer **SQLite** backend, and a Worker can reach the same database in two ways:\n\n`storage.get`\n\n/ `put`\n\n), which stores each value serialized with the `storage.sql.exec`\n\n), which runs raw SQL against the same database.The key/value data lives in a reserved SQLite table, `_cf_KV`\n\n, and reading a value back **deserializes** its bytes with V8’s structured-clone deserializer, including workerd’s handlers for internal types.\n\nA SQL *authorizer* guards those internal tables. It rejects any query that touches a `_cf_`\n\n-prefixed table: `CREATE`\n\n, `SELECT`\n\n, `INSERT`\n\n, `UPDATE`\n\n, `DROP`\n\n, all of it. But we found one operation it forgot to check.\n\nThe authorizer validates the tables a query *references*, but not the *destination name* of a rename. So while every direct query against `_cf_KV`\n\nis rejected, nothing stops you from creating an ordinary table under an allowed name and then renaming it with `ALTER TABLE … RENAME TO _cf_KV`\n\n. You build the table under a name the authorizer permits, fill it with crafted bytes, and rename it into place:\n\n```\nCREATE TABLE kv_tmp (key TEXT, value BLOB);          -- allowed\nINSERT INTO kv_tmp VALUES ('k', <attacker bytes>);   -- crafted payload\nALTER TABLE kv_tmp RENAME TO _cf_KV;                 -- not checked → now KV\n```\n\nA later key/value read (`storage.get('k')`\n\n) then feeds those attacker-controlled bytes straight into workerd’s internal deserializers, exactly the untrusted input they were never meant to handle.\n\nWe 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.\n\nCloudflare Workers run the same `workerd`\n\nand 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.\n\nFigure 10 – Cross-tenant OOB read\n\nSo 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).\n\nRecall the primitive from §7. The read goes one entry past the end of `nameList`\n\n, treats those 24 bytes as a `kj::String { ptr, size, disposer }`\n\n, and returns the bytes at `ptr`\n\n. So if we control whatever sits right after `nameList`\n\n, we control that fake `kj::String`\n\n, and reading one attacker-chosen `kj::String`\n\nis reading any address we point it at:\n\nFigure 11 – Fake kj::String read primitive\n\nThat 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:\n\n`ptr`\n\nof the fake `kj::String`\n\n. So we can read the bytes at any address we choose.One lever first, because it makes the rest easier. `nameList`\n\n’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<kj::String>`\n\nto whatever size we want. tcmalloc places allocations by size class, so choosing `nameList`\n\n’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.\n\nA 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`\n\n’s `ptr`\n\npoints at, so if we arrange for `ptr`\n\nto point at a location that itself holds a heap pointer, the read hands that pointer’s bytes back to us as a string:\n\nFigure 12 – Leaking a heap pointer\n\nSo we need an object right after `nameList`\n\nwith two things:\n\n`ptr`\n\n`size`\n\nWe 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:\n\n`next`\n\npointer (to the next free chunk), which is requirement #1.`size`\n\nwe wrote there earlier stays put. That is requirement #2.So what we can do is allocate a chunk right after `nameList`\n\n, write `size = 8`\n\ninto its bytes 8–15, and free it. The free turns its first 8 bytes into a `next`\n\npointer to the next free chunk, while our `size = 8`\n\nsurvives:\n\nFigure 13 – Freelist next-pointer overwrite\n\nThe 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.\n\nASLR 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`\n\nagain 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.\n\nThe 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`\n\non 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`\n\n’s size class and a sprayed file lands right after it.\n\nThe idea is to shape the heap once so a VFS file lands right after `nameList`\n\n, then read any address by rewriting that file’s bytes in place and calling `exec()`\n\nagain, with no re-shaping per read:\n\nFigure 14 – Repeatable read via VFS\n\n(This works because `nameList`\n\nis allocated when the URLPattern is **constructed**, but the out-of-bounds read only fires later on `exec()`\n\n, so the shaped layout persists across reads.)\n\nFrom 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…`\n\n-style API tokens, until we find one belonging to a co-located Worker.\n\nThe 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.\n\nRecall what §8 gives us, broken into the pieces we’ll build on:\n\n`params()`\n\nflushes, zlib writes through the stale `next_out`\n\ninto the output buffer, Our primitive, then:\n\nFigure 15 – Reusing the freed buffer\n\nAnd the write isn’t clean. The first 5 bytes of every flush are compression metadata.\n\nTwo improvements make it precise:\n\n**1. The offset of the write.** workerd’s `write()`\n\nlets 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`\n\n, so the write lands at `freed + offset`\n\n, a precise spot inside the reused object instead of always at its start.\n\n**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.\n\nTogether that turns a blunt write at the top of the buffer into a small write landing exactly on a field we pick:\n\nFigure 16 – Flush at chosen offset\n\nYou 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.\n\nThe strong object we use is, again, a **VFS file**, but this time we corrupt the file’s **metadata** (the `FileImpl`\n\nobject that tracks where the file’s data lives and how long it is), not the file’s contents:\n\nFigure 17 – FileImpl metadata layout\n\nWith a `FileImpl`\n\nin the freed slot, we aim the UAF write at offset `0x20`\n\nso it lands on `data.size`\n\nand inflates the length.\n\nWhy does a bigger `data.size`\n\nmatter? The file’s data lives at `data.ptr`\n\n, and `data.size`\n\nis 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)`\n\nof `data.ptr`\n\n. Normally `data.size`\n\nmatches 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:\n\nFigure 18 – Inflating data.size out-of-bounds\n\nAnd the file API makes that precise. Node’s `fs`\n\nread/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`\n\n. To read 8 bytes from an out-of-bounds offset:\n\nFigure 19 – OOB read via readSync\n\nAnd to write 8 bytes at an out-of-bounds offset. Here the bytes *are* ours, it’s an ordinary file write:\n\nFigure 20 – OOB write via writeSync\n\nSo one inflated length turns the VFS file into an out-of-bounds read *and* write at any offset across the heap.\n\nOOB 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`\n\n.\n\nThe idea is to use the OOB **write** from the inflated file to reach a **second** `FileImpl`\n\nsitting further along the heap, and overwrite *its* `data.ptr`\n\nwith any address we want. That second file’s metadata now says “your contents live at `<address>`\n\n”, so an ordinary read or write of the second file reads or writes **that address**:\n\nFigure 21 – Arbitrary read/write primitive\n\nAnd it’s **repeatable.** To hit a new address we just rewrite the second file’s `data.ptr`\n\nthrough 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.\n\nOn 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`\n\n, present from process startup, no leak required. So we skip ROP entirely.\n\nThe 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()`\n\ninvokes (reached through the `z_stream`\n\n, which we locate via its `avail_in`\n\nfield). We overwrite that callback’s target with our shellcode address and then call `handle.write()`\n\nonce 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.\n\n**Cage-off caveat.** This chain was built against a **self-hosted** `workerd`\n\n**compiled with the V8 sandbox off**, which lets `ArrayBuffer`\n\nbacking stores and native C++ objects share one heap, exactly what the `FileImpl`\n\noverlap relies on (and how Code Mode runs, §5). The underlying UAF is independent of the cage, but with the cage on this specific `FileImpl`\n\ntechnique would not work as-is. Reaching RCE there would need a different post-UAF path.\n\n`kj`\n\ncontainers live All five vulnerabilities were reported to Cloudflare through HackerOne under coordinated disclosure.\n\n| Date | Event |\n|---|---|\n| February 1, 2026 | 4 of the 5 vulnerabilities reported via HackerOne (zlib UAF, HTMLRewriter UAF, both URLPattern OOB reads) |\n| March 11, 2026 | Cloudflare rated two of them Critical (zlib UAF, HTMLRewriter UAF) |\n| March 12, 2026 | The 5th, the KV SQL-bypass → deserialization, reported |\n| Aug 5–6, 2026 | Public reveal at Black Hat USA 2026 (Mandalay Bay) |\n\nCloudflare’s responses and confirmations:\n\n`urlpattern_original`\n\n) does", "url": "https://wpnews.pro/news/when-agentic-glue-melts-exploiting-cloudflare-code-mode-and-workers", "canonical_source": "https://research.checkpoint.com/2026/when-agentic-glue-melts/", "published_at": "2026-08-10 12:59:43+00:00", "updated_at": "2026-08-10 13:13:07.345860+00:00", "lang": "en", "topics": ["ai-safety", "ai-infrastructure", "ai-tools"], "entities": ["Check Point Research", "Cloudflare", "Cloudflare Code Mode", "Cloudflare Workers", "workerd", "V8", "URLPattern", "node:zlib"], "alternates": {"html": "https://wpnews.pro/news/when-agentic-glue-melts-exploiting-cloudflare-code-mode-and-workers", "markdown": "https://wpnews.pro/news/when-agentic-glue-melts-exploiting-cloudflare-code-mode-and-workers.md", "text": "https://wpnews.pro/news/when-agentic-glue-melts-exploiting-cloudflare-code-mode-and-workers.txt", "jsonld": "https://wpnews.pro/news/when-agentic-glue-melts-exploiting-cloudflare-code-mode-and-workers.jsonld"}}