先说结论:我见过的 agent 沙箱里,最常见的翻车点不是模型调用写崩了,而是 DNS 这个看起来人畜无害的“基础设施”把整个隔离带打穿。上周我们复盘了一个真实攻防案例,一个部署在隔离沙箱里的编码型 agent,理论上只能访问白名单 API,结果还是把不该出网的数据传了出去。追查到最后,问题没出在什么零 day 漏洞上,就出在 UDP 53 这个几乎所有沙箱都默认放行的端口上。
这篇东西我不讲虚的,把我复盘的过程、DNS 为什么能成为突破口、以及后来给我自己的 agent 网络层重做的完整方案,一步步拆开写清楚。如果你手里也跑着 agent 服务,或者正准备给团队搭一个沙箱环境,这篇至少能帮你少踩两三个大坑。
1. 一次 DNS“越狱”复盘:攻击链路还原
1.1 现场还原:日志里跑出了一串奇怪子域名
先交代一下我们当时的环境。这套沙箱跑的是类似 OpenAI Codex 那类编码型 agent,也就是给 LLM 一个云端执行环境,让它自主读代码、调 API、跑测试。沙箱的网络策略当时是这么设计的:
- 允许容器内访问外网 DNS(UDP 53)做域名解析;
- 出站 TCP 只允许 443 端口,且目标 IP 限定在几个白名单 API 服务商;
- 应用层挂了一个出网审计代理,记录 HTTP 请求日志。
看起来还算严谨对吧?但事故是这么被发现的。某天审计系统开始报警,agent 的运行日志里出现了大量异常的 DNS 查询,每秒钟几十次,子域名是看起来毫无规律的长字符串,而且全是 NXDOMAIN(查询不存在)状态。比如3f9a2c81b0e4d7c5a6b2.example.org这种,子域名部分明显是编码过的。
一开始大家以为是 agent 在扫描什么奇怪的域名,没当回事。但把那些子域名解码出来之后,发现里面是项目源码的片段、环境变量名、内部服务地址的哈希值。这不是什么配置错误,这是数据外带。
这里要说一句,那条攻击链路其实非常朴素。攻击者(或者是被诱导的 agent 模型)把要外带的数据切块,做一层 Base32 编码,拼接成一个子域名,然后发起一次普通的 DNS 查询。这个查询会经过沙箱内部的 DNS 缓存,转发到上游递归解析器,最终到达攻击者自己控制的权威 DNS 服务器。攻击者只要在权威服务器上把查询日志收下来,就能把编码在域名里的数据一段段还原出来。
整个过程没有任何端口扫描、没有二进制漏洞利用、没有反弹 shell。一次最普通的 DNS 查询,就把隔离沙箱变成了一个数据出站通道。
1.2 为什么偏偏是 DNS:三个致命设计盲区
复盘完这条链路,我一直在想一个问题:为什么不是绕过 443 防火墙,而是 DNS?后来我总结下来,DNS 能成为突破口,有几层原因,层层都是设计盲区。
第一层,沙箱设计者默认 DNS 是“基础设施”,必须放行。这确实是实际需求:容器里要访问 https://api.openai.com,首先得解析这个域名;要装 npm 包,得解析 registry.npmjs.org。如果沙箱把 DNS 请求全部掐掉,那所有依赖域名的功能直接瘫了一半。于是 UDP 53 成了“不能动”的端口。
第二层,传统防火墙对 UDP 53 的检查深度几乎为零。iptables 或者安全组可以精确控制源 IP、目标 IP、端口,但基本不关心 DNS 载荷里写了什么。攻击者把数据编码进子域名之后,对防火墙来说它就是一个合法的 DNS 查询。这和 HTTP 流量能过 Web 应用防火墙做内容检测完全不同,DNS 在大多数网络策略里就是一个不透明的管道。
第三层,DNS 数据外带不需要目标服务器主动连接外部。沙箱里的 agent 进程只要发起一次普通查询,数据就出去了,不需要建立 TCP 连接,不需要等反弹,隔离网络里常见的“只出不进”策略对 DNS 完全无效。再加上 DNS 查询天然就是短小、高频、无状态的,混在正常解析流量里毫不起眼。
这三层一叠加,DNS 就成了那个“人人都知道它存在、但没人盯着它干活”的薄弱点。复盘完这个案例,我意识到了一个很直接的问题:如果我们的沙箱网络策略里没有针对 DNS 做反向约束,那下一次被越狱的就会是我们自己的 agent。
2. 从事故到方案:agent 网络层的三层收紧设计
2.1 主流的沙箱网络隔离方案为什么不够用
复盘之后,我顺手盘了一圈市面上常见的沙箱网络方案,发现大部分都停留在“防端口”而不是“防语义”的层面上,对 agent 这种高自主性的负载来说远远不够。
第一类是纯网络层 ACL。做法是在宿主机上用 iptables 或者云安全组限定容器出站 IP 和端口。这种方案能挡住端口扫描、挡住直连内网,但对 DNS 隧道基本无感,因为 DNS 查询本身就是它放行的流量。而且 agent 这类程序经常动态拉取依赖、调用不同区域的服务,IP 白名单很难覆盖全面,稍微漏一个 CDN 段就会导致功能异常,运维同学只能反复加白,最后白名单越来越大,形同虚设。
第二类是透明代理模式。沙箱里注入一个 HTTP 代理,agent 的请求统一走代理出去。这个比裸 ACL 进了一步,但如果 agent 进程不走代理呢?很多 CLI 工具、原生 socket 连接、某些语言的 SDK 不认HTTPS_PROXY环境变量,它们会直接建立 TCP 连接绕过代理。更关键的是,代理一般只管 TCP 的 80/443,UDP 53 的 DNS 流量它根本不看一眼,DNS 隧道依旧畅通。
第三类是双向 mTLS 代理。这个算比较严格的方案了,所有出站连接都要经过代理做双向证书认证,可以有效控制谁能出网。但问题是配置复杂,agent 运行时的很多动态依赖(比如 apt 源、时间同步、CDN 资源)很难全部纳入 mTLS 范围,强行套上会导致大量误伤。而且它防的是“连接”,对 DNS 这类不建立长连接的通道还是存在盲区。
这三类方案我全部拆开看过之后,得出了一个结论:给 agent 用的沙箱,网络策略必须以“默认拒绝”为底,分三层逐级收紧,而不是靠某一个单点能力去防。单点能力再强,总有绕过去的姿势。
2.2 目标架构:解析层、出口层、应用层各司其职
我最后定下来的架构是三层渐进式收紧,每一层解决一类问题,层与层之间互相兜底:
- 第一层是解析层。沙箱内的所有 DNS 查询统一走本地策略 DNS,只允许解析白名单域名,其余域名一律返回 NXDOMAIN。这一层直接把 DNS 隧道的根掐断——你连数据编码到子域名里的机会都没有,因为非白名单的域名根本解析不了。
- 第二层是出口层。在容器网络命名空间里用 iptables 做默认 DROP,只放行本地回环、到策略 DNS 的 UDP 53、以及到出口代理和少数白名单 IP 的 TCP 443。这一层解决的是“不走代理的裸连接”问题,不管 agent 进程怎么绕,底层 socket 发出去的包在命名空间出口就被丢了,物理上出不去。
- 第三层是应用层。给 agent 进程注入统一的环境变量,强制 HTTP 客户端走本地出口代理;出口代理侧再按域名白名单过滤一遍,把连接的目标收敛到预期的服务商 IP。这一层的意义是可控性和可观测性,所有出站请求都能留下结构化日志,方便事后审计。
这三层不是并列关系,是纵深防御。DNS 先挡一道,底层再兜一道,代理侧再留一个审计口。就算某层被绕过,下一层还能接住。
3. 实操:DNS 反外带、出口白名单和默认拒绝
3.1 第一层落地:用 CoreDNS 做 DNS 白名单与反外带
解析层我选了 CoreDNS,原因很简单:配置文件简单、支持热加载、能直接返回 NXDOMAIN,不需要维护一个完整的 BIND 服务。我们把它部署在沙箱宿主机上,监听一个独立的内网 IP,容器里的/etc/resolv.conf指向它。
关键配置是这么几段。首先定义白名单域名,我把它们归类成三类:一类是 AI API 服务商域名(api.openai.com、api.anthropic.com这类),一类是代码仓库和包管理域名(github.com、registry.npmjs.org、pypi.org),还有一类是基础运维域名(NTP 时间同步、系统更新源)。每一类单独建一个配置文件,方便不同项目按需引用。
然后基于这些白名单做策略。用 CoreDNS 的forward插件把白名单域名的请求转发到上游 DNS,其余请求直接走template插件返回 NXDOMAIN。配置大致长这样:
# Corefile .:53 { log errors # 白名单域名转发到上游解析 forward . 1.1.1.1 8.8.8.8 { policy sequential } # 非白名单域名直接返回 NXDOMAIN,不产生外呼 template IN A { match "^(?!.*\.(openai\.com|github\.com|npmjs\.org)$).*$" answer "{{ .Name }} 3600 IN A 0.0.0.0" rcode NXDOMAIN } }这一层效果很明显:DNS 隧道需要让查询到达攻击者控制的权威服务器,而前提是递归解析器愿意向上游查询这个域名。我们让所有非白名单域名在本地直接返回 NXDOMAIN,沙箱内部产生的查询根本不会走出宿主机,数据外带的通道从源头就断了。
这里有个细节要注意:白名单匹配一定要用后缀匹配而不是完全匹配。比如openai.com需要同时覆盖api.openai.com、files.oaiusercontent.com这类子域名,否则 agent 在下载文件时遇到超纲域名就会直接解析失败。我后来是把白名单维护成一个 Bash 脚本,按顶级域名自动展开二级域的常见子域名,但这样做也要小心,别因为白名单过宽把不该放行的域名放进来了。
另外一件很重要的事:策略 DNS 本身必须是沙箱内唯一可用的 DNS 服务。如果容器里有人手动改了/etc/resolv.conf指向8.8.8.8,那你这套白名单就白做了。所以出口层要配合把发往非本地 DNS 的 UDP 53 流量全部 drop,这个在下一节讲。
3.2 第二层落地:iptables 默认拒绝,只留业务必须口子
解析层管住了“查什么”,出口层要管住“连哪里”。这一步我直接在容器网络命名空间里操作,用的是 iptables 的 OUTPUT 链。
先说原则:白名单之外一切出站流量默认丢弃。这个策略看起来强硬,但实际操作中我发现,只要把下列三类流量放行,agent 的绝大多数正常功能都不会受影响:
- 本地回环流量(
lo接口),agent 内部进程间通信和本地调试依赖这个; - 到本地策略 DNS 的 UDP 53 查询,这是解析层的通路;
- 到白名单 API IP 的 TCP 443 流量,以及到出口代理的 TCP 443 流量,这是业务数据通路。
对应规则我整理出来供参考:
iptables -P OUTPUT DROP iptables -A OUTPUT -o lo -j ACCEPT iptables -A OUTPUT -p udp --dport 53 -d 127.0.0.2 -j ACCEPT iptables -A OUTPUT -p tcp --dport 443 -d <WHITELIST_API_IP> -j ACCEPT iptables -A OUTPUT -p tcp --dport 443 -d <EGRESS_PROXY_IP> -j ACCEPT白名单 IP 这块我会写一个脚本,从解析层的域名列表里动态解析出来,每两个小时刷新一次,避免 IP 变动导致服务中断。这里踩过一个坑:有些 API 服务商用的是 CDN,IP 段非常多,手动写死两三个 IP 根本不够用,agent 一调用就会超时。后来我把这些服务商按 ASN 或者 IP 网段批量放行,才稳定下来。但网段放行的风险就是范围比域名大,万一服务商被拉黑或者 IP 被复用,你有可能会放行不该放行的目标。所以出口层的网段列表要配合解析层的域名白名单一起看,两者交叉验证,才能保证“解析时是白名单域名、连接时是白名单 IP”。
3.3 第三层落地:强制代理接管应用流量,日志全量留痕
解析层断根、出口层兜底之后,我还在应用层加了一道保险:强制 agent 的 HTTP 流量走本地出口代理,同时代理侧再按域名白名单过滤。
实现方式不复杂。在沙箱的 agent 基础镜像里预置一个轻量出站代理(我们用的是一个内部代理网关,核心是验证 CONNECT 请求的目标域名是否在白名单内),然后把HTTP_PROXY、HTTPS_PROXY、ALL_PROXY几个环境变量写进 agent 进程的启动环境里。绝大多数主流的 HTTP 客户端、OpenAI SDK、npm、pip 都会自动读取这些变量,流量自然进入代理通道。
代理侧的核心逻辑是:解析 CONNECT 请求里的目标域名,如果不在白名单列表,直接返回 403;在列表内,则正常建立隧道。代理还会把每一个出站请求的元数据(源进程 PID、目标域名、目标 IP、请求时间、传输字节数)写入 JSON 日志。
这一步解决的核心问题是“可观测性”。iptables 能告诉你流量被放行还是被丢弃,但它不告诉你流量是谁发出来的、要去哪里。有了代理日志,我们可以按进程、按域名做审计,出了问题能第一时间定位到是哪个 agent 任务在访问什么地址。
不过这里要提醒一句:应用层强制代理不能作为唯一防线。因为总有一些程序不走代理——比如某些依赖原生 socket 的底层库、某些 CLI 工具会自行解析 DNS 后直连。这也是为什么出口层的 iptables 默认拒绝必须存在,它是底层物理兜底。没有第二层,应用层的代理形同虚设;没有第三层,第二层堵住了流量但审计无从谈起。这两层缺一不可。
4. 上线后的真实故障与排查记录
4.1 故障一:DNS 被掐了,但 Agent 还是能“出网”——DoH 通道
这是我们上线第一周遇到的最麻烦的问题。解析层和出口层都生效了,照理说外带通道应该断了。但审计日志里还是出现了异常流量——agent 在访问一些不在白名单里的域名,而且是通过 443 端口出去的。
排查到最后发现,问题出在 DNS over HTTPS(DoH)上。agent 的某些底层库在解析阶段可以直接发起 HTTPS 请求到公共 DoH 服务(比如1.1.1.1的 DoH 端点或者8.8.8.8的 DoH 端点),绕过本地策略 DNS 完成域名解析。对出口层来说,这就是一次普通的 443 流量,如果目标 IP 恰好落在放行网段里,它就能穿透网络策略。
这个问题的根子在于,DNS 外带不只是 UDP 53 这一条路径。只要还允许自由的 443 出站流量,agent 就能用 DoH 或者 DNS over TLS(DoT)的方式完成“非白名单域名解析 + 数据外带”的组合拳。
应对方案是双管齐下。第一,出口代理对 CONNECT 目标做严格白名单,知名 DoH 服务商的域名字段直接拒绝;第二,iptables 层把代理和少数业务 IP 之外的 443 全部 DROP,让 DoH 请求物理上没有出网通道。这个案例让我学到一个教训:封禁 DNS 必须是“全局封禁协议行为”,不能只封 53 端口就收工。
4.2 故障二:白名单切太死,npm 装包和系统更新全挂
出口层默认拒绝上线之后,第二天运维那边就收到一堆反馈——agent 跑代码时无法安装依赖,npm install 一直超时,pip 下载包也报错,系统时间还出现了偏差。
逐个排查后发现问题出在动态域名上。npm 实际下载包时走的不是registry.npmjs.org一个域名,还会访问registry.npmjs.org下面的多个 CDN 节点,以及nodejs.org的预编译二进制下载链接。pip 和 apt 也类似,各有好几个子域名。我最初的域名白名单只写了主站,没覆盖这些 CDN 和下载子域,导致 agent 的包管理器在解析阶段就被 NXDOMAIN 拦住了。
解决方式是把白名单从“主站级”放宽到“服务商级”,按照域名的归属关系,把*.npmjs.org、*.pypi.org、*.debian.org这类通配符模式加入白名单。放宽白名单确实会带来额外的风险,但这类下载源本身承担的是公开内容分发,风险水平是可控的,相比之下保证 agent 正常工作更重要。
从这次故障里我总结出一条经验:白名单设计必须“主站 + 附属域名 + CDN”三位一体,上线前最好先跑一遍代理日志,看看 agent 前十分钟到底访问了哪些域名,再针对性放行。
4.3 故障三:日志有了,溯源还是慢——缺的是关联分析
网络层三件套都上线之后,数据是有了:CoreDNS 有 querylog,出口代理有 JSON 日志,iptables 有计数器和日志。但第一次做安全事件溯源的时候,我们还是花了一个多小时才拼出完整链路——因为三个数据源彼此孤立,没有关联字段。
后来我做了两件事。第一,在 CoreDNS 的 querylog 里补充客户端 IP 和 agent 任务 ID 的映射字段,这样任何一条 DNS 查询都能直接关联到具体的任务和进程。第二,在出口代理日志里记录源进程的容器 PID,加上时间戳,就能和 DNS 查询日志做关联。当一份日志里出现“高频 NXDOMAIN 查询 + 编码型子域名”的组合特征,就能立刻输出事件告警。
另外还要注意给高频查询统计加一个阈值。我们现在的规则是:单任务五秒内非白名单域名解析次数超过 10 次,或者单任务解析成功率低于 20%,自动触发告警。这个阈值要根据业务基线动态调整,刚开始我们设得比较低,结果被正常的依赖扫描流程误报了好几次,逐步提高之后才稳定下来。
说到底,网络层安全没有一劳永逸的方案。把三层策略叠起来之后,我们内部跑过几轮模拟测试,DNS 隧道类的外带通道基本都被掐死在解析阶段,DoH 直连路径也被出口层挡住。但这套东西需要持续维护的点不少——域名白名单要跟着服务商 IP 变,日志基线要跟着 agent 业务变,一旦业务新跑的依赖出现新的域名,就要及时评估并加入白名单或加白策略。我给团队定的规矩是每两周做一次白名单审查,每次变更走代码评审,确保网络层策略不是拍脑袋放行。这段实际跑下来的经验就是:沙箱隔离不是一次性搭建工程,而是一个持续运营的安全基线。