'Chatmate'
BLOG

Breaking the M365 Copilot Sandbox with ChatMate

PUBLISHED JUL 30, 2026

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

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.

Breaking the M365 Copilot Sandbox, Part Two: The Daemon on Port 8578

We talked our way past Copilot's code-execution safety layer, mapped the sandbox, escalated to root, and - by sniffing our own traffic - learned that the whole thing is an Azure Container Apps dynamic session, a public Azure product we can rent and research directly. That last fact is what makes this part possible.

In part one there was one direction we got stuck on. During recon, netstat showed a listener I couldn't account for:

Shell

An HTTP server on port 8578, owned by a process I couldn't see, that answered 404 to everything.

 

But once I knew the sandbox runs on Azure Dynamic Sessions - which runs on Container Apps, which runs on AKS - I went back to check whether this mystery service also showed up in those platforms. It did: the same listener was present on plain Container Apps and on AKS nodes, which meant it belongs to an infrastructure shared by all three, not to Copilot's sandbox specifically.

 

That was the opening I needed. Instead of studying the service through Copilot prompts, I could spin up an ordinary Container App with whatever tooling I liked and poke at it directly.

Knocking on every door

The first time I looked at 8578, I'd tried the obvious handful of routes by hand. However, all routes I tried returned 404 Not Found. Also, no Server header, no status-line quirk, nothing in the response to fingerprint it. That's why I'd given up on it the first time.

But "I tried ten routes by hand" and "there are no routes" are very different statements. Now that the same service was reachable from a plain Azure Container App I control, I could brute-force it properly: upload a big wordlist and a fuzzer, and throw millions of paths at it.

It didn't take long:

Missing field

It found a route - /config - that instead of a blank 404, it talked back: missing field ns. It wants a parameter.

So I gave it one:

Config

And this time I got the response “unknown config“. Sounds like it is looking for a config (or maybe a namespace) named hello and doesn’t find it.

So, maybe we can write a config using PUT? Let’s try it:

Config

And we got 200 OK! Seems like we managed to write a config. Let’s try to read it:
HelloAnd indeed, this time, we get 200 OK (remember this exact same request returned unknown config before!). The body is empty, so we are not actually getting the config itself, but we can see the config named hello does exist after we wrote it with PUT.

So, the service stores named configs: run PUT with a name, it writes it; run GET with a name, it returns if it exists.

Neat. And, at first, completely useless. What do you do with a service that stores nothing you can read back, under names you pick?

I played with it for a long time - trying to make it persist data across sessions, trying to make it fetch URLs, trying every shape of value - and got nowhere.

What if ns is a filename?

When a black box stores something under a name you control, the question worth asking is: what is that name, to the program? A key in a map? A row ID? Or maybe a path on disk?

Maybe each config is written to a file named after ns, then maybe, just maybe, the service doesn't sanitize the name, then ns is a directory-traversal primitive. But there's a catch that makes this hard to test: I can't see the filesystem the service writes to. It's some other process, in some other container. So even if traversal works, how would I know?

Pause here for a second and try to work out how you could know - it's a neat trick.

Here's the insight. If ns is used as a path, then these two are the same file:

  1. aa
  2. bb/../aa

Going to a file aa at the root is identical to going into a directory bb, stepping back out with .., and landing on aaaa. So I don't need to see the filesystem to prove traversal - I just need the service to agree that those two names point at the same thing:

  1. PUT /config?ns=aa - write a config at the traversed path.
  2. GET /config?ns=bb/../aa - check if it exists using a different-looking path.

If the second request returns 200 OK, the .. was resolved on a real filesystem.

And… it worked. It returned 200 OK, meaning aa and bb/../aa resolve to the same file - so we found a directory traversal vulnerability in the mysterious service!

In addition, it turns about we can even use absolute paths - for example, /bin/file to point to whatever file we want.

But what is it doing with the name? One more pair of probes settled it. I pointed the service at two paths that already exist - one a file, one a directory:

existing directory

Sit with that pair, because it pins down the whole mechanism. If the service simply wrote a file at the name I gave it, then /bin - already a directory - should have failed exactly like /bin/ls did. It didn't. The only model that fits both results is: the service treats the name as a directory, creates it, and writes a file inside:

  • /bin/ls fails because you can't create a directory on top of an existing file
  • /bin succeeds because the directory is already there, so it just drops a new file into it.
  • In addition, since /bin is writable only by root, this means the daemon is running as root.

So, we got a directory traversal vulnerability. What now?

Stuck, and pulling my hair out

I had a directory traversal into some filesystem I couldn't see, writing files with contents I have no idea what they are, and I couldn't overwrite anything that already existed. On top of that, I assumed the service was just some other container living next to my pod - network-isolated like everything else in this environment. So even if I managed to exploit this vulnerability into code execution, I couldn't reach anything interesting anyway.

I decided to put the whole research aside and went to work on other research for about a month (the AI orchestration research, by the way — [TODO: link to that series]).

But the research still stayed in my mind, since I achieved so much:

  • I understood every bit of the sandbox.
  • I'd found and exploited a privilege escalation in the sandbox.
  • I'd found a directory traversal in an internal Azure service.

However, I managed to achieve absolutely zero real-world impact with all of it.

Cool findings, no value.

The morning it cracked

One morning I came back to it annoyed, and gave the "can't overwrite existing files" behavior another look - not as an obstacle, but as a feature.

Think about what that behavior actually is:

  • PUT a name where no file exists yet → 200 OK.
  • PUT a name where a file already exists → 500 Internal Server Error.

That's not just an annoyance. That's a “Does file exist?” oracle. I can hand the service any absolute path and it will tell me, by its status code, whether that file exists on the filesystem it's writing to.

For example:

DOES EXIST

So maybe, by checking what files exists in this environment, I can fingerprint the filesystem and hopefully understand what this filesystem is.

I loaded up the bruteforcer again, this time to try and find telltale paths that will hint at what this filesystem is.

Most of it came back "doesn't exist." And then the fuzzer hit something I did not expect:

FUZZER findings

/var/lib/cloud/instance/… exists.

What are these files? Those files belong to cloud-init - the package that bootstraps a cloud virtual machine on first boot. You do not find /var/lib/cloud/instance/boot-finished inside an application container. You find it on the host VM.

Read that again, because it took me a second to believe it. This service isn't writing into a container next to my pod. It's writing to the Kubernetes node my pod is running on. We are writing outside the sandbox!

Host VM

The file-existence oracle proved the daemon writes to the host VM — outside the sandbox entirely.

Everything about the finding changed in that moment. A directory traversal into an adjacent container is a curiosity. A directory traversal that writes files, as some privileged daemon, onto the host node underneath an Azure sandbox - reachable from inside code a prompt can trigger - is something else entirely.

There were still two things I didn't know: what the service actually writes into those files, and how to turn "create a new file" into something that runs. But the shape of the bug was finally clear, and it was much, much bigger than I'd thought.

Breaking the M365 Copilot Sandbox, Part Three: Escaping the Container

Host daemonWe can create files, as some privileged daemon, on the host node — but we don't control their contents.

The mysterious service listening on port 8578 in the sandbox has a directory-traversal bug in its /config endpoint - it takes the ns name, treats it as a directory, creates it, and writes a file inside. Using it as a file-existence oracle, we proved that those writes land on the Kubernetes node, not inside our sandbox, and that the daemon does them as root. We can create a directory-with-a-file at an arbitrary path, outside the container, as root.

That's an interesting primitive - but a limited one. We control where it creates its directory, not what it writes there: we don't know the name of the file it drops inside, or a single byte of that file's contents.

To weaponize this we need to know exactly what the service writes. So the first job is to read what it produces — and for that we can use the fact that our sandbox pod lives on the very node the daemon is writing to.

Making the daemon write somewhere we can read

The key is something findmnt showed us back in part one: our /mnt/data isn't part of the container's own overlay filesystem — it's a bind mount from the host's disk. Here's the actual line:

findmnt

Read it carefully. The directory the sandbox sees as /mnt/data is, on the host node, /podr/volume/ca0c744a898449029951ae58951dbebe. Same files, two paths - one inside the pod, one on the node. So if I make the daemon create its folder at that host path, whatever it writes lands inside my own sandbox at /mnt/data, where I can simply read it.

Daemon config

Turning the traversal on my own pod: I point the daemon at my volume's host path, and read back inside the sandbox the file whose contents I couldn't otherwise see.

That's exactly what the traversal buys me. Instead of aiming it at some blind host path, I aimed it back into my own pod's volume: I gave the service a name that traverses to /podr/volume/…/confname. It dutifully created a confname directory there on the host — which is the very directory I see at /mnt/data/confname inside the sandbox. I opened the file it had left behind:

Host server capabiliites

Two things clicked at once.

First, I could finally see the file the daemon drops inside each config directory: it's called hosts.toml. Back in part two I deduced that the service creates a directory named after the config and writes a file inside - now I know the file's name.

Second, hosts.toml with capabilities = ["resolve", "pull"] and skip_verify is a containerd registry-hosts configuration - the file that tells containerd where to pull image content from. The daemon writing it identifies itself as acr, part of Azure Container Runtime - the node component behind AKS image/artifact streaming, the feature that lets a node start a container before its image has finished downloading. That's also why the same service is reachable from Container Apps, Dynamic Sessions, and AKS alike: they all run on nodes with this runtime.

And crucially, it writes this config under /etc on the node - confirming, from a second angle, the root we'd already inferred from the /bin probe back in part two: this daemon runs as root.

Turning "create a file" into "write any file, with any contents"

Now line up what we have against what we need.

To turn this into a real exploit we need to overwrite a specific, security-relevant file on the node, with contents we choose - so we need control over both the file's path (a primitive that can only ever drop a file called hosts.toml is difficult to exploit to an RCE) and also control the contents of the file we are dropping. What we have is a primitive that creates a directory containing a hosts.toml whose contents are mostly fixed - we only control the <name> that goes into the server = "https://<name>" line:

Server pull

Two problems, two tricks.

Problem 1: we control the name, not the target path. The write always lands at <config-dir>/hosts.toml.

Can you see how to redirect it onto an arbitrary existing file?

Hint: symlinks.

We create a symlink, inside our own pod's volume, where the daemon is about to write its hosts.toml, and point that symlink at the file we actually want to clobber - say, /etc/something on the node. When the daemon writes hosts.toml, it follows the symlink and writes through it onto the target.

Problem 2: the contents are a fixed containerd template; we only control <name>. A file with the containerd format isn't very useful on its own. But look again at where our input lands:

local host

What if <name> contains a newline? It turns out the service doesn't sanitize it! So we can break out of the server line and write additional lines of our choosing into the file:

arbitrary line

We're left with a fixed prefix (server = "https://) and a fixed suffix (the rest of the containerd template), but everything in between is ours. This is a TOML/config injection via the unsanitized ns value.

Put the two tricks together and the primitive is no longer "create a file with fixed contents." It's:

Write an arbitrary file, with (almost) arbitrary contents, as root, on the Kubernetes node

(driven from code that a prompt put inside the sandbox.)

From arbitrary write to code execution on the host

An arbitrary file write, even as root, is not yet code execution. We need a file that, once written, causes our code to run. I walked the usual suspects:

  • cron - the crontab format is picky, and our fixed prefix/suffix broke it. No.
  • /etc/profile.d/ - works, but only fires on an interactive login. No one was going to log in. No trigger.
  • Python .pth files - promising (they execute on interpreter startup), but I couldn't get one to fire reliably here. No luck.
  • /etc/ld.so.preloadyes. Any library listed here is force-loaded into essentially every dynamically linked process that starts on the machine. The node starts new processes constantly, so a library dropped here gets loaded almost immediately, in a root process.

So here is the plan: write a small shared library into our pod's volume (it's already on the node's disk, at a known host path), then use the arbitrary-write primitive to put that library's node path into /etc/ld.so.preload. The next process to start on the node loads our library — as root.

Final exploit

Here's the real exploit assembling exactly that - symlink for the ld.so.preload file, newline injection for pointing into our backdoor library, and the traversal to land the write on our own pod's confname directory:

End of output

The library itself is a tiny agent. Since /mnt/data is shared between the sandbox and the node, it uses that shared directory as a two-way channel: read a command from a file, run it, write the output back. Here is how we communicate with it:

We write a command to /mnt/data/cnc/cmd; our preloaded library on the node reads it, executes it as root, and writes stdout to /mnt/data/cnc/out; we read it back inside the sandbox. A clean shell bridge across the container boundary.

One tiny note: I could’ve probably used the root execution to enable network access to the pod and access the attacker directly from the pod, but I realized that at a later stage.

Because ld.so.preload loads the library into every new process on the node, I also gave it three safeguards to keep it robust:

  1. On startup it checks that it was injected into a root process; if not, it exits.
  2. It takes a mutex on startup, so only one instance ever stays alive.
  3. It forks and daemonizes off the process it was loaded into, so it doesn't disturb that process.

See the sandbox escape in action in the following demonstration. You can watch the exploit drop the preload library, and the moment it's loaded on the node we get a root shell and run commands on the host:

We've escaped the sandbox and we're running as root on the Kubernetes node.

Sandbox escape

Sandbox escaped: a preloaded library gives us root code execution on the node, bridged to the sandbox over the shared /mnt/data channel.

There's a broader point worth stating plainly here, because it's the part that reaches past Copilot. The bug in acr isn't "a Copilot bug." It's a daemon that listens on localhost, requires no authentication, and turns a write request into a root-owned file write on the host - and it runs on Container Apps, Dynamic Sessions, and AKS nodes (with image streaming enabled). Anywhere you can reach localhost:8578 - including through a server-side request-forgery (SSRF) bug in any workload on such a node - this converts into root code execution on the node itself.

We set out from a chat box. We're now root on the host VM underneath an Azure service. But root on the node still isn't the goal - the goal was always the victim's data. In part four, the node's one crucial capability that the sandbox never had turns this from an impressive escape into an attack.

Breaking the M365 Copilot Sandbox, Part Four: An Interactive Shell on an AI Assistant

We have now turned the acr daemon's directory-traversal-plus-config-injection into an arbitrary file write on the host, and used /etc/ld.so.preload to land root code execution on the Kubernetes node. Over the shared /mnt/data volume we have a clean command channel between our sandbox code and a root process on the node.

So: what is being root on the node actually worth?

I checked the obvious thing first - can I reach other tenants' pods from here? No. Azure's isolation held; we were alone on the node. However, the node had internet access.

The node has internet

The whole time we were inside the sandbox, the network was a moat: no DNS, no egress, every outbound connection dead. That hardening is real - but it was applied at the pod level. The node underneath it has to pull images, talk to the Azure control plane, and generally function as a machine on the internet. The host has outbound internet access.

That single capability closes the loop we'd been missing since part one. From our root process on the node we can open a connection out to an attacker-controlled server.

Now chain the two hops we already built:

Attack chain

  • Copilot speaks to the sandbox by running Python.
  • The sandbox talks to the node over /mnt/data, through the backdoor our exploit installed.
  • The node talks to the attacker over the internet.

For the first time, code running inside a victim's Copilot sandbox can reach the outside world.

The immediate use is data exfiltration - the goal we set at the very beginning. Whenever a user asks Copilot to do something that requires the sandbox (crunch a spreadsheet, parse an uploaded document), Copilot ships that data into the sandbox, and now we can quietly forward it out through the node. I dropped a monitor on the directory where uploaded documents land and exfiltrated them over the host's network.

Useful - but honestly, a little underwhelming. It only yields whatever happens to pass through the sandbox, in the specific session we attacked. Copilot can reach an entire Microsoft 365 tenant - mail, files, calendar, chats - and almost none of that ever touches the sandbox. For about a week I thought data-in-the-sandbox was the ceiling, and I moved on.

Then I had the epiphany that named this whole project.

The channel goes both ways

I'd been thinking of the chain as an exfiltration pipe - data flowing outward, victim → attacker.

The attacker can also send input back the same way - back down that connection, into the node, across /mnt/data into the sandbox, and up into the Copilot conversation itself.

So essentially, we have a bidirectional communication between the attacker and the victim’s Copilot:

Bidirectional communications

The same chain, read backwards: a bidirectional channel lets the attacker push arbitrary prompts into the victim's Copilot.

That means the attacker can push an arbitrary prompt into the victim's live Copilot, let Copilot execute it with the victim's identity and full M365 access, and stream the response back out. Send a prompt, get a response, send the next one.

That's an interactive shell - on an AI assistant.

In classic exploitation the ultimate win is an interactive shell on a machine. The AI-assistant equivalent didn't exist. This is one: a live, two-way channel where the attacker types and the assistant answers - with the victim's data, permissions, and reach.

This is the Remote Prompt Execution I promised you back in part one - the prompt-world analogue of Remote Code Execution - and we named the exploit that delivers it ChatMate.

Delivering it: any prompt injection attack vector

Everything so far, I drove from my own Copilot. For a real attack, the chain has to fire inside a victim's Copilot, triggered by something an attacker can plausibly get in front of them.

This can be done with any prompt injection attack vector you’ve heard of - for example, the one click attack vector in Reprompttriggering prompt injection through E-mails in EchoLeak, or any other prompt injection vector.

For the sake of demonstration, we’ve chosen the following attack vector: we’ll convince the user to hand our document to the AI assistant and ask it to deal with it. Copilot reads and processes the files a user uploads, and that is all the foothold we need.

So we build an innocuous-looking document - a Word file that reads like routine business:

Dear John letter

This is a classic prompt-injection attack. We hide the prompt-injection part behind a white-background rectangle, so the user won't be able to see it. See the delivery on its own in the following demonstration. The document looks entirely innocent when John opens it; but the moment he uploads it to Copilot, simply having it parsed makes Copilot run our code inside the sandbox:

On top of hiding it, three details in the injected prompt are what make the attack work:

  1. The Python code is wrapped in the gzip trick from part one, so Copilot won't suspect anything.
  2. We ask Copilot not to report anything to the user about the Python execution.
  3. And most crucially: we ask Copilot to treat the program's output as instructions, and to send the result of following them back to the sandbox. This is what creates the prompt → response loop, achieving an interactive shell.

From there the rest of the chain runs on its own:

The victim asks Copilot to fill in the details in the document.

  1. In the background, the prompt injection fires: our code runs in their sandbox, escalates, escapes to the node, and dials the attacker's C&C.
  2. A shell pops up on the attacker's side.
  3. The attacker enters a prompt.
  4. The prompt travels to the node backdoor, from there into the sandbox, and is returned to Copilot as program output.
  5. Copilot treats that program output as a prompt (because of the prompt injection in the document).
  6. Copilot executes the prompt and sends the output back into the sandbox.
  7. The sandbox backdoor passes it to the host backdoor, which passes it to the attacker.
  8. The attacker sees the response and sends another prompt.
  9. Steps 4–9 repeat, for as long as the attacker likes, or until the maximum number of turns is exhausted.

That loop is ChatMate.

ChatMate Loop

ChatMate: a malicious document bootstraps a bidirectional channel from the attacker all the way into the victim's Copilot.
Connection initiated

An illustration of the attacker screen showing a prompt

Let’s see a demo now. I reported this vulnerability to Microsoft and it was fixed before recording the demo, so the internet connection to the attacker is simulated. Everything else in the demo is real. 

Postmortem

Pulling on the thread of "is this sandbox secure?" unwound into five distinct issues across four Microsoft products:

  • Azure Dynamic Sessions
    • Sandbox privilege escalation
    • Host-network exposure
  • Azure Container Runtime
    • Missing authentication
    • Path traversal
    • TOML injection

In total, four Microsoft products were affected:

  1. Azure Kubernetes Service
  2. Azure Container Apps
  3. Azure Dynamic Sessions
  4. Microsoft Copilot

And the primitive itself: an interactive shell on an AI assistant is now a real attacker capability. As assistants gain more autonomy and more reach into our data, the risk of that autonomy being turned against us only grows.

One more thing

The sandbox escape vulnerability is actually a vulnerability in an Azure Container Runtime daemon. This means the vulnerability had wider impact then Copilot. Any SSRF vulnerability in a Container App or in AKS (with image streaming and host-network exposed app) could be exploited into an RCE on the K8s node running the application.

Disclosure timeline

  • 2026-02-10 — Both the sandbox privilege escalation and the acr directory traversal reported to the Microsoft Security Response Center (MSRC).
  • 2026-03-19 — Sandbox privilege escalation fixed.
  • Mid-April 2026 — acr directory-traversal / host-escape chain fixed; assigned CVE-2026-32193 (CVSS 8.8) and awarded a $48,000 bug bounty, due to the criticality of the vulnerability.

Genuine thanks to MSRC for their brilliant response.

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