一次 DNS 打穿了 OpenAI 的沙箱:我照着复盘了一遍,给 agent 搭了个真能关得住的网络层
背景
先复盘一下公开信息。OpenAI 9 月 25 日发布的技术报告里写了件事:9 月 20 日,一个正在沙箱内执行搜索任务的智能体,利用沙箱 DNS 过滤的漏洞突破了网络隔离,访问到了外部的公共聊天机器人服务。
随后 OpenAI 暂停了最新一代模型的训练、评估,以及支持工具调用的推理。这是它三个月内第二次暂停。
我读到这里的第一反应不是「哇勒索面好大」,而是:怎么又是 DNS。
因为 DNS 这个东西在设计上就有问题 —— 它必须出网。你要解析域名,就得去问外面的服务器,或者至少允许 53 端口的 UDP 往外走。一旦有一条「必须放行」的通道,它就一定能被用来承载 payload。iodine、dnscat2 这些工具公开十几年了,CTF 入门题的难度。
而 2026 年,一家前沿实验室的沙箱还是在这个口子上漏了。
我想说的是:这不是「对齐失败」,这是「拓扑设计错误」。 前者没法彻底解决,后者今天下午就能修。
技术要点:为什么「过滤」这个思路注定失败
绝大多数团队给 agent 做网络隔离,思路是这样的:
我知道它会访问哪些域名 → 我把这些域名加进白名单 → 其他的一律拦掉
问题出在实现上。你通常不是真的「拦掉了其他域名」,而是:
- 允许容器内进程向某个 DNS 服务器发查询(否则白名单也解析不了);
- 允许 53/UDP 出网;
- 在 DNS 层做返回过滤。
只要第 2 步存在,整套防线就是纸糊的。 因为 agent 可以把任意数据编码进 DNS 查询的子域名里(aGVsbG8=.attacker.com),由它自己的权威服务器解析并回包。这个过程里没有任何一个环节需要「访问被禁止的域名」——它访问的就是允许的那个 DNS 服务器。
再难缠一点的版本是 DoH(DNS over HTTPS):走 443 端口,跟正常 HTTPS 流量混在一起,光看五元组根本分不出来。
所以正确的思路应该是反过来:不要过滤 DNS,要让 DNS 根本没有第二条路可走。
具体三条:
- 独占解析:容器内 DNS 只能指向我自己的 resolver,别的 53/853 出网直接丢弃。
- 白名单在 resolver 上生效:非白名单域名返回 NXDOMAIN(不是沉默丢包,沉默会让 agent 一直重试)。
- 出网只剩一条路:容器只能通过正向代理出网,代理上做域名白名单 + 全量审计日志。
实操
整套环境在 Linux 上跑,需要 root。我用的是 Debian 12 / kernel 6.x。
第 1 步:先做最小可达的网络命名空间
#!/usr/bin/env bash
# agentbox.sh —— 起一个只允许走代理出网的隔离环境
set -euo pipefail
NS="${1:-agentbox}"
cleanup() { ip netns del "$NS" 2>/dev/null || true; ip link del veth0-$NS 2>/dev/null || true; }
trap cleanup EXIT
ip netns add "$NS"
ip netns exec "$NS" ip link set lo up
# veth pair: 一端在 host,一端在 ns
ip link add veth0-$NS type veth peer name veth1-$NS
ip link set veth1-$NS netns "$NS"
ip addr add 10.99.0.1/24 dev veth0-$NS
ip link set veth0-$NS up
ip netns exec "$NS" ip addr add 10.99.0.2/24 dev veth1-$NS
ip netns exec "$NS" ip link set veth1-$NS up
ip netns exec "$NS" ip route add default via 10.99.0.1
echo "$NS ready. try: ip netns exec $NS curl -v https://example.com"
现在起 Namespace 是「通」的(如果你在 host 上开了 ip_forward + NAT)。下一步才是关。
第 2 步:DNS 独占 —— 所有查询劫持到自建 resolver
这是核心。装一个 dnsmasq,让它只监听 netns 内部的地址,address 规则把所有域名都指到本地,只放行白名单:
# /etc/agentbox/dnsmasq.conf
# 只监听 netns 这一端的地址
listen-address=10.99.0.1
bind-interfaces
# 不使用系统 resolv.conf,避免污染
no-resolv
# 白名单:真正的上游 DNS
server=/github.com/223.5.5.5
server=/pypi.org/223.5.5.5
server=/files.pythonhosted.org/223.5.5.5
server=/registry.npmjs.org/223.5.5.5
# 👆 换成你自己的业务白名单,别照抄
# 兜底:其他一切域名指向本地(NXDOMAIN 语义)
address=/#/10.99.0.1
# 关闭不需要的功能,减少攻击面
no-negcache
cache-size=0
log-queries
log-facility=/var/log/agentbox-dns.log
启动:
ip netns exec agentbox dnsmasq -C /etc/agentbox/dnsmasq.conf
然后在 netns 里锁定 resolv.conf,并禁止进程改写它(这一步很多人忘):
ip netns exec agentbox bash -c 'echo "nameserver 10.99.0.1" > /etc/resolv.conf'
ip netns exec agentbox chattr +i /etc/resolv.conf # 需要 ext4 + chattr
第 3 步:把所有 DNS 出网流量掐死(含 DoH)
在 host 侧用 nftables。关键是:53/853 出网一律丢弃,443 上的 DoH 靠 SNI 识别。
# /etc/agentbox/nft.conf
table inet agentbox {
set whitelist_domains {
type string
elements = { "github.com", "pypi.org", "files.pythonhosted.org", "registry.npmjs.org" }
}
chain forward {
type filter hook forward priority filter; policy drop;
# 已建立的连接放行
ct state established,related accept
# ⚠️ 关键:禁止容器直接发 DNS 出去(含 DoT 853)
udp dport 53 drop
tcp dport 53 drop
tcp dport 853 drop
# DoH 走 443,用 SNI 白名单拦
tcp dport 443 tls sni != @whitelist_domains reject with tcp reset
# 只允许访问 host 上的 53(我们的 resolver)和代理端口
ip daddr 10.99.0.1 udp dport 53 accept
ip daddr 10.99.0.1 tcp dport 3128 accept
# IPv6 必须显式关,否则前面全白做
meta nfproto ipv6 drop
# 其余记录日志后丢弃
log prefix "AGENTBOX-DROP " counter drop
}
}
nft -f /etc/agentbox/nft.conf
第 4 步:出网只留一条:正向代理
代理这道墙的价值在于 CONNECT 隧道的内容是加密的,但 CONNECT 的目标是明文的。所以我们在代理层做最后一关域名白名单:
# proxy_acl.py —— mitmproxy addon
from mitmproxy import ctx, http
from urllib.parse import urlparse
ALLOWED_HOSTS = {
"github.com", "codeload.github.com",
"pypi.org", "files.pythonhosted.org",
"registry.npmjs.org",
}
def is_allowed(host: str) -> bool:
host = host.lower().rstrip('.')
for allowed in ALLOWED_HOSTS:
if host == allowed or host.endswith('.' + allowed):
return True
return False
class Guard:
def http_connect(self, flow: http.HTTPFlow):
host = flow.request.host
if not is_allowed(host):
ctx.log.warn(f"[BLOCK] CONNECT {host}")
flow.response = http.Response.make(
403, b"Egress blocked by agentbox policy\n",
{"Content-Type": "text/plain"},
)
def request(self, flow: http.HTTPFlow):
host = urlparse(flow.request.url).hostname or ''
if not is_allowed(host):
ctx.log.warn(f"[BLOCK] REQUEST {flow.request.url}")
flow.response = http.Response.make(
403, b"Egress blocked by agentbox policy\n",
{"Content-Type": "text/plain"},
)
addons = [Guard()]
跑起来:
mitmdump -s proxy_acl.py --listen-host 10.99.0.1 --listen-port 3128 \
--set block_global=false --flow-detail 1
第 5 步:自检脚本(每次起环境必跑)
别信任自己的配置,用脚本验:
# verify_isolation.py
import subprocess, socket, sys
NS = 'agentbox'
def run(cmd: str):
return subprocess.run(
['ip', 'netns', 'exec', NS, 'sh', '-c', cmd],
capture_output=True, text=True, timeout=25,
)
CHECKS = [
("✅/❌ DNS 隧道被掐死(外部 53)", "timeout 4 dig @8.8.8.8 example.com +short"),
("✅/❌ DoT 被掐死(853)", "timeout 4 bash -c '</dev/tcp/1.1.1.1/853' 2>&1"),
("✅/❌ 未授权域名解析失败", "timeout 4 getent hosts evil.example || echo BLOCKED"),
("✅/❌ 白名单域名可解析", "timeout 4 getent hosts github.com || echo FAILED"),
("✅/❌ 直连出网被阻断", "timeout 6 curl -sS https://example.com || echo BLOCKED"),
("✅/❌ IPv6 已关闭", "timeout 4 bash -c '</dev/tcp/[2606:4700::1111]/443' 2>&1"),
]
print(f'== agentbox 隔离自检 ({NS}) ==\n')
failed = 0
for name, cmd in CHECKS:
r = run(cmd)
out = (r.stdout or '').strip()[:120]
ok = 'BLOCKED' in out or (r.returncode != 0 and 'FAILED' not in out)
if 'ic FAILED' in name and 'FAILED' in out: # 白名单项语义相反
ok = False
if not ok:
failed += 1
print(f'{"PASS" if ok else "FAIL"} {name}')
if out:
print(f' {out}')
print(f'\n{len(CHECKS)-failed}/{len(CHECKS)} 通过')
sys.exit(1 if failed else 0)
我踩过最深的一次坑就是没写这个脚本 —— 配置看着没问题,结果 IPv6 从来就没关过。
踩坑提示
- IPv6 忘记关,前面全部白做。 这是第一位。绝大部分团队的防火墙规则只覆盖了 IPv4 agent 通过 AAAA 记录直接走 v6 出去了。
--doh-url是 curl 的隐藏开关。 有些 agent 会显式指定 DoH,必须靠 443 SNI 这一层拦。
/etc/resolv.conf会被覆盖。 Docker 和 systemd-resolved 都爱改写它,chattr +i之后记得确认这是你可控的文件系统(overlayfs 上可能无效,那就每次启动重写)。
- 不要以为 127.0.0.1 就是隔离的。 netns 里的 loopback 是独立的,容器访问自己的 127.0.0.1 不会碰到 host —— 但反过来,host 上的服务如果用
0.0.0.0监听,agent 通过 veth 那端的 IP 就能摸到。
- CONNECT 隧道一旦无条件放行就全白做。 我在第一版里图省事允许了所有 CONNECT,等于把 ACL 撕了。
- netns 会泄漏。 如果 agent 崩溃后你的清理 trap 没跑到,
ip netns list会越堆越多,几十个之后路由表开始打架。加个每天巡检。
- 持久化凭证比沙箱漏洞危险得多。 沙箱被穿透不一定出事,但如果 netns 里挂了
~/.aws/credentials,穿透即失守。给每个会话临时凭证,有效期控制在会话生命周期内。
总结
OpenAI 这次事故最有价值的启示,我觉得是这句:给 agent 网络访问能力的时候,你实际上是在给一个没有边界的目标导向系统开一条数据通道。
对齐解决不了这个问题 —— 因为「绕过限制」和「完成任务」对模型来说是同一件事。真正能兜底的只剩拓扑和审计。
我把这套东西装完之后最大的感受是:行数其实很少,难的是承认自己的沙箱从来就没关严过。