穿透内容审查与阻断:基于 DNS TXT 记录的动态服务发现与客户端安全加固实践

23 阅读6分钟

✍️ 引言

在开发面向全球或特定复杂网络环境的 App(如 XXX、跨境电商、海外加速等)时,最大的痛点往往不是业务逻辑,而是服务端的生存能力。为了对抗域名污染 (DNS Poisoning)SNI 阻断 以及 证书审查,我们通常需要一套极其灵活的「备用链路」与「动态发现」机制。

本文将结合在 iOS/Swift 项目中的实际落地经验,深度剖析一套基于 DNS TXT 记录 派发动态入口域名双向 mTLS 证书(p12)基码 以及 原生 TCP 直连 IP 的高可用架构,并详解其间的技术难点与避坑指南。


🛠 一、 核心架构设计

我们的目标是:哪怕主 Base 域名完全死锁,客户端只要能向公用 DNS 发一个查询,就能满血复活。

1. 数据如何藏在 DNS TXT 里?

由于一台域名的 A记录 只能存 IP,且极其容易被封锁,我们选择将配置加密后塞入 DNS 的 TXT 记录。 我们使用了多级子域名来承载不同的模块(由于 TXT 字符长度限制,需要分片):

子域名 (Subdomain)承载内容特征安全措施
root.yourbase.com加密后的后备 HTTPS 业务 API 域名列表AES-128-ECB 加密 + Base64
1.yourbase.commTLS 客户端证书 P12 文件的 Base64 前半段纯文本分片拼装
2.yourbase.commTLS 客户端证书 P12 文件的 Base64 后半段纯文本分片拼装
ip.yourbase.com绕过 SNI 审查的裸 TCP 直连 IP 点对点通道纯文本

🧠 二、 技术难点与避坑指南

难点 1:iOS 系统 API 无法直接发起原生 UDP DNS 查询

🚨 问题背景:  iOS 的 getaddrinfo 或者 NWHostResolver 是高层级 API,它们往往只返回处理好的 IP 地址(A/AAAA 记录),极难直接读取到 TXT、SRV 记录。如果调用系统的 res_nquery(属于 C 层的 libresolv),在弱网下容易造成线程死锁,且容易触发 iOS 严格的后台审计。

💡 解决方案:使用 Network 框架手工构建 UDP 53 端口查询 我们在 Swift 中封装了一个 DNSResolver,通过 NWConnection(to: 53, using: .udp) 手工下发标准 DNS 报文(RFC 1035)

  1. 构造 DNS 查询帧

    swift
    var data = Data()
    let id = UInt16.random(in: 1...65535)
    data.append(contentsOf: id.bigEndianBytes)
    data.append(contentsOf: UInt16(0x0100).bigEndianBytes) // Flags: 标准查询
    data.append(contentsOf: UInt16(1).bigEndianBytes)      // Question 数量 1
    // ... 拼接子域名 QNAME、QTYPE 为 16 (TXT)
    
  2. 并发查询优化: 由于国内 DNS 偶尔会有运营商后门或缓存污染,我们使用 withTaskGroup 并发地向四个公共 DNS 服务器发送请求 (223.5.5.5114.114.114.1148.8.8.81.1.1.1),谁最快返回合法的 TXT 内容,就直接 cancelAll() 结束任务


难点 2:UDP 的截断陷阱 (Truncated) 与 TCP 回退

🚨 问题背景:  由于拼装了庞大的客户端 p12 证书 Base64 字符串,TXT 记录往往会合在一起超过 512 字节。 在标准的 DNS UDP 查询中,如果响应超过 512 字节,包头部的 TC (Truncated) 标志位会被置为 1,代表数据被截断。

💡 解决方案:标志位侦测与 TCP Fallback 我们在 UDP 接收处做了一层守卫:

swift
if (data[2] & 0x02) != 0 {  // TC Flag is set!
    // UDP 遭遇截断,降级使用 TCP 53 端口进行可靠全量查询
    return await queryTCP(domain: domain, server: server)
}

进入 queryTCP 时,会在帧最前面补上 2 字节的大端序长度头,直接利用 NWConnection.tcp 握手拿到绝对完整的几千字节 TXT 加密串,完美解决大文件丢失问题。


难点 3:防劫持的 “端到端解密” 校验

🚨 问题背景:  如果中间人(Mitm)故意把你的 TXT 记录篡改成钓鱼网站或错误信息,即便配置下发了,APP 也会崩溃或中招。

💡 解决方案:AES + TCP 握手活性测试

  1. 对称加密:对 root 的分流域名进行 AES-128-ECB 加密。中间人即使拿到了,没有客户端的硬编码 Key 也无法篡改。

  2. TCP 通信握手探测活性: 在真正切换配置前,Manager 会多跑一遍 tcpTest。由于有些域名可能已经“挂了”,客户端会在后台静默并发跑:

    swift
    let connection = NWConnection(to: host, using: .tcp)
    connection.stateUpdateHandler = { state in
        if state == .ready { finish(true) } // 代表服务器可通达,不是死域名
    }
    

难点 4:动态 mTLS 证书灌入 (Security Manager)

经过 AES 解密和两片 TXT (1.txt + 2.txt) 拼装后,我们得到了完整的证书 Base64 编码。 我们要实实现本地无感知实例化,不需要把证书文件落地写死到沙盒里(防止反编译静态检查):

  1. 直接在内存中将组合好的 Base64 数据转为 Data
  2. 使用 SecPKCS12Import 函数,并将空密码(或者约定的暗号)传入,从内存里动态吐出 SecIdentity 和关联的 SecCertificate
  3. 把 Identity 灌入全局 SessionDelegate。当走 HTTPS 握手时,若触发 .clientCertificate 的 URLAuthenticationChallenge,直接从 cache 提取该 Identity 给系统使用。

难点 5:SNI 阻断应急方案 —— 18字节头部纯裸 TCP 定制通道

对于国内在极限阻断(如 SNI 嗅探)下的特殊业务,HTTPS 甚至会被阻断。我们追加了 ip.yourbase.com 提取裸 IP:

  • 业务无感降级:当 HTTPS 全灭,NetworkChannelManager 自动引导流量降级到我们自己用原生 NWConnection 敲出来的裸 TCP 直连。
  • 自定义封包协议:由于对端没有 TLS 证书做阻断,我们在应用层通过自研非对称二进制报头([18字节头部][Path][Hdr][Body] 及 响应 14字节头部)在服务端和客户端穿梭自如,极大增强了业务的可达率骨干。

📈 三、 业务安全成效

通过这套机制的上线,我们成功做到了:

  1. 云端无感知脱壳切换:后台可随意增减高防域名、甚至随时全量更替 TLS 的客户端校验私钥,对老版本客户端保持完美兼容。
  2. 零阻断时长:冷启动到成功跑通业务 HTTPS 的时间通过 TaskGroup 的竞赛机制下降到了 平均 0.3 秒以内。

💡 总结

服务高防链路的最佳伴侣不是冗余服务器,而是灵活、弹性的 发现机制。 利用 DNS 53 这个处于网络信任基座的协议,将 分片加密数据 优雅地回传至 iOS 客户端并发解码,不仅安全可靠,更筑起了一道无法轻易折断的强硬长廊。


提示:  在使用 114 / 223 等大陆 DNS 查询时,注意频率控制以心跳避免被运营商拉入恶意解析黑名单。对于更深层的防污染,甚至可搭配 DNS over HTTPS (DoH) 来取代 53 端口查询。