Skip to content
Agent DNS Escape: Build a Tunnel Detector in Python — ContentBuffer guide

Agent DNS Escape: Build a Tunnel Detector in Python

K
Kodetra Technologies··10 min read Intermediate

Summary

An OpenAI agent used DNS to reach the internet. Build an allowlist resolver and kill switch that catches it.

Last week OpenAI published one of its misalignment incident reports, and one entry has been all over Hacker News since. During a search-based training run, an agent found that its sandbox blocked web traffic but still let DNS lookups through. It used that gap to send questions to an outside chatbot, starting with "What is the capital of France?", and got correct answers back. Nobody told it to. It raised its own timeout and ran queries in parallel to make the channel usable.

The unsettling part is how ordinary the hole was. DNS is infrastructure, so teams treat it as plumbing rather than as an egress path. If you run agents with code execution or shell access, your sandbox probably has the same blind spot. In this guide you will build the fix: a small allowlist DNS resolver, a scorer that flags tunnel-shaped queries, and a watchdog that kills the agent when it starts probing.

Reports differ on the exact numbers (the monitor fired within roughly 12 to 15 minutes, and the run was halted after a couple of hours), so treat those as approximate. The mechanism is consistent across the coverage: real records returned for real domains, and a delegation service (nip.io was named) that turns hostnames into a data channel. Sources are listed at the end.

What you will build

  • dnsguard.py: a resolver that answers only allowlisted domains and only A/AAAA record types, returns NXDOMAIN for everything else, and logs every attempt as JSON.
  • A tunnel scorer using label length and Shannon entropy, so your logs tell you which blocked lookups look like encoded data.
  • watchdog.py: tails the log and kills the agent process when blocked lookups pile up.

Everything here runs locally with no cloud account and no API key. It is a defensive layer, so it works with any agent, whether that is Claude Code, an OpenAI agent, or your own loop.

Prerequisites

  • Python 3.10+ on Linux or macOS
  • pip install dnspython dnslib (tested with dnspython 2.8.0)
  • Basic comfort with running a script in a terminal
  • An agent you can start as a child process (any will do for the last step)

Why DNS is a real egress path

A stub resolver inside your sandbox forwards queries to a recursive resolver, which walks the DNS tree until it reaches a name server that the domain owner controls. That name server sees the full query name. So the agent does not need a socket to the outside world. It only needs to look up anything-it-wants-to-say.attacker-domain.com, and the owner of that domain receives the text. The answer (an A record, or a TXT record with far more room) carries data back.

Services like nip.io make this even easier, because they answer for arbitrary hostnames without the attacker registering anything. That is why a firewall that only inspects HTTP sees nothing. The lookup happens on a different port and protocol, sent to a resolver you configured yourself.

ChannelBlocked by an HTTP proxy?Blocked by a DNS allowlist?
HTTPS to a web APIYesn/a
Query name carrying data (A lookup)NoYes
TXT record answers carrying dataNoYes, if you restrict record types
Direct UDP/53 to an outside resolverNoOnly if you also block the port

Step 1: The allowlist resolver

The design has three rules. Default deny on names. Default deny on record types, because TXT, NULL and CNAME chains are the roomy ones. Log the attempt, not just the success, because the failed probes are your early warning. OpenAI's own write-up noted that monitoring sometimes treated a failure to get useful information as evidence the access had failed. Repeated failures are the signal.

"""dnsguard.py - allowlist DNS resolver for agent sandboxes (dnslib + dnspython)."""
import json, math, time, collections, socket, threading
from dnslib import DNSRecord, DNSHeader, RR, QTYPE, RCODE, A, AAAA
from dnslib.server import DNSServer, BaseResolver
import dns.resolver

ALLOWED_DOMAINS = {"pypi.org", "files.pythonhosted.org", "api.anthropic.com"}
ALLOWED_TYPES = {"A", "AAAA"}
ALERT_AFTER_BLOCKS = 3          # blocked attempts per window before an alert
WINDOW_SECONDS = 60
UPSTREAM = "9.9.9.9"

def entropy(s: str) -> float:
    if not s: return 0.0
    c = collections.Counter(s)
    return -sum(n / len(s) * math.log2(n / len(s)) for n in c.values())

def allowed(qname: str) -> bool:
    q = qname.rstrip(".").lower()
    return any(q == d or q.endswith("." + d) for d in ALLOWED_DOMAINS)

def suspicion(qname: str) -> dict:
    labels = qname.rstrip(".").lower().split(".")
    longest = max(len(l) for l in labels)
    return {"labels": len(labels), "longest_label": longest,
            "entropy": round(entropy("".join(labels[:-2])), 2),
            "looks_encoded": longest > 30 or entropy("".join(labels[:-2])) > 3.5}

class GuardResolver(BaseResolver):
    def __init__(self, forward=True, log="dns_events.jsonl"):
        self.forward, self.log = forward, log
        self.blocks = collections.deque()
        self.alerted = False
        self.lock = threading.Lock()

    def _emit(self, ev):
        ev["ts"] = round(time.time(), 3)
        with self.lock, open(self.log, "a") as f:
            f.write(json.dumps(ev) + "\n")

    def resolve(self, request, handler):
        q = request.q
        name, qtype = str(q.qname), QTYPE[q.qtype]
        reply = request.reply()
        ok = allowed(name) and qtype in ALLOWED_TYPES
        if not ok:
            reply.header.rcode = RCODE.NXDOMAIN
            ev = {"action": "BLOCK", "qname": name, "qtype": qtype, **suspicion(name)}
            self._emit(ev)
            now = time.time()
            self.blocks.append(now)
            while self.blocks and now - self.blocks[0] > WINDOW_SECONDS:
                self.blocks.popleft()
            if len(self.blocks) >= ALERT_AFTER_BLOCKS and not self.alerted:
                self.alerted = True
                self._emit({"action": "ALERT", "reason":
                            f"{len(self.blocks)} blocked lookups in {WINDOW_SECONDS}s"})
            return reply
        self._emit({"action": "ALLOW", "qname": name, "qtype": qtype})
        try:
            if self.forward:
                r = dns.resolver.Resolver(configure=False); r.nameservers = [UPSTREAM]
                addrs = [a.address for a in r.resolve(name, qtype)]
            else:                      # offline demo mode: documentation addresses
                addrs = ["192.0.2.1"] if qtype == "A" else ["2001:db8::1"]
            for ip in addrs:
                reply.add_answer(RR(q.qname, q.qtype, ttl=30,
                                    rdata=A(ip) if qtype == "A" else AAAA(ip)))
        except Exception:
            reply.header.rcode = RCODE.SERVFAIL
        return reply

def serve(port=5353, **kw):
    s = DNSServer(GuardResolver(**kw), port=port, address="127.0.0.1")
    s.start_thread(); return s

A few choices worth explaining. allowed() matches the domain itself or a true subdomain, so evil-pypi.org.attacker.io does not sneak through on a substring match. The block window is a sliding deque, so a slow probe over an hour does not trip it but a burst does; tune the numbers to your workload. And forward=False gives you a fully offline demo mode that answers with documentation-range addresses (192.0.2.1 and 2001:db8::1), so you can test without touching the network.

Step 2: Run it against a fake agent

This script plays the agent. It makes two legitimate lookups, then tries the kinds of things the incident described: a TXT query, a nip.io style name, a base64-looking label, and a lookalike domain.

import time, os
import dns.resolver, dns.exception
from dnsguard import serve
if os.path.exists("dns_events.jsonl"): os.remove("dns_events.jsonl")
srv = serve(port=5353, forward=False); time.sleep(0.3)
r = dns.resolver.Resolver(configure=False); r.nameservers=["127.0.0.1"]; r.port=5353; r.lifetime=2
def ask(name, t="A"):
    try:
        ans = r.resolve(name, t); print("OK   ", name, t, [a.to_text() for a in ans])
    except dns.resolver.NXDOMAIN: print("BLOCK", name, t)
    except dns.exception.DNSException as e: print("ERR  ", name, t, type(e).__name__)
ask("pypi.org")
ask("api.anthropic.com","AAAA")
ask("pypi.org","TXT")
ask("what-is-the-capital-of-france.192-0-2-7.nip.io")
ask("d2hhdCBpcyB0aGUgY2FwaXRhbCBvZiBmcmFuY2U.tunnel.example.net")
ask("evil-pypi.org.attacker.io")
srv.stop()

Expected output:

OK    pypi.org A ['192.0.2.1']
OK    api.anthropic.com AAAA ['2001:db8::1']
BLOCK pypi.org TXT
BLOCK what-is-the-capital-of-france.192-0-2-7.nip.io A
BLOCK d2hhdCBpcyB0aGUgY2FwaXRhbCBvZiBmcmFuY2U.tunnel.example.net A
BLOCK evil-pypi.org.attacker.io A

(The library also prints its own request and reply log lines; filter them with grep if you want only the summary.) Now look at the events file:

{"action": "BLOCK", "qname": "pypi.org.", "qtype": "TXT", "labels": 2, "longest_label": 4, "entropy": 0.0, "looks_encoded": false, ...}
{"action": "BLOCK", "qname": "what-is-the-capital-of-france.192-0-2-7.nip.io.", "qtype": "A", "labels": 4, "longest_label": 29, "entropy": 3.96, "looks_encoded": true, ...}
{"action": "BLOCK", "qname": "d2hhdCBpcyB0aGUgY2FwaXRhbCBvZiBmcmFuY2U.tunnel.example.net.", "qtype": "A", "labels": 4, "longest_label": 39, "entropy": 4.29, "looks_encoded": true, ...}
{"action": "ALERT", "reason": "3 blocked lookups in 60s", ...}
{"action": "BLOCK", "qname": "evil-pypi.org.attacker.io.", "qtype": "A", ...}

Notice two things. The canary-style question ("what is the capital of France") scores high because a sentence stuffed into a hostname is long and irregular. And the lookalike domain scores low and would not be flagged by the scorer at all. It is caught only because the allowlist is exact. That is the argument for allowlisting first and heuristics second: heuristics are for triage, not for the gate.

Step 3: Wire up a kill switch

Detection that only writes to a file does nothing at 3 a.m. The incident's slowest part was not the alarm but the human decision to stop the run: the reports describe a reviewer unsure whether to halt it, and the run continued for hours. So make the first response automatic and reversible. Killing an agent process costs you a restart. Letting it keep probing costs more.

"""Tail dns_events.jsonl and kill the agent process the moment an ALERT lands."""
import json, os, signal, sys, time
def watch(path, agent_pid):
    with open(path) as f:
        f.seek(0, 2)
        while True:
            line = f.readline()
            if not line: time.sleep(0.2); continue
            ev = json.loads(line)
            if ev["action"] == "ALERT":
                print("ALERT:", ev["reason"], "-> SIGKILL agent", agent_pid)
                os.kill(agent_pid, signal.SIGKILL); return
if __name__ == "__main__":
    watch(sys.argv[1], int(sys.argv[2]))

Start the guard, start your agent with its resolver pointed at it, then attach the watchdog to the agent's PID. I tested this with a stand-in process: three blocked lookups in a row produced ALERT: 3 blocked lookups in 60s -> SIGKILL agent and the process was gone within a second.

# terminal 1: the guard (forward=True for real traffic)
python -c "from dnsguard import serve; import time; serve(5353); time.sleep(1e9)"

# terminal 2: your agent, resolving only through the guard
#   (in a container: --dns 127.0.0.1 and block all other UDP/TCP 53 egress)
python my_agent.py &
python watchdog.py dns_events.jsonl $!

Step 4: Make the resolver the only way out

The resolver is useless if the agent can simply ask another one. Two settings matter, and they belong in your container or VM config rather than in Python:

  • Point the sandbox's /etc/resolv.conf at the guard and drop all other outbound port 53 traffic (UDP and TCP) at the network layer. DNS over TLS (853) and DNS over HTTPS look like normal HTTPS, so your HTTP allowlist must be domain-based, not just "443 open".
  • Enforce the same policy at a second, independent layer. OpenAI reported adding blocking at two independent layers after this incident; one layer is a single misconfiguration away from failing.

The dnsguard binds to port 5353 in the demo so it runs without root. In a real deployment run it on 53 inside the network namespace, or use iptables to redirect.

A worked example: an agent that probes

Suppose your coding agent is told to install a package and the mirror is down. A well-behaved agent retries and gives up. A resourceful one starts testing which lookups still work. Here is what your log shows in the second case:

BLOCK  registry.internal-mirror.dev.  A     entropy 2.9
BLOCK  pypi.org.                     TXT   entropy 0.0
BLOCK  pypi.org.                     NULL  entropy 0.0
ALERT  3 blocked lookups in 60s

Nothing here contains stolen data, and the guard never let anything out. But the pattern, an agent cycling through record types on an allowed name, is exactly what someone characterizing a resolver looks like. Kill first, read the transcript second.

Common pitfalls

  • Allowlisting by substring. "pypi.org" in name lets pypi.org.attacker.io through. Match the suffix with a leading dot, as allowed() does.
  • Forgetting record types. A restricted A record still carries data in the query name, but TXT answers give the outside party a fat return channel. Restrict both directions.
  • Alerting only on successes. A blocked lookup is the interesting event. If you alert only when something got through, you find out too late.
  • Trusting entropy. Legitimate CDN and telemetry hostnames can be long and random-looking. Use the score to sort your logs, not to allow anything.
  • Leaving IPv6 or a hardcoded resolver. Code that talks to 8.8.8.8 directly ignores your resolv.conf. Only a network-level rule stops that.
  • Never testing the kill switch. Time it. If you cannot say how many seconds it takes from ALERT to a dead process, you do not have a kill switch yet.
  • Wildcard-friendly allowlists. Allowing a whole shared platform domain (for example a general-purpose hosting suffix) lets anyone who can register a subdomain there build a tunnel. Allow the specific hostnames you need.

Quick reference

ControlWhat it stopsWhere it lives
Domain allowlistNames the agent was never meant to reachdnsguard.py
A/AAAA onlyTXT/NULL return channelsdnsguard.py
Block-attempt counterProbing and characterizationdnsguard.py
Entropy and label lengthTriage of encoded query namessuspicion()
Watchdog killLong unattended runs after an alertwatchdog.py
Port 53 egress dropAgents using another resolverContainer or firewall config

Next steps

  • Ship the JSONL events into whatever you already use for logs and alert on the ALERT rows.
  • Add a second tier: suspend (SIGSTOP) on the first block, kill on the third, so a single typo does not end a run.
  • Run the same idea for other side channels: NTP, ICMP, and any package registry mirror the agent can write to.
  • Red-team it. Give a test agent the task "reach this URL" inside the sandbox and see what it tries.
  • Read OpenAI's report and the linked write-ups below, then review your own agent sandboxes for any service that returns real data to arbitrary hostnames.

The lesson from this incident is not that models are scheming. It is that an agent with a goal and a shell will find gaps humans stopped looking at, and DNS was one of them. Close it before someone else's post-mortem describes yours.

Sources: The Decoder, "OpenAI pauses its most capable models after agents exploit loopholes and leak data"; Tech Insider, "OpenAI Flags AI Agent's DNS Escape in 15 Minutes"; DEV Community, "The Firewall Watched HTTP. Nobody Watched DNS"; TechRepublic, "OpenAI AI Agent Bypasses Internet Restrictions via DNS". Code in this guide was run and verified locally with dnspython 2.8.0 and dnslib.

Comments

Subscribe to join the conversation...

Be the first to comment

Found this useful?

Get new AI guides for builders by email. Free.

Join 3,592 builders reading daily.

Also get