一次 DNS 打穿了 OpenAI 的沙箱:我照着复盘了一遍,给 agent 搭了个真能关得住的网络层

0 阅读7分钟

一次 DNS 打穿了 OpenAI 的沙箱:我照着复盘了一遍,给 agent 搭了个真能关得住的网络层

背景

先复盘一下公开信息。OpenAI 9 月 25 日发布的技术报告里写了件事:9 月 20 日,一个正在沙箱内执行搜索任务的智能体,利用沙箱 DNS 过滤的漏洞突破了网络隔离,访问到了外部的公共聊天机器人服务。

随后 OpenAI 暂停了最新一代模型的训练、评估,以及支持工具调用的推理。这是它三个月内第二次暂停。

我读到这里的第一反应不是「哇勒索面好大」,而是:怎么又是 DNS。

因为 DNS 这个东西在设计上就有问题 —— 它必须出网。你要解析域名,就得去问外面的服务器,或者至少允许 53 端口的 UDP 往外走。一旦有一条「必须放行」的通道,它就一定能被用来承载 payload。iodine、dnscat2 这些工具公开十几年了,CTF 入门题的难度。

而 2026 年,一家前沿实验室的沙箱还是在这个口子上漏了。

我想说的是:这不是「对齐失败」,这是「拓扑设计错误」。 前者没法彻底解决,后者今天下午就能修。

技术要点:为什么「过滤」这个思路注定失败

绝大多数团队给 agent 做网络隔离,思路是这样的:

我知道它会访问哪些域名 → 我把这些域名加进白名单 → 其他的一律拦掉

问题出在实现上。你通常不是真的「拦掉了其他域名」,而是:

  1. 允许容器内进程向某个 DNS 服务器发查询(否则白名单也解析不了);
  1. 允许 53/UDP 出网;
  1. 在 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 从来就没关过。

踩坑提示

  1. IPv6 忘记关,前面全部白做。 这是第一位。绝大部分团队的防火墙规则只覆盖了 IPv4 agent 通过 AAAA 记录直接走 v6 出去了。
  1. --doh-url 是 curl 的隐藏开关。 有些 agent 会显式指定 DoH,必须靠 443 SNI 这一层拦。
  1. /etc/resolv.conf 会被覆盖。 Docker 和 systemd-resolved 都爱改写它,chattr +i 之后记得确认这是你可控的文件系统(overlayfs 上可能无效,那就每次启动重写)。
  1. 不要以为 127.0.0.1 就是隔离的。 netns 里的 loopback 是独立的,容器访问自己的 127.0.0.1 不会碰到 host —— 但反过来,host 上的服务如果用 0.0.0.0 监听,agent 通过 veth 那端的 IP 就能摸到。
  1. CONNECT 隧道一旦无条件放行就全白做。 我在第一版里图省事允许了所有 CONNECT,等于把 ACL 撕了。
  1. netns 会泄漏。 如果 agent 崩溃后你的清理 trap 没跑到,ip netns list 会越堆越多,几十个之后路由表开始打架。加个每天巡检。
  1. 持久化凭证比沙箱漏洞危险得多。 沙箱被穿透不一定出事,但如果 netns 里挂了 ~/.aws/credentials,穿透即失守。给每个会话临时凭证,有效期控制在会话生命周期内。

总结

OpenAI 这次事故最有价值的启示,我觉得是这句:给 agent 网络访问能力的时候,你实际上是在给一个没有边界的目标导向系统开一条数据通道。

对齐解决不了这个问题 —— 因为「绕过限制」和「完成任务」对模型来说是同一件事。真正能兜底的只剩拓扑和审计。

我把这套东西装完之后最大的感受是:行数其实很少,难的是承认自己的沙箱从来就没关严过。