I Tested 24 Exfiltration Attacks Against a Locked-Down AI Sandbox A security researcher tested 24 exfiltration payloads against a deny-by-default Tensorlake Sandbox configured with a single allowlisted destination, api.github.com, and none of the payloads delivered a token to the attacker-controlled receiver. The test split the payloads into Family A, covering direct channels such as HTTPS POST, raw sockets, DNS tunneling, and curl/wget subprocesses, and Family B, covering parser-differential tricks including null bytes, CRLF, decimal/hex/octal IP encoding, IPv4-mapped IPv6, punycode, userinfo, trailing-dot FQDNs, and DNS-over-HTTPS. The researcher noted that with a non-empty allow_out list, DNS itself is default-deny, and that the two controls — one hitting api.github.com and one hitting an unlisted host — were included to verify the rig measured both success and failure. A few articles ago I built a code interpreter on Tensorlake https://www.tensorlake.ai/ Sandboxes. The shape is familiar to anyone who has shipped one. A user uploads some data. Asks a question. A model writes Python. The sandbox runs it. The microVM keeps that code off my host and away from other tenants, which is exactly what you want. But here is the problem isolation does nothing about. Suppose the uploaded file carries a prompt injection. Or the user just asks for something hostile. The generated code is valid Python and does exactly what it says: it reads the data and posts it to a server the attacker controls. Nobody escaped the sandbox. Nobody broke isolation. The data walked out the front door over an ordinary HTTPS request, and the sandbox helped, because helping code reach the network is its job. That is the exfiltration window, and every code interpreter has it. The only thing standing between model-generated code and an attacker is the network policy. So I stopped theorising and tested it. I wrote two dozen exfiltration payloads, pointed every one at a receiver I control, ran them inside a deny-by-default Tensorlake sandbox, and checked whether any token arrived on my end. None did. But the clean result is not the interesting part. The interesting part is which layer stopped each attempt , because they did not all fail the same way, and two of them never really tested the policy at all. The defense is Tensorlake’s egress policy. A sandbox created with a non-empty allow out list is default-deny : only the destinations you name are reachable, everything else is refused. For this test the allowlist held exactly one entry, the single endpoint a real harness might legitimately need: python from tensorlake.sandbox import Sandbox, NetworkConfigsbx = Sandbox.create name="egress-test", image="tensorlake/ubuntu-minimal", cpus=1.0, memory mb=1024, allow internet access=True, allow out= "api.github.com" , the only thing allowed out api.github.com is reachable. Nothing else is supposed to be. My receiver, a webhook.site endpoint with a matching DNS logger, is deliberately not on the list. It plays the attacker. One detail shapes many of the results: with a non-empty allowlist, DNS itself is default-deny. The sandbox will resolve api.github.com and nothing else. Twenty-four payloads in two families, plus two controls. Each one is a small Python program that runs inside the sandbox and tries to send a unique token to my receiver. If the token shows up on my end, that payload beat the policy. Family A, the obvious channels. The direct ways code phones home: an HTTPS POST to an unlisted domain, a GET with the data in the query string, plain HTTP with no TLS, a raw socket, a connection straight to an IP that skips DNS, a DNS tunnel hiding the payload in a subdomain lookup, curl and wget subprocesses, an image-beacon GET. Family B, the parser-differential class. This is where allowlists actually fail in the real world. An allowlist is a string comparison, and string comparisons can be fooled when two layers disagree about what a hostname is. The template is this year’s Claude Code bypass, where a host like attacker.evil\x00.allowed.com passed a suffix check in one layer while the C resolver truncated it at the null byte and connected somewhere else entirely. So this family throws the whole bag at it: null bytes and CRLF in hostnames, the receiver’s IP encoded as decimal, hex, and octal, an IPv4-mapped IPv6 literal, punycode and userinfo tricks api.github.com@receiver , a trailing-dot FQDN, an uppercased host, DNS-over-HTTPS to sidestep the resolver, and a subdomain glued onto the allowlisted apex. The two controls. A test that only ever says blocked is not measuring anything. One control hits the allowed api.github.com and must succeed. One hits an obviously unlisted host and must fail. The rig records two independent signals per payload, and keeping them separate turned out to be the whole story: Only the second signal decides the outcome. The first is just what your own harness would see, and it is not the same thing. Writing bytes to a socket only means they reached the local network stack, not that they reached the internet. So a payload can report success and still deliver nothing. Five of them did exactly that. If I had trusted the exit code, I would have published a false alarm. for payload in PAYLOADS: sbx.write file "/tmp/p.py", payload.code.encode r = sbx.run "python3", "/tmp/p.py" , timeout=30 observed = last line r.stderr signal 1: what the code saw leaked = receiver.got token payload signal 2: ground truth Every payload, what the code saw, the layer that actually stopped it, and whether the token reached my receiver. That last column is confirmed against the receiver logs, not inferred from the exit code. Not every row is a policy win, so here is the honest tally : The accurate claim is narrow, and I think it’s more convincing for being narrow: no test token reached the receiver, and of the attempts that genuinely exercised the network, the policy blocked every one. The controls behaved. The allowed host answered; the unlisted host did not. A clean sweep is only worth reading if you understand why it swept. There are three distinct walls in that table. The majority died with Temporary failure in name resolution. That is deny-by-default DNS doing its job. Because the allowlist contains only api.github.com, the sandbox refuses to resolve any other hostname, so the attack never even learns the receiver's address. This is also why the clever DNS-over-HTTPS payload failed: to reach a DoH resolver, you first have to resolve the resolver’s own hostname. That’s blocked too. You cannot connect to what you cannot resolve. B02 is worth pausing on. The null-byte host that defeated another sandbox this year didn’t even reach Tensorlake’s policy, because Python’s own http.client rejected it first: URL can't contain control characters The socket-level version, B01, hit the DNS wall instead. Either way the differential never materialised, because there was no second layer with a more permissive parser to disagree with the first. A06 and B06 through B09 all returned exit 0. Every one skips DNS and connects straight to the receiver's raw IP , in decimal, hex, octal, or IPv6-mapped form. Trust the exit code, and the headline writes itself: “IP-address payloads bypass the allowlist.” It would have been wrong. This is the one place the two-signal design earned its keep. So I ran a second sandbox to find out what exit 0 actually meant: php CONF1 HTTP to receiver IP:80 - Connection reset by peerCONF2 TLS + SNI to receiver IP:443 - Connection reset by peerCONF3 bare socket to 1.1.1.1:443 - "CONNECTED"CONF4 bare socket to 8.8.8.8:53 - "CONNECTED"CONF5 https to api.github.com allowed - works That is the shape of a transparent egress proxy. The sandbox terminates outbound TCP locally, so the handshake always appears to succeed, which is why a bare connect to any IP returns exit 0. Enforcement happens one layer up, the moment the connection declares where it’s really going. CONF2 is the proof: the policy read the TLS SNI, saw a host that wasn’t on the allowlist, and cut the connection. The five exit 0 payloads hit that same wall the instant they tried to send anything real. Which is why the receiver logs stayed empty. Inside the sandbox, a successful connect proves nothing. Only the destination knows whether data arrived. Two more checks, run by mutating the policy on the live sandbox. Precedence. With api.github.com in both allow out and deny out at once, the connection was refused. Deny wins over allow , so a standing denylist is a real backstop you can layer under a generous allowlist. Wildcard apex. With only .github.com allowed, api.github.com was reachable but the bare apex github.com was not. Exactly as documented, and exactly the edge people get wrong. And the operational number nobody publishes: tightening the policy on a running sandbox took 0.225 seconds , and the swap is atomic, no window where the sandbox runs briefly unprotected while the new rules apply. That last fact is what makes the pattern below practical. A code interpreter needs the network at two moments, with two different threat models. Because the policy is a live property of a running sandbox rather than a boot-time constant, you can have both from one sandbox: setup phase: open enough to install what the job needssbx = Sandbox.create name="ci", allow internet access=True, allow out= "pypi.org", "files.pythonhosted.org" ubuntu-minimal ships an externally-managed Python PEP 668 , so pip needs --break-system-packages or run inside a venv sbx.run "pip", "install", "--break-system-packages", "pandas" clamp, atomically, right before running model-generated codesbx.update network=NetworkConfig allow internet access=True, allow out= "api.openai.com" now execute the untrusted code with the front door shutsbx.run "python3", "/tmp/model generated.py" The tighten call belongs immediately before the tool call that runs model output, not at session start. Pair a narrow allowlist for what the harness genuinely needs with a standing deny out backstop, since we confirmed deny wins. One long-lived session can cycle between open and locked as many times as it needs. Each transition costs about a fifth of a second and no restart. Egress control is not a complete answer, and a security article that pretends otherwise isn’t worth reading. What you get is a much smaller attack surface during the exact phase when you’re running code you did not write, for the price of one atomic API call. Good software drops privileges the moment it finishes the step that needed them. Agents haven’t been able to do that at the network layer, because the sandbox network policy was fixed when the sandbox was born. Making it a runtime property is what lets an agent hold broad access exactly as long as it’s doing trusted setup, then shed it before it touches anything it did not write. I went in trying to break the deny-by-default posture two dozen ways, including the parser tricks that have beaten other sandboxes this year. Of the attempts that actually reached the network, the policy refused every one, and no token ever arrived at my receiver. The most instructive part was the five payloads that lied about succeeding, because they’re the reason you measure at the destination and never trust the exit code inside the box. The harness is small and the payloads are not secret. Point them at your own setup, and don’t believe a defense you haven’t watched fail. Written using Tensorlake SDK 0.5.121. All output is real, captured from sandboxes running on the free tier. I Tested 24 Exfiltration Attacks Against a Locked-Down AI Sandbox https://pub.towardsai.net/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox-4604052cc582 was originally published in Towards AI https://pub.towardsai.net on Medium, where people are continuing the conversation by highlighting and responding to this story.