{"slug": "i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox", "title": "I Tested 24 Exfiltration Attacks Against a Locked-Down AI Sandbox", "summary": "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.", "body_md": "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.\n\nA user uploads some data. Asks a question. A model writes Python. The sandbox runs it.\n\nThe 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.\n\nSuppose 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.**\n\nNobody 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.\n\n**That is the exfiltration window, and every code interpreter has it.**\n\nThe only thing standing between model-generated code and an attacker is the network policy. So I stopped theorising and tested it.\n\nI 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.\n\n**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.\n\nThe 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.\n\nFor this test the allowlist held exactly one entry, the single endpoint a real harness might legitimately need:\n\n``` python\nfrom 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)\n```\n\napi.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.\n\nOne 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.\n\nTwenty-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.\n\n**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.\n\n**Family B, the parser-differential class.** This is where allowlists actually fail in the real world.\n\nAn 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.\n\nSo 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.\n\n**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.\n\nThe rig records **two independent signals** per payload, and keeping them separate turned out to be the whole story:\n\nOnly the second signal decides the outcome.\n\nThe 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.\n\n**Five of them did exactly that. If I had trusted the exit code, I would have published a false alarm.**\n\n```\nfor 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\n```\n\nEvery 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.\n\nNot every row is a policy win, so here is the **honest tally**:\n\n**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.**\n\nThe controls behaved. The allowed host answered; the unlisted host did not.\n\nA clean sweep is only worth reading if you understand *why* it swept. There are three distinct walls in that table.\n\nThe majority died with Temporary failure in name resolution. That is deny-by-default DNS doing its job.\n\nBecause 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.\n\nThis 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.\n\n**You cannot connect to what you cannot resolve.**\n\nB02 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:\n\n*URL can't contain control characters*\n\nThe 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.\n\nA06 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.\n\nTrust 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.\n\nSo I ran a second sandbox to find out what exit 0 actually meant:\n\n``` php\nCONF1  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\n```\n\nThat 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.\n\nEnforcement 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.\n\nThe five exit 0 payloads hit that same wall the instant they tried to send anything real. Which is why the receiver logs stayed empty.\n\n**Inside the sandbox, a successful** **connect() proves nothing. Only the destination knows whether data arrived.**\n\nTwo more checks, run by mutating the policy on the *live* sandbox.\n\n**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.\n\n**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.\n\nAnd 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.\n\nThat last fact is what makes the pattern below practical.\n\nA code interpreter needs the network at two moments, with two different threat models.\n\nBecause the policy is a *live property* of a running sandbox rather than a boot-time constant, you can have both from one sandbox:\n\n```\n# 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\"])\n```\n\nThe 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.\n\nOne 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.\n\nEgress control is not a complete answer, and a security article that pretends otherwise isn’t worth reading.\n\nWhat 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.\n\nGood software drops privileges the moment it finishes the step that needed them.\n\nAgents 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.\n\nI 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.\n\nThe 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.\n\n*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.*\n\n*Written using Tensorlake SDK 0.5.121. All output is real, captured from sandboxes running on the free tier.*\n\n[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.", "url": "https://wpnews.pro/news/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox", "canonical_source": "https://pub.towardsai.net/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox-4604052cc582?source=rss----98111c9905da---4", "published_at": "2026-09-11 07:21:37+00:00", "updated_at": "2026-09-11 07:58:01.024349+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Tensorlake", "Tensorlake Sandbox", "api.github.com", "webhook.site", "Claude Code", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox", "markdown": "https://wpnews.pro/news/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox.md", "text": "https://wpnews.pro/news/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox.txt", "jsonld": "https://wpnews.pro/news/i-tested-24-exfiltration-attacks-against-a-locked-down-ai-sandbox.jsonld"}}