When Agentic Glue Melts

Exploiting Cloudflare Code Mode & Workers

Yarden Porat·Shahar Tal·Check Point Research Black Hat USA 2026·Mandalay Bay

The engine is not the whole boundary.

The glue around it is part of the boundary too.

Hold that thought — everything after this is evidence.

How this goes

Setup → evidence → proof → verdict

  1. 01 The claimed boundary What Code Mode & Workers promise — the wall we’re told holds
  2. 02 Five bugs in the glue The evidence — where the native seam gives way
  3. 03 Two live demos The proof — we run two of them, on stage
  4. 04 The verdict Takeaways & disclosure — what it means if you ship on this stack
PART I

The Claimed Boundary


what they built, and what they promise it holds

The Claimed Boundary

Code Mode: let the model write the code

Instead of one tool call at a time, the model writes one program.

Classic tool-calling Code Mode model { tool, args } agent result ×N round-trips a fresh model call + network hop per step model workerd isolate const r = await tools.search(q); for () { } return summary; loops run locally final result only
“LLMs are better at writing code to call MCP, than at calling MCP directly.” — Cloudflare, Code Mode · Sept 26 2025

The Claimed Boundary

The sandbox’s promise

Reach the declared tools — and nothing else.

Code Mode sandbox · V8 isolate const r = await tools.search(q); for () { } return summary; MCP tools RPC into the agent loop → typed TS API / bindings Internet blocked fetch() throws connect() throws no filesystem † Agents docs
The global fetch() and connect() functions throw errors. — Cloudflare, Code Mode

The Claimed Boundary

Under Code Mode: workerd

The same core runtime code that powers Cloudflare Workers

cloudflare/workerd JavaScript / Wasm server runtime Apache-2.0 2017 Workers launches closed source Sept 2022 workerd open-sourced Apache 2.0 2025 Code Mode ships on this runtime 2026 we audit the runtime itself
based on the same code that powers Cloudflare Workers workerd README

same core runtime code — not byte-identical; production adds security + orchestration

Why target the runtime

Why audit the runtime, not the seam

Like breaking an AI assistant by auditing Docker’s source.

1

A bold in-process bet

A whole security model resting on one in-process software wall. Bold things deserve a stress test.

2

No public scrutiny

V8 gets picked apart continuously. workerd had almost none of that attention.

3

A huge native surface

Web + Node APIs reimplemented in C++ — each its own implementation, all reachable from untrusted JS.

4

Blast radius

A bug here reaches all of Workers — not one experimental feature.

Lots of armor on the engine. None on the glue.

Blast radius

How big is “all of Workers”?

5.5M+

Workers developers (Q1 FY2026)

+1M in a single quarter — vs +1.5M in all of 2025

Cloudflare Q1 FY2026 earnings (CFO)

hundreds of billions

agentic requests / month

“growing exponentially”

Cloudflare CEO, Q1 FY2026 earnings call

>10%

of Cloudflare network requests use Workers

July 2020 — a dated floor, not current

A bug here is not contained to a feature.

The Claimed Boundary

The bet: isolate, don’t virtualize

The isolate is the boundary — in one shared process.

VM / container per tenant 10s–100s ms cold start, heavy memory — too slow for the edge. OS process (workerd) V8 isolate V8 isolate V8 isolate V8 isolate V8 isolate single-digit ms, a few MB — ~100× faster shared address space one tcmalloc heap the only wall the boundary is software — untrusted code runs IN-PROCESS

The Claimed Boundary

They assumed V8 would break

So they layered defenses — the cage, MPK, an L2 sandbox.

L2 process sandbox · namespaces + seccomp no filesystem · no network 8 GiB V8 sandbox 4 GiB pointer cage JS heap · 32-bit offsets contains heap corruption MPK · ~92% cross-isolate reads → HW trap (~11/12 keys) tcmalloc native heap kj containers node C++ objects VFS buffers outside every box
cage + MPK + L2 — verbatim, Safe in the Sandbox “tcmalloc outside cage + MPK” — disclosure-sourced, not a public quote

The Claimed Boundary

JSG: the glue Cloudflare reimplemented

Node, in C++, reachable from untrusted JS — on the unprotected heap.

Untrusted JS model-written / tenant await require( 'node:zlib' ) JSG JavaScript Glue JSG_RESOURCE_TYPE JSG_METHOD the C++↔V8 binding layer workerd native C++ node:* reimplemented in C++, not polyfill node:zlib URLPattern HTMLRewriter node:fs node:crypto node:buffer enabled by default · require('node:zlib') just works · nodejs_compat 2024-09-23 tcmalloc native heap outside cage + MPK · the cage can’t reach here
Node assumes the JavaScript author is trusted. In Code Mode, the attacker writes the JavaScript.
PART II

Five Bugs in the Glue


5 found · 2 rated Critical

URLPattern OOB node:zlib UAFCRITICAL HTMLRewriter UAFCRITICAL KV SQL bypass same OOB, both URLPattern impls
0 CVEs

Evidence: Five Bugs in the Glue

The slate, at a glance

Four findings — the URLPattern OOB exists in both implementations.

01
URLPattern OOB read → arbitrary read
High cross-tenant demo
02
node:zlib deflateParams() UAF → controllable write
CRITICAL RCE demo
03
HTMLRewriter AttributesIterator UAF
CRITICAL
04
Durable Objects KV SQL-authorizer bypass → arbitrary deserialization
Logic

Same OOB in both URLPattern implementations Cloudflare ships — that’s 5 reports.

The common thread Four memory-corruption, one logic — every one on the seam the cage doesn’t cover. URLPattern + zlib are the two we chain end-to-end today.

Evidence · URLPattern

Bug 1 — URLPattern, the router

Match a URL against a pattern; read the capture groups.

URLPattern · the happy path
const p = new URLPattern({ pathname: '/users/:id' });
p.exec('/users/42').pathname.groups;
 { id: '42' }
exec() builds groups from two parallel lists
VALUES
one per capture group
from V8’s regex
NAMES
the group names
URLPattern’s own parser
two lists · two different counters · zipped into groups
urlpattern_originalworkerd-native urlpattern_standardAda-backed · default 2025-05-01
two implementations, one flag away — same OOB in both, and the Ada-backed one is production.
the attacker controls the pattern

Evidence: URLPattern

Two counters, one mismatch

V8 counts every group. URLPattern’s parser misses a nested one.

urlpattern.c++ — exec()
uint32_t length = array.size(); uint32_t index = 0; while (index < length) { // ... groups.add(JsRef<JsValue>{ .name = kj::str(nameList[index - 1]), }); ++index; }
trigger
new URLPattern({ pathname: "/(ab(cde))" }).exec({ pathname: "/abcde" });
V8 counts2every group, nested too
nameList has1missed the nested group
index runs one past nameList → reads off the end of the kj::Vector — OOB read.

Check Point analysis — undocumented. One nested paren is the whole bug.

Evidence: URLPattern

OOB read → arbitrary read

One slot past nameList is a kj::String you control.

nameList : kj::Vector<kj::String> String String String end of nameList String OOB — nameList[i] reinterpret 24 bytes as a kj::String 0x00 0x08 0x18 size disposer ptr bytes you control kj::str() copies the string memory at *ptr any address you choose returned JS string = bytes at that address control bytes after nameList → control ptr → read any address
OOB read ⇒ arbitrary read
Bug 2 of 5

node:zlib deflateParams() UAF CRITICAL


one of the two Critical — the one that goes to a shell

Evidence · node:zlib

node:zlib is glue, and the glue forgets

It hands raw pointers to the real zlib — then never clears them.

glue — hands raw pointers to the real zlib, owns nothing
workerd · zlib-util.c++ — ZlibContext::setBuffers
void setBuffers(kj::ArrayPtr<const kj::byte> input, kj::ArrayPtr<kj::byte> output) { stream.avail_in = input.size(); stream.next_in = const_cast<kj::byte*>(input.begin()); stream.avail_out = output.size(); stream.next_out = output.begin(); // raw Bytef* into the JS buffer} ← next_out never nulled on return

next_out = a raw pointer straight into your JavaScript output buffer.

Fixed upstream by clearBuffers() (next_out = &dummyByte via KJ_DEFER) — shown pre-fix.

Evidence · node:zlib

Three JavaScript calls

deflateParams() flushes before it applies — through the dangling pointer

deflate.c — deflateParams()
if ((strategy != s->strategy || …) && /* data pending */) { err = deflate(strm, Z_BLOCK); // flush OLD config first}s->level = level; s->strategy = strategy; // applied only AFTER the flush

↳ the flush writes pending output through strm->next_outour dangling pointer

1
write(input, outBuf, Z_NO_FLUSH)
data buffered (Z_NO_FLUSH=0); next_outoutBuf
2
outBuf = null; gc()
V8 frees the buffer; next_out now dangling
3
params(6, 0)
deflateParams flushes via stale next_out = UAF write
z_stream
next_inavail_in
next_outavail_out
raw Bytef* — dangles after GC
UAF WRITE — three calls, attacker-controlled, on by default

pre-fix: upstream clearBuffers() later sets next_out = &dummyByte via KJ_DEFER

Evidence: Five Bugs in the Glue

Two more, same lesson

HTMLRewriter UAF · the KV SQL bypass

HTMLRewriter CRITICAL
const iter = el.attributes[Symbol.iterator]();iter.next(); // pointer into lol-html’s Vecfor (let i = 0; i < 10000; i++) el.setAttribute(`x${i}`, 'A'.repeat(100)); // Vec reallocsconst leaked = iter.next().value; // UAF read of freed array

Iterator holds a raw pointer into the element’s attribute array. Grow it past a size class and the array reallocates — the old one is freed; next() reads it back.

html-rewriter.c++ “Mutating attributes may cause lol-html’s internal Vec to reallocate, invalidating any live iterators’ pointers.”
the punchline The bug is in the C++ binding — not the memory-safe Rust (lol-html, BSD-3-Clause).
Durable Objects KV logic
sqlite-kv.h “… we can block it from accessing any table prefixed with _cf_.”

The authorizer checks the tables a query references — but never the destination of a rename.

CREATE TABLE kv_tmp (key TEXT, value BLOB);INSERT INTO kv_tmp VALUES('k', <attacker bytes>);ALTER TABLE kv_tmp RENAME TO _cf_KV;
storage.get('k') feeds those bytes to the structured-clone deserializer — built for trusted, in-process data.

Reported, not weaponized. It’s about the surface.

Both now patched — iter->invalidate(); the authorizer checks the rename destination.

Evidence: Five Bugs in the Glue

Four bugs, one address

Every one reaches the same native heap — outside the cage.

URLPattern OOB read → arbitrary read zlib UAF → write primitive HTMLRewriter UAF → write primitive KV authorizer bypass → deserialization surface tcmalloc native heap outside cage + MPK

Cloudflare confirmed this heap is outside both the cage and MPK — via coordinated disclosure, not a public quote.

PART III · PROOF

Two Live Demos


from primitive to payoff

Demo 1 prompt injection → reverse shell on host zlib UAF Demo 2 one Worker reads another tenant's secret URLPattern

Proof: Two Live Demos

Two entry points, two threat models

Different assumptions — one heap.

A  ·  Sandbox escape prompt injection in Code Mode zlib UAF write → arbitrary R/W native code on the host self-hosted: cage OFF how Code Mode runs tcmalloc native heap every object they touch lives here — OUTSIDE cage + MPK B  ·  Cross-tenant secret theft attacker deploys a Worker URLPattern arbitrary read read a co-tenant’s secret production: cage + MPK ON

Lane B was NOT run on Cloudflare production — on a shared host, a crash could take down a co-tenant.

Proof: Sandbox Escape

From UAF write to arbitrary R/W

Stop caring about the bytes. Inflate a length.

FileImpl #1 — 160 bytes (VFS file) 0x00 0x08 0x10 0x18 0x20 0x28 vtable refcount discrim. data.ptr address control data.size data.size UAF write target — blow it up data.disposer STEP 1  ·  shrink the flush to 8 bytes, control the offset inflate length real buffer adjacent heap — read reaches here (forward only) bounds check still passes — [0, data.size) of data.ptr  ·  Node fs position arg → R/W at data.ptr + offset STEP 2  ·  plant FileImpl #2 FileImpl #2 data.ptr set to any address arbitrary 64-bit R/W read AND write anywhere — stable, repeatable

Proof: Sandbox Escape

To native code — no ROP needed

workerd hands you a 256 MB RWX region at a fixed address.

/proc/<pid>/maps
5577ac0000-5577ad4000 r-xp ... workerd 5577ad4000-5577ad6000 r--p ... workerd ffff9c000000-ffff9c021000 rw-p ... [heap] aaaaf0000000-aaab00000000 rwxp ... [v8 code] aaab10000000-aaab14000000 r--p ... tcmalloc ffffd0000000-ffffd0021000 rw-p ... [stack]
256 MB R + W + EXECUTABLE · fixed address · present from startup · no leak needed
self-hosted workerd — V8 cage OFF native objects + buffers share one heap — the self-hosted build that runs model code the way Code Mode's Worker Loader does.
1
writeShellcode(0xaaaaf0000000 + 0x100000) ARM64 reverse shell, written straight in — no ROP
2
locate z_stream native write callback the dangling output path from the zlib UAF
3
overwrite callback target → +0x100000 point it at our shellcode
4
handle.write() once more → control jumps to shellcode
caveat V8 sandbox compiled OFF — the cleanest setting we measured (self-hosted, first-party). The UAF is cage-independent; with the cage ON this FileImpl finish needs a different post-UAF path. observed on workerd v1.20260108.0, Linux/ARM64 (first-party) · base differs by version/arch

Proof: Cross-tenant

Turning one OOB read into a heap sweep

Production — cage + MPK ON. It still works.

cage + MPK Production — cage + MPK ON all objects live OUTSIDE both — on tcmalloc tcmalloc native heap 1 size nameList : kj::Vector pad groups → pick the tcmalloc size class → pick the neighborhood 2 defeat ASLR 0x00 0x08 next ptr planted size = 8 free() overwrites survives free() tcmalloc heap is 1 GB-aligned one leak = heap base 3 repeat OOB read VFS file kj::heapArray overwrite its bytes in place + exec() again → read any address, repeatably nameList allocated at construction · OOB fires later on exec() — the shaped layout persists. “outside cage + MPK” per disclosure.

Proof: Two Live Demos

Two chains, one heap

The cage and the keys protect V8’s heap. We never touched it.

L2 process sandbox · namespaces + seccomp 8 GiB V8 sandbox 4 GiB pointer cage cage + MPK guard the V8 heap tcmalloc native heap outside cage + MPK we landed here kj::Vector / nameList FileImpl / VFS file z_stream freed chunk
cage + MPK protect the V8 heap. Our primitives never needed it.
PART IV

The Verdict


what the evidence proves

The engine is not the whole boundary.

The glue around it is part of the boundary too.

Proven.

Four takeaways

If you ship on this stack

01

Review the glue

JSG marshals lifetimes and pointers across the JS/native seam — exactly where UAFs live — on a fraction of V8’s scrutiny.

02

Threat-model the native heap

tcmalloc, kj containers, VFS buffers sit outside the cage. If the cage is your story, what it skips is your attack surface.

03

Agent code is just code

Model-written JavaScript runs like any other. Model the model as an attacker who can write JS.

04

Prompt injection = RCE

In Code Mode it’s a code-execution entry point, not a content problem. Treat it like one.

The Verdict — Disclosure

Coordinated, fixed, quiet

All five reported via HackerOne — a clean process, start to finish.

Feb 1, 2026

4 of 5 reported — zlib UAF, HTMLRewriter UAF, two URLPattern OOB reads.

Mar 11, 2026

Cloudflare rates two Critical — the zlib and HTMLRewriter use-after-frees. 2× CRITICAL

Mar 12, 2026

5th reported — the KV SQL bypass.

Jun 2, 2026

workerd v1.20260602.1 closes everything for self-hosted. Managed Workers were already fixed in production.

PR #6785

Changelog reads “2026 05 27 update” — a deliberately quiet note, no security mention. 0 CVEs as of writing.

Reported via HackerOne — coordinated Managed Workers: fixed in production Self-hosted → update to v1.20260602.1

Credit to Cloudflare — a clean coordinated process, and good to work with throughout.

This isn’t a Cloudflare problem.

It’s the shape of every in-process sandbox running untrusted code.

Wherever a memory-safe engine sits behind memory-unsafe glue, the glue is the boundary.

When Agentic Glue Melts

Setupevidenceproofverdict

The cage is necessary. It is not sufficient. Mind the glue.

Thank you Yarden Porat·Shahar Tal Check Point Research
Patched workerd v1.20260602.1 · PoC & full write-up · Black Hat USA 2026
write-up