'Chatmate'
BLOG

Breaking the M365 Copilot Sandbox, Part One: Getting Root in the Code Interpreter

PUBLISHED JUL 30, 2026

ChatMate, the first documented instance of remote prompt execution, shows how a malicious document can lead to sandbox escape.

Editor’s note: This is the first in a four-part series on Breaking the M365 Copilot Sandbox, which details research presented at Black Hat USA 2026. 

Imagine a user asks an LLM a question about a document. An unseen attacker establishes an interactive prompt channel into that chat session, enabling the attacker to send prompts, read the assistant's responses, and decide what to ask next.

We call this attack class Remote Prompt Execution (RPE). It’s characterized by adaptive, bidirectional control of an AI assistant after a single initial trigger. “ChatMate,” the first RPE to be publicly documented, shows how a malicious document can cause Microsoft Copilot to invoke its code-execution environment, escape the network-isolated sandbox, and establish a human-operated prompt shell back to the attacker.

Copilot windowCopilot email highlightsWhy ChatMate matters

Microsoft Copilot is deployed across a huge number of enterprises and, like most modern assistants, it runs a Python interpreter behind the scenes to conduct real computation. An interpreter that runs model-generated code is an interesting attack surface, so we investigated it. That eventually turned into a full container-to-host escape and a path to the victim's Microsoft 365 data, tracked as CVE-2026-32193 (CVSS Severity: High, 8.8). 

This series charts the path from hidden text instruction to code execution on the host node and, in the end, to a human-operated prompt shell in the victim's assistant context. Part one covers two building blocks everything else stands on:

  1. A bypass of Copilot's code-execution safety layer: Copilot refuses to run "pokey" code like ps or netstat, but we found a reliable way to make it run anything, turning the assistant into a general-purpose Python runner.
  2. A local privilege escalation to root inside the sandbox

The deepest, most impactful bug comes later in the series. This is where we explore the tools needed to find it.

What we mean by Remote Prompt Execution

RPE describes a post-compromise capability in which an attacker can repeatedly supply prompts to a victim's authenticated AI assistant, receive output, and adapt the next prompt without further victim interaction. In ChatMate, this takes the form of a human-operated, full-response REPL: the attacker sees the assistant's complete answer, types the next prompt, and repeats.

ChatMate was developed independently of Varonis's Reprompt, the closest related work we know of. Reprompt used Copilot's intended internet functionality to create a server-driven request chain in which follow-up instructions depended on prior responses. Its public write-up describes continuous, dynamic data exfiltration. It does not publicly demonstrate a human-operated, general-purpose prompt shell. Nevertheless, the underlying control loop is closely related.

The distinction is therefore not that adaptive remote control had never appeared before. It is how ChatMate demonstrates it: a malicious document triggers code execution, escapes a network-isolated sandbox, and turns the escaped environment into a complete-response prompt channel. Reprompt weaponized an intended internet capability. ChatMate creates the channel by crossing the assistant's execution boundary.

What are we even looking at?

Large language models are good at generating text. They are not so good at mechanical computation. The industry-standard fix is to give the model a code interpreter. For example, to generate a PDF, the model writes Python that generates a PDF, runs it in a sandbox, and reads back the result. Microsoft 365 Copilot does exactly this. Ask it to crunch a spreadsheet, build a chart, or process an uploaded document, and behind the scenes it generates Python code and runs it through a code execution tool.

The code execution tool runs the code in an isolated sandbox environment. If you do offensive research, that sentence should make your ears prick up. Building a safe sandbox that runs attacker-controlled code is a very difficult thing to do properly.

Making Copilot run our code

The first obstacle is that Copilot does not want to run arbitrary code. The code execution tool is meant for legitimate data tasks, and there's a safety layer that resists anything that looks like the code is poking at the environment. Ask it to run ps (process list) or netstat (list of sockets) and it politely declines.

But the safety layer is judging intent from the code it's shown, and intent is easy to launder. Instructing Copilot to benchmark gzip decompression with a blob of compressed bytes to decompress and time reads as a perfectly innocent performance test, even if the decompressed bytes happen to be the exact ps/netstat code it just refused to run.

So we wrote a tiny helper that takes any Python source, gzip-compresses it, and wraps it in a benign-looking "gzipbenchmark" prompt:
 

Helper prompt


Feed it this:
 

Python subprocess


…and out comes a prompt whose payload is a hex blob:
 

Text blob

Copilot sees a harmless benchmark, decompresses it, execs it, and hands us back the output as a downloadable file. The same ps and netstat it refused to run a moment ago now run without complaint. From here on, "I ran X" means "I ran X through this harness." We effectively had a Python REPL inside the Copilot sandbox.

In rare cases, however, Copilot got suspicious and wrote code that decompressed the payload without executing it, just to examine what it was about to run. At this point it declined. A more robust bypass would obfuscate the exec itself, not just the payload; but for research purposes, the simple version was sufficient.

Mapping the sandbox from the inside

With a REPL in hand, the first job is reconnaissance. What sandbox is Copilot running code in?

Running ps gives the shape of the environment immediately:
 

Reconnaissance


A clear picture emerges:

  • PID 1 is /app/entrypoint.sh, running as root. It launches everything else. Keep this in mind as it will matter in a moment.
  • Our code runs as the unprivileged ubuntu user, inside a Jupyter/IPython kernel (that's the ipykernel_launcher and jupyter-notebook processes).
  • goclientapp is a Go binary that, per later reverse engineering, is the thing that talks to the outside world and feeds our kernel code to run, files to up/download, and so on.
  • httpproxyapp is a proxy, and Apache Tika is there for document parsing.

netstat and the environment variables filled in the rest. A few things jumped out. First, the environment is heavily network-hardened: no internet, DNS is deliberately broken, and the http_proxy/https_proxy variables point at a local proxy that returns forbidden for everything.
 

FORBIDDEN


netstat also showed a handful of local listeners that weren't ours:


netstat


Ports 53827 and 53828 are part of an internal Azure service called PodAgent. Port 8578 in particular is a mystery: an HTTP server that answers 404 to everything we throw at it, owned by a process we can't see (the “-” in the PID column). This mysterious service plays a significant role in this research.

findmnt, the command that lists all file-system mounts, showed something promising. /mnt/data, /etc/hosts, and /etc/resolv.conf are all backed by the host's disk (/dev/sda2) rather than the container overlay. That means they're shared outside the container's own filesystem.
 

Sandbox

The mapped-out sandbox where our code runs as a weak ubuntu user inside a Jupyter kernel with no internet access, while entrypoint.sh runs as root, one unexplained "mysterious service" listens off to the side.

Hitting walls

We spent real time trying to get out of this box from our unprivileged position and mostly found good hardening:

  • Network. No internet or working DNS. When we tried reaching IMDS, a dozen well-known Azure and public DNS servers, the IPs goclientapp was talking to all timed out.
  • Other sessions. Other Copilot sessions on the same and on different accounts remained isolated and could not be communicated with.
  • Local services. goclientapphttpproxyapp, Jupyter, and Tika all run as our own ubuntu user. Winning them buys nothing. The interesting listeners (8578, the PodAgent ports) are owned by processes we can't see and suspect are just as network-hardened.

The sandbox looks well built and we look stuck.

Progress required root access so we could sniff traffic, for example. This meant finding a privilege escalation vulnerability inside the sandbox.

Getting root in the sandbox

Go back to that first ps line:
 

ps line


PID 1, running as root, is a bash script at /app/entrypoint.sh. And /app is where all the sandbox harness files live—the same /app our ubuntu user has been reading from all along.

So I checked the permissions on that script. Can you guess where this is going?

It's writable by us. The root-owned process that launches the entire environment is a shell script that unprivileged users could edit. If we can get that script re-executed, whatever we append to it runs as root. But this is the entrypoint, Nothing else ever runs it. Killing the process tears down the environment, and the platform just resets the whole sandbox.

The solution is a bash-script-append attack. Bash executes a shell script by reading and running it one line at a time. This means that even after bash has started executing a script, any change we make to a part of the file below the line it's currently on will still be read and run.

In our case, the entrypoint.sh script is roughly:
 

entrypoint.sh script


When the sandbox is up, the script is parked on its last line - wait. So all we have to do is:

  1. Append a backdoor after the wait line.
  2. Get entrypoint.sh to finish executing that wait line so it reaches our appended code.

Step 1 is trivial. But how do we do step 2? wait blocks until all child processes finish. In other words, the wait line only returns, and our backdoor code only runs, once ./goclientapp./httpproxyapp, and ./keepAliveJupyterSvc.sh have all died. So we just kill those three processes and our backdoor runs, right?

Almost, but there's a catch. Killing ./goclientapp also kills our only channel for talking to the sandbox. We'd be running as root for a brief moment, but we'd have gained nothing.

So the idea has to be more careful. It requires killing those processes to release the wait, gaining root, and putting the environment back exactly as it was fast enough that the platform never notices anything died. Concretely, the appended payload re-launches every service the entrypoint normally starts. Here is what the appended entrypoint.sh looked like:
 

Appended entrypoint.sh


Simple in theory. In practice it stubbornly did not work. The environment kept getting recycled no matter what. We chased many wrong theories before doing what should have done sooner: reproduce it offline.

Spinning up an Ubuntu VM on the same version, copying over the harness files, stripping everything nonessential, and running the attack demonstrated the error. goclientapp was failing to start after the restart. It had startup requirements the naive re-launch didn't satisfy, so the service never came back. The platform declared the session dead. After fixing, the goclientapp relaunch came up cleanly and the whole thing worked, first in the VM and then on Copilot itself.

We now have root inside the Copilot sandbox.

To make root usable rather than a one-shot, the payload starts a tiny daemon: a local listener on port 1337 that accepts bash and runs it as root. Then, exactly like the gzip trick, a small script that takes root bash commands (or root Python code) and packages it into ordinary sandbox code talks to localhost:1337. From the outside, it looks like simply asking Copilot to run Python while, underneath, running root commands.

One dead end worth mentioning, because ruling it out was instructive: the classic cp /bin/bash /mnt/data/rootbash; chmod +xs SUID trick didn't work. The mount has nosuid/no-new-privileges semantics, so a SUID binary buys nothing here. Running code as root, on the other hand, works fine, which is what the port-1337 daemon gives us.

The payoff: Azure Dynamic Sessions

With the root, we could finally watch traffic. We didn't have tcpdump, so we created a small promiscuous-mode packet sniffer in Python.

Here's a request it captured:
 

Promiscuous packet sniffer


Host: ACA-Session-Interpreter. "ACA" is Azure Container Apps. Combined with AzureContainerApps-DynamicSessions environment variables, the picture was suddenly clear: the Copilot code interpreter is built on Azure Container Apps dynamic sessions, a general-purpose, publicly available Azure product for running untrusted code in disposable sandboxes.

This insight reshaped our research. If Copilot's sandbox is Dynamic Sessions, then the exact same environment can be rented directly from Azure, with a clean, documented API—no gzip prompts, chat window, or fighting the safety layer.

Opening Dynamic Sessions pool in a personal Azure account and executing code through its REST API confirmed it was the exact same environment. Same /app, same services, same everything with the privilege-escalation exploit working as-is. 

That meant:

  • Research got dramatically easier, allowing us to open a session, run the PE, and drive root commands from a normal Python script instead of a chatbot
  • Anything discovered here wasn't just a Copilot bug, but affected Azure Dynamic Sessions as well

In summary

After part one, we now:

  • Can run arbitrary code in the Copilot sandbox, reliably, despite the safety layer.
  • Can escalate to root inside that sandbox.
  • Know the sandbox is Azure Container Apps dynamic sessions, which gives us a clean, scriptable replica to work in.

And yet, we're still inside a container that is entirely our own. Root in a disposable sandbox, with no network, isn't impactful on its own. Everything reachable from here runs as us or is walled off by that very good network hardening. The one loose thread is that unidentified HTTP server on port 8578, the one that answered 404 to everything we tried.

That 404-to-everything server on port 8578 is the one thing I couldn't explain and couldn't crack. In part two, we'll get back to it.

Ori Lahav is a security researcher at Rubrik Zero Labs. This research was conducted as part of Rubrik Zero Labs' work on the security of emerging AI infrastructure.