← Back to all posts
Published on · by Renaud Deraison

The token was in the same heap

Oren Yomtov of Accomplish escaped OpenAI's Codex sandbox twice. One escape ran from read-only, the strictest mode: untrusted JavaScript called v8.getHeapSnapshot() and read the trusted context's authorisation token out of the heap the two of them shared. The other made apply_patch widen its own write permission to the filesystem root, then append a line to .zshrc. OpenAI fixed both in eight days. The property that produced them has no patch: a guard and the code it guards were two variables in one process. Bromure Agentic Coding runs the guard on your Mac and the agent in a VM.

The untrusted context never broke the sandbox. It asked Node for a snapshot of the heap, found the sandbox's own authorisation token sitting there as an ordinary string, and used it.

You point Codex at a repository somebody sent you. You are careful, so you run it in read-only, the mode where the agent can look and cannot touch. The agent reads the code. Some of that code is a payload, and a few seconds later a command is running on your Mac, outside the sandbox, with no prompt on screen.

That is Heapjack, one of two Codex sandbox escapes Oren Yomtov of Accomplish published this month. BleepingComputer covered them on September 20. Yomtov reported both to OpenAI on August 12, and OpenAI fixed them within eight days: Heapjack in Codex Desktop build 26.818.21641, the other in Codex CLI 0.149.0. OpenAI's statement is the short kind: "We thank the researchers for contacting us and sharing their findings. We addressed both issues in August, but we're continually strengthening our sandboxes."

Why the first bug existed is the part that outlasts the patch.

One process, one heap, one token

Codex Desktop has a tool called node_repl that runs JavaScript. It keeps two contexts inside it: a trusted one, which is allowed to ask the parent process to do real things, and an untrusted one, which runs whatever the agent is evaluating. The separation between them is the sandbox.

Both contexts live in the same Node process. In Yomtov's words: "One Node process, one V8 isolate, one heap shared by both contexts. The token is a string in that heap."

The token is how the parent process tells the two apart. A request arriving on the pipe with the right token is trusted and gets executed; one without it is not. It is a random UUID, which is a fine secret as long as the code you are keeping it from cannot read your memory.

It can. v8.getHeapSnapshot() is a public Node API, a profiling tool, and it returns a dump of every object in the heap, strings included. The untrusted context calls it, filters for things shaped like a UUID, and ends up with a handful of candidates. Picking the real one out takes one more step: try each against the parent and watch what comes back. A wrong token answers "not authorized". The right one answers with a complaint about the rest of the request. Yomtov keeps the string that produces the second reply.

With the token in hand, the untrusted context writes requests onto the same stdout pipe the trusted one uses. The parent checks the token, finds it valid, and runs the command unsandboxed. From there: system commands through open, the Docker socket, edits to configuration files.

All of that from read-only. Quoting Yomtov again: "All of it ran at read-only, the strictest sandbox mode, where the agent supposedly can't write anything."

The boundary and the thing it bounds, in one address spaceone Node process, one V8 heaptrusted contextmay ask the parent to actholds the tokenuntrusted contextruns the repository's codethis is the sandboxthe heap both of them share"a1f3…-…-…" the authorisation token, as a stringv8.getHeapSnapshot()what the untrusted side does1. snapshot the heap, keep UUID-shaped strings2. "not authorized" vs a validation error3. forge requests on the shared stdout pipethe parent processtoken valid → run it, unsandboxedcommands on the Macfrom read-only, with no prompt
Heapjack does not break the sandbox; it reads the sandbox's key. The trusted context, the untrusted context and the authorisation token that distinguishes them all live in one V8 heap inside one Node process. v8.getHeapSnapshot() is a public profiling API that returns every string in that heap. The untrusted context collects the UUID-shaped candidates and identifies the real token by the difference between a 'not authorized' reply and a validation error, then forges trusted requests on the shared stdout pipe. The parent process validates the token and executes the command outside the sandbox, in read-only mode, where nothing is supposed to be writable.

A write permission that grows

Overpatch, the second escape, is plainer. Codex CLI has an apply_patch tool for editing files, and it works out how much write access it needs from the paths named in the patch it was handed. The paths come from the model, and the model is reading the repository.

So a patch names /tmp in one entry with a change that does nothing. The change fails. The permission grant it implied does not: write access widens up to the filesystem root. A second entry in the same patch then writes through a symlink into $HOME and appends a line to .zshrc.

Nothing has escaped yet. The escape happens later, when you open a terminal and your shell runs that line as you, with no sandbox anywhere in the path. This one ran in workspace-write, the everyday mode.

Two different bugs, two different products, one sentence from the write-up that covers both: "The thing doing the enforcement was sitting inside the thing being enforced."

The guard and the guarded, in one address space

A sandbox that lives in the same process as the code it restrains is a convention. It holds until somebody looks hard at it. The token is in the heap. The permission calculation reads attacker-supplied paths. The pipe is the same pipe. Each of those is fixable, and OpenAI fixed each in eight days. The next bug of this kind will be a different string in the same heap.

Yomtov's own conclusion is the architectural one: "That is why Accomplish runs the whole agent inside a VM… We treat everything in the guest as untrusted, root included, because nothing that decides what is allowed is running in there with it."

That is the design Bromure Agentic Coding ships. The agent runs in an Ubuntu 24.04 guest under Apple's Virtualization.framework, the hypervisor macOS itself provides. The guest is untrusted by construction, root included. The code that decides what is permitted runs as a host process on the other side of a vsock, and no token inside the guest authorises anything, because everything worth authorising sits outside the guest.

a sandbox in the processone operating system, one user accountthe agentreading their repositorythe policya token in the same heap~/.ssh ~/.aws ~/.zshrc the keychain every repoone string read from memoryand the separation is over,everything above is in reacha boundary at the hypervisorthe Linux guest, untrusted, root includedthe agent, the repository, its own $HOMEghp_… sk-ant-… placeholdersVirtualization.framework + vsockthe Mac, where the deciding happensthe proxyreal keys, egress rules,the 451 on the way outthe dialog"Allow write on … ?"rendered outside the VM
What matters is which machine evaluates the rules. Left: the guard, the guarded code, the authorisation token and your SSH keys, cloud credentials and .zshrc all sit in one operating system, so reading one string ends the separation. Right: the agent gets a Linux VM whose credentials are structure-preserving placeholders, whose port-80 and port-443 flows the virtual switch diverts into a host proxy that the guest cannot unset, and whose worst outcome is a disk you wipe. Full compromise of the guest, root included, buys the attacker the guest.

What each escape meets instead

Overpatch is the concrete one, so start there. The trick ends by appending to .zshrc in $HOME. In a Bromure workspace the guest's $HOME is home.img, an ext4 image attached to that VM. Your Mac's home directory is not inside it. What the guest can reach on your Mac is the project folders you shared into it: up to eight, chosen by you, listed in the workspace editor. The dotfiles your shell reads when you open Terminal are not among them unless you put them there.

Heapjack is the harder case. Nothing in Bromure detects a heap-snapshot trick inside the guest, and nothing is meant to. What changes is where the escape arrives. In Codex the escaped code landed in the account that holds your SSH keys and your cloud credentials. In a Bromure workspace it lands in a Linux VM where:

The credentials are placeholders

sk-ant-api03-brm-…, a ghp_-shaped string, a ~/.aws profile whose secret is invalid. They are structure-preserving, so tools accept them, and worthless upstream. The real values live in the proxy's memory on the host and are swapped in on the way out, scoped exact-or-subdomain to the host they belong to.

Carrying one out trips the wire

The proxy scans headers and bodies of every outbound request for the workspace's own fakes. A fake headed somewhere it was not minted for is blocked with HTTP 451, with not a byte forwarded. The VM pauses, and a red Credential brokering row lands in the Security Timeline.

The network is a list you wrote

Egress is an ordered ruleset ending in default allow or default deny. The virtual switch diverts the VM's port-80 and port-443 flows into the host proxy with no environment variable involved and nothing the guest can unset. Edits apply to a running session immediately.

A push is a question, asked on the Mac

Git forge credentials carry a write policy, and new workspaces default to Prompt before write. git push is a write. The dialog names the exact operation and renders on your desktop. An attacker inside the VM cannot click it.

When it does go wrong, you recover with a menu item. The compromise flow wipes the workspace's disk and home and keeps your settings, tokens and SSH keys. The real credentials never left your Mac, so you rotate nothing. Reset disk re-clones the system image. No Bromure action touches your shared project folders, and the prompt says so.

The strictest mode was the one that lost

The developer in this story did everything right. They chose the strictest setting the tool offers, on purpose, because the repository was not theirs. read-only is a real feature and it did real work, until a string in a shared heap turned it into a suggestion.

Modes, allowlists and approval prompts are worth having, and every one of them assumes the code enforcing them sits out of reach of the code it constrains. That is a claim about memory layout, and you can go and check it. Yomtov checked Codex twice, across two products, and the answer came back no both times.

So run the agent somewhere the answer stops mattering. Give it root, let it install what it likes, let it read the repository somebody sent you, and keep every credential, every destination decision and every push confirmation on the other side of a hypervisor, where the next escape arrives in a machine you were already willing to throw away.