QUIC流量域名识别与管控:基于C/C++的SNI提取与静默丢包实现
做出口网关和访问控制这块的同学这两年多少都有点焦虑以前在 TCP 层做域名管控那套经验好像一夜之间就不太灵了。抓包能看到大量发往 443 端口的 UDP 报文但连接状态追不上TCP RST 也打不进去连以前最依赖的 SNI 都不像 TCP 时代那样在握手前几个包就能稳定抓到。这就是 QUIC 协议普及带来的连锁反应。这篇文章想聊的是基于 C/C 在 QUIC 流量里做域名级识别与策略执行的一套完整实现路径从 QUIC 头部解析、Initial 包里的 SNI 提取到高性能匹配引擎和丢包决策尽量把关键代码和踩坑点都摊开说。先说清楚一个问题为什么 QUIC 会让域名管控这件事变得困难然后再讲实现。如果不懂这个为什么后面写代码的时候很容易在错误的方向上使劲。1. QUIC 把传统域名管控逻辑打散之后我们到底在追什么1.1 传统 TCP 时代的域名拦截链路回顾在 TCP 占绝对主导的时代一套典型的域名管控流程是这样的客户端发起 DNS 查询DNS 报文通常是明文 UDP管控设备可以污染或重定向 DNS 响应让域名解析到黑洞 IP。这是最早的手段但现在客户端普遍用 DoH/DoT 了DNS 层面能做的越来越少。DNS 没拦住TCP 握手时还有第二次机会。客户端发送的第一个 TCP SYN 包本身不带域名但随后 TLS 握手阶段的 ClientHello 会以明文出现在 TCP 载荷里。设备可以做 DPI深度包检测在 TCP 载荷里匹配 SNI 字段命中策略就注入 TCP RST 或直接丢弃握手包。这是目前绝大多数企业网关和内容过滤方案的核心逻辑也是很多域名封堵产品的底层工作原理。即便 TLS 连接已经建成了TCP 连接状态也是可被感知的。设备通过四元组、序列号、确认号能持续跟踪这条连接随时可以 RST 掉或者用连接跟踪超时的方式静默切断。这套管控体系能成立有三个前提连接状态可见、控制报文可注入、关键握手信息加密前可读取。TCP 面向连接的语义和 TLS ClientHello 明文的客观事实共同支撑了这套逻辑。1.2 QUIC 在协议设计上做了哪些反拦截设定QUIC 协议最初的目标和内容管控没有半点关系它就是为了降低连接延迟、改善弱网体验、提升安全性。但它的一些设计恰好把上述三个前提全打破了。第一连接状态不可见。QUIC 基于 UDP设备侧看到的是一条条无状态的 UDP 数据报。没有 SYN、ACK、FIN 这种控制面语义四元组只能代表UDP 端口上有流量在跑代表不了连接状态。连接跟踪只能靠超时猜没有精确的结束信号。第二控制报文无法注入。TCP 时代的 RST 注入到了 QUIC 这里完全失效。往一条 UDP 流里伪造一个包QUIC 层有连接 ID、包号、密钥认证多重校验对端很容易识别这不是合法数据并丢弃。注入 ICMP Destination Unreachable 也没用现代操作系统和 QUIC 协议栈大部分时候会直接忽略。最可靠的干预手段反而回到了最简单的静默丢包。第三明文信息变少但没完全消失。TLS 1.3 把握手过程中除 SNI 之外的大部分元数据都加密了QUIC 里整个 TLS 握手数据都在 CRYPTO 帧里但好在 ClientHello 中的 SNI 扩展仍然以明文形式传递。这给域名级识别留下了一个关键窗口。后面会详细讲这个窗口具体在哪、怎么抓住。这三个前提失效决定了 QUIC 时代域名管控的实现思路必须换一套在无连接、不可注入、信息半加密的流量里靠读到 SNI、记住这条流、按策略丢弃完成闭环。整个架构的核心是识别能力加连接缓存加丢包动作的组合和 TCP 时代的逻辑有本质差异。1.3 题目真正要解决的技术问题综合来看QUIC 域名封堵这个题目真正的技术难点有三个第一个是认得出。UDP 流量里哪些包是 QUICQUIC 包里哪个字段能作为稳定指纹拿到一个 QUIC Initial 包后怎么在不清洗 TLS 载荷的情况下找到 SNI第二个是记得住。QUIC 没有连接结束的显式信号那连接跟踪表该设计成什么样什么时候该把一条流的缓存淘汰掉连接 ID 在跟踪里能起什么作用第三个是管得住。识别出目标域名后用什么样的动作能达到可靠管控为什么 RST 对 UDP 无效静默丢包的实际效果如何验证下面几个章节就按这三个问题展开。看完之后你可以直接用 C/C 把一条完整的 QUIC 域名识别与策略执行链路搭起来。2. QUIC 流量里能读到什么可观测信号拆解想写解析代码得先认识 QUIC 的包头。很多刚开始搞 QUIC DPI 的人会去翻协议标准然后被各种扩展、帧类型搞懵。实际上做管控系统不需要完整实现 QUIC 协议只需要把特定信号抓出来就够了。2.1 认识 Long Header固定 bit、Version 与连接 IDQUIC 包头分 Long Header 和 Short Header 两种形态。管控系统最关心 Long Header因为握手阶段的关键包Initial、Handshake、0-RTT都用 Long Header而 Short Header 是握手完成后的 1-RTT 数据包里面几乎没有可读的明文信息。一个 Long Header 的核心结构是第一个字节的高位部分标识 Header Form0x80 表示 Long Header和 Fixed bit0x40必须为 1这是协议层面的强制校验位也是 DPI 识别 QUIC 最稳定的指纹之一接下来 4 个字节是 Version 字段QUIC v1 的 Version 是 0x00000001v2 是 0x6b3343cf——这是一个随机的版本号不是 2设计上有意为之后面是 DCIDDestination Connection ID和 SCIDSource Connection ID的长度加内容。在 C/C 里解析这个头部非常直接#define QUIC_LONG_HEADER 0x80 #define QUIC_FIXED_BIT 0x40 #define QUIC_TYPE_MASK 0x30 #define QUIC_INITIAL_TYPE 0x00 typedef struct { uint8_t first_byte; uint32_t version; uint8_t dcid_len; uint8_t dcid[20]; uint8_t scid_len; uint8_t scid[20]; uint8_t type; } quic_long_header_t; int quic_parse_long_header(const uint8_t *buf, size_t len, quic_long_header_t *hdr) { if (len 7) return -1; hdr-first_byte buf[0]; if ((hdr-first_byte QUIC_LONG_HEADER) 0) return -1; if ((hdr-first_byte QUIC_FIXED_BIT) 0) return -1; hdr-version ntohl(*(uint32_t *)(buf 1)); hdr-dcid_len buf[5]; if (hdr-dcid_len 20 || 6 hdr-dcid_len 1 len) return -1; memcpy(hdr-dcid, buf 6, hdr-dcid_len); uint32_t off 6 hdr-dcid_len; if (off len) return -1; hdr-scid_len buf[off]; if (off hdr-scid_len len) return -1; memcpy(hdr-scid, buf off, hdr-scid_len); off hdr-scid_len; hdr-type (hdr-first_byte QUIC_TYPE_MASK) 4; return 0; }这段代码有几个值得注意的细节。DCID 最大 20 字节是协议限制Version 字段在网络字节序和主机字节序之间的转换非常容易出错一不留神会把 0x00000001 判断成 0x01000000。我早期调代码在这个问题上卡了很久建议在所有涉及 Version 比较的地方直接用 ntohl 统一转一次不要在多个函数里各转各的。2.2 Initial 包里的 CRYPTO 帧与 TLS ClientHello解析出 Long Header 只是第一步要拿到域名还得继续往下拆 Initial 包的载荷。这一点集中体现了为什么说 QUIC 的 SNI 仍然是可提取的——因为 Initial 包在密钥协商之前整个 CRYPTO 载荷都是明文TLS 握手消息边界相对规整。只要知道 QUIC 帧的封装方式SNI 就能用和 TCP 时代类似的启发式扫描找出来。Initial 包的载荷遵循 QUIC 帧格式流控、ACK、CRYPTO 都以帧的形式承载。CRYPTO 帧类型是 0x06里面装载的就是 TLS 握手消息。提取思路是解析完 Long Header 后从载荷起始位置开始循环解析帧头找到 frame type 为 0x06读取它的长度字段把这段数据交给 TLS 解析函数。有一种启发式做法在工程上更实用不完整解析 QUIC 帧头直接在载荷里扫描 TLS ContentType0x01表示 handshake再往后读 4 字节握手消息长度跳过 34 字节的握手头在 ClientHello 的固定字段之后扫描扩展项找 extension type 为 0x0000 的 SNI 扩展。这个扫描法在明文 ClientHello 的前提下比精确解析 QUIC 帧更快更稳。一个简化版 SNI 提取函数的核心逻辑如下static int extract_sni_from_clienthello(const uint8_t *tls_buf, size_t tls_len, char *sni, size_t sni_cap) { if (tls_len 4) return -1; size_t off 4; if (off 34 tls_len) return -1; off 34; // 跳过 version(2) random(32) if (off tls_len) return -1; uint8_t sid_len tls_buf[off]; if (off sid_len tls_len) return -1; off sid_len; // 跳过 session_id if (off 2 tls_len) return -1; uint16_t cs_len ntohs(*(uint16_t *)(tls_buf off)); off 2 cs_len; // 跳过 cipher_suites if (off tls_len) return -1; uint8_t cm_len tls_buf[off]; off cm_len; // 跳过 compression_methods if (off 2 tls_len) return -1; uint16_t ext_total ntohs(*(uint16_t *)(tls_buf off)); off 2; uint16_t parsed 0; while (parsed 4 ext_total off 4 tls_len) { uint16_t ext_type ntohs(*(uint16_t *)(tls_buf off)); uint16_t ext_len ntohs(*(uint16_t *)(tls_buf off 2)); if (ext_type 0x0000 ext_len 5 off 4 ext_len tls_len) { // SNI extension 内部结构 // server_name_list_len(2) name_type(1) name_len(2) name uint16_t name_len ntohs(*(uint16_t *)(tls_buf off 9)); if (name_len sni_cap - 1 off 11 name_len tls_len) { memcpy(sni, tls_buf off 11, name_len); sni[name_len] \0; return 0; } } off 4 ext_len; parsed 4 ext_len; } return -1; }这段代码看起来长但逻辑非常机械。两个容易出错的细节一是 SNI 扩展内部还有 2 字节的 server_name_list 长度字段偏移量容易算乱二是客户端会在 ClientHello 里填充多个扩展不能直接按固定偏移取要按扩展链表顺序扫描。2.3 ECH、DNS 与连接 IDSNI 之外的旁路信号ECHEncrypted ClientHello是 TLS 1.3 的扩展目标是连 SNI 一起加密。如果 ECH 大规模部署上面这套 SNI 明文提取逻辑就会失效。但现实是ECH 的部署需要客户端和服务端配套升级期间还有各种兼容性问题实际流量占比很低。对管控系统来说正确的态度是把 ECH 当成变量考虑而不是立刻推翻现有架构。解析不到 SNI 时靠其他信号兜底。有两个旁路信号值得重视。第一个是连接 ID。QUIC 的连接 ID 由客户端随机生成在同一连接的所有数据包中保持稳定即使 IP 和端口变了也不会变。对照五元组和连接 ID可以把一条连接在不同网络路径下的流量关联起来。第二个是 DNS 线索。如果管控设备能同时看到 DNS 解析流量那么在某个 IP端口上先出现某域名的解析响应、之后再出现 QUIC 流量两者之间就有强关联。这些旁路信号决定了系统在 SNI 提取失败时还有没有次优决策能力而不是直接变成盲区。3. C/C 实现路径从抓包到执行丢包的完整链路3.1 整体架构四模块用队列衔接一个可落地的 QUIC 域名管控系统我建议拆成四个模块抓包模块、协议解析模块、策略匹配模块、执行动作模块。四个模块之间用无锁队列或环形缓冲区衔接抓包线程只管把原始包放进队列解析线程负责从队列里取包、解析、查策略、决策动作线程负责实际丢包和日志统计。模块职责分清楚后面调性能、修 bug 都会省力很多。需要提前明确部署形态。旁路部署时抓包用镜像口丢包动作需要在另一个设备上执行或者在同一设备上通过另一个物理接口下发指令串联部署时抓包模块和执行动作模块共用同一块网卡的收发路径决策后直接丢弃不需要跨设备通信。这两个场景的架构差异很大下面按串联部署讲这也是最常见的实现方式。3.2 抓包层选型libpcap、AF_PACKET 与 DPDK 的取舍抓包层选型直接决定性能上限。三种常见方案libpcap开发效率最高跨平台适合原型验证和低流量场景。缺点是要经过内核协议栈和 BPF 过滤性能天花板明显。AF_PACKET PACKET_MMAP通过 mmap 把内核环形缓冲区映射到用户态减少一次拷贝性能优于 libpcap代码量也可控。这是很多高性能 DPI 产品的选择。DPDK用 UIO/VFIO 绕过内核用户态轮询网卡性能最强但部署复杂CPU 占满还要配置大页内存。适合几十 Gbps 的骨干链路。我的实际项目经验是先用 libpcap 验证解析逻辑流量上来后切 AF_PACKET真正到了骨干网再上 DPDK。不要一上来就 DPDK调试成本会拖慢整个项目进度。libpcap 的初始化代码大致如下char errbuf[PCAP_ERRBUF_SIZE]; pcap_t *handle pcap_create(eth0, errbuf); pcap_set_snaplen(handle, 256); // 只要前 256 字节够提取 SNI 了 pcap_set_promisc(handle, 1); pcap_set_timeout(handle, 100); pcap_activate(handle); struct bpf_program fp; pcap_compile(handle, fp, udp port 443, 0, PCAP_NETMASK_UNKNOWN); pcap_setfilter(handle, fp);snaplen 值得单独说。提取 SNI 只需要抓到 QUIC 包头加部分 CRYPTO 帧256 字节基本够用。snaplen 设太大收包吞吐量明显下降设太小又可能导致 SNI 被截断。建议上线前先统计一下实际流量里 SNI 出现在第几个字节再调这个参数。3.3 核心解析从 UDP 载荷到 SNI 字符串把前面几个环节串起来就是一个从 UDP 载荷到 SNI 的完整函数。入口接收脱去以太网头、IP 头的 UDP 载荷指针出口是提取出的域名。int quic_extract_domain(const uint8_t *udp_payload, size_t payload_len, char *domain, size_t domain_cap) { quic_long_header_t hdr; if (quic_parse_long_header(udp_payload, payload_len, hdr) ! 0) { return -1; // 不是 QUIC 长包头或者被截断了 } if (hdr.version 0) { return -2; // Version Negotiation 包没有载荷 } size_t hdr_len 6 hdr.dcid_len 1 hdr.scid_len; if (hdr_len payload_len) return -3; // 在载荷中寻找 CRYPTO frame const uint8_t *cur udp_payload hdr_len; const uint8_t *end udp_payload payload_len; while (cur end) { uint8_t frame_type cur[0]; if (frame_type 0x06) { // CRYPTO frame return extract_sni_from_clienthello(cur 1, end - cur - 1, domain, domain_cap); } cur; } return -4; }工程上这里还能继续打磨。QUIC 的帧长度和偏移用的是 VARINT 编码不是定长字段完整实现要正确解析变长整数。另外 Initial 包在路径 MTU 限制下可能被拆成多个包每个包里只有一部分 CRYPTO 帧必须做分片缓存和重组。分片重组是 QUIC 解析里最容易出 bug 的地方。我遇到过 ClientHello 被拆在两个 UDP 包里第一个包只到 session_id第二个包才是 SNI 扩展。如果不做重组SNI 永远提取不到。实际工程里要维护一个基于四元组加连接 ID 的 CRYPTO 分片缓冲区按 offset 写入、按需拼接。3.4 策略匹配哈希表精确命中前缀树解决通配拿到域名后要解决的是怎么和策略库高效比对的问题。三种做法各有适用场景哈希表精确匹配适合完整域名精确名单查询性能最好每秒能完成几百万次查询。这是最高效的路径。前缀树/后缀匹配策略库里有大量通配规则比如 .example.com时哈希表就难受了。用基于标签倒序的前缀树比如 com - example - *插入和查询都很快。字符串全量扫描策略库只有几十条时线性扫描最简单CPU 开销可忽略规则上万条就必须走前两种方案。实际项目里我推荐“哈希表精确匹配 前缀树通配匹配”双引擎。先查精确白名单命中放行没命中再走前缀树做后缀匹配。域名归一化要提前做全部转小写、去掉末尾的点、IDN 转 punycode。这里有个特别容易被忽视的坑不少客户端会带一个无意义的尾随点比如 example.com.如果库里存的是 example.com不做归一化就会漏掉。归一化放在加载规则时做不要在查询时做这样查询路径少一次字符串处理。3.5 丢包执行UDP RST 是伪命题静默丢包才是正解有人会习惯性地在识别出目标域名后尝试注入 RST这在 QUIC 场景下是无效的。QUIC 连接状态只在端到端之间维护设备伪造的任何 UDP 包都会被连接 ID、包号、密钥认证三重校验识破。所以 QUIC 域名管控最可靠的动作是静默丢弃命中策略的数据包让它得不到任何响应客户端反复重试后超时失败。这个动作在串联部署里实现起来很简单解析线程命中策略后直接丢弃这个包不向网卡发送。但有一个细节容易被忽略——客户端重试次数是有限的如果只丢 1-RTT 数据包而不丢 Initial 包客户端可能已经完成握手进入正常通信了。最稳妥的策略是一旦识别出该连接命中了策略就把这条连接从 Initial 开始的所有后续包全部丢弃持续一段时间。这个持续一段时间要多久得靠连接跟踪表的超时配置来控制后面章节专门讲。4. 性能与误杀权衡决定方案能不能上生产线的关键4.1 单机吞吐、小包 PPS 与环形缓冲的背压问题QUIC 流量最常见的是几十到几百字节的小包小包场景考验的是包处理速率PPS不是带宽。一台普通 x86 服务器用 AF_PACKET 加多队列加 CPU 亲和性单核跑到 1~2M PPS 是正常的DPDK 环境下单核可以到 10M PPS 以上。按这个量级估算千兆出口可能只需要几个核就够了万兆骨干网必须上 DPDK 或者多机负载。环形缓冲的背压设计要特别注意。解析线程处理不过来时环形缓冲区会被写满。这时候抓包线程应该丢新包而不是丢老包因为已经读到的包可能包含关键握手信息。如果反过来丢老包连接跟踪状态会大量缺失SNI 缓存命中率直线下降系统的实际管控能力会大打折扣。4.2 连接跟踪表设计与超时策略QUIC 没有 FIN 标志设备感知不到连接结束。连接跟踪表必须靠超时机制回收。我根据流量统计给的建议是UDP 会话超时设 60 秒QUIC 因为有长时间空闲后复用连接的情况建议放宽到 120 秒。连接表项超时后标记为可回收否则高连接数场景下内存会被慢慢吃光。连接表的 key 建议用五元组源 IP、目的 IP、源端口、目的端口、协议同时额外存一份连接 ID。为什么还要存连接 ID因为 QUIC 连接迁移会改变 IP 和端口但连接 ID 不变。靠连接 ID 可以追踪到同一条连接在不同网络路径下的流量这个信息在策略执行时非常有用比如连接迁移后仍然能关联到之前记录的目标域名。4.3 解不到 SNI 时的降级决策链解不到 SNI 的情况不少ECH 加密、分片超时、snaplen 截断、非标准 QUIC 实现都有可能导致提取失败。这种情况下一刀切放行等于让策略形同虚设一刀切阻断又会误伤大量正常流量。我建议把降级逻辑做成一条优先级链第一优先看之前解析并缓存的该 IP 的 SNI 记录第二优先看 DNS 响应缓存中的域名与 IP 映射第三优先看该 IP 的端口和流量行为画像比如短时间大量 UDP 443 流量且没有其他协议特征很可能就是 QUIC 批量访问最后才放弃决策按默认策略处理。这里需要配合日志回答一个问题每一项决策是从哪个来源命中的只有把命中来源分布统计出来才能知道系统真正看清了多少流量。如果 SNI 命中率长期低于 50%那就说明系统存在严重的识别盲区必须回头优化解析逻辑或降级策略。4.4 命中日志与统计先看清自己再谈优化生产级实现不能只闷头丢包还要能回答丢了多少、命中哪些规则、有没有误伤。我一般用两个统计维度。本地计数器用原子变量或 per-CPU 数组记录总包数、QUIC 包数、SNI 提取成功数、命中策略数、丢包数这是实时诊断的基础审计日志采样记录命中的五元组、域名、规则 ID、时间戳异步写入磁盘或发到日志中心用于事后分析和规则调优。性能开销方面每包加几次原子自增对整体处理性能影响可以忽略。关键是日志不能同步写同步写磁盘会把整个数据面拖死必须异步。5. 绕过手法、误判场景与加固方向5.1 ECH 与自定义 TLS 层带来的盲区ECH 一旦铺开SNI 提取的根基就不存在了。那管控是不是就彻底失效也不是。从协议设计看ECH 只是加密了 ClientHello 里的 SNI服务端的证书、IP 地址、连接行为仍然可观测。可以利用已有情报库做 IP 画像把某些服务使用的 IP 网段和域名做关联也可以对连接建立后的证书指纹做被动识别甚至主动探测。这是一场攻防对抗不可能一劳永逸但架构上不要把宝全部押在单一信号上。从工程角度建议在解析模块里做一个可配置开关当某个目标 IP 的 QUIC 流量占比异常升高但又一直提取不到 SNI 时触发重点跟踪提高这个 IP 关联命中的缓存优先级而不是直接放弃。这套机制在遇到新型客户端或协议变体时特别有用。5.2 非标 UDP 端口与流量伪装QUIC 标准端口是 443但协议本身不强制客户端完全可以把 QUIC 流量发到任意 UDP 端口。要识别这类流量得靠 QUIC 本身的指纹。我在识别模块里干脆放弃单端口判断统一对全部 UDP 流量做 QUIC 可能性评分分数高就进完整解析流程。评分逻辑不复杂第一字节 Fixed bit 加 Version 字段合法性准确率已经非常高。流量伪装是另一个方向。有人会在 UDP 载荷前面拼一段非 QUIC 的数据或者修改 Version 字段绕过规则。遇到这种情况可以在解析前先做一次载荷偏移探测自动跳过未知的前缀。这个功能实现起来不复杂但能显著提升在对抗环境下的识别鲁棒性。5.3 误杀场景CDN、共享 IP 与域名跳转IP 上经常是成千上万个域名共存的尤其在 CDN 场景。如果策略只看域名命中不看 IP 归属很容易因为一个子域名命中策略就误杀整台服务器上的所有域名。确认前一定要做 DNS 关联和证书 SAN 校验。域名跳转也很常见一个主页域名跳转到另一个内容域名策略要跟着主文档域名走不能只拦初始域名就算完事。这类误杀在测试阶段很难暴露通常都是上线后业务方来投诉。所以架构里要预留白名单优先通道匹配顺序上白名单先于黑名单重要业务域名走专门白名单规则不参与通配匹配。5.4 后续可以往哪些方向扩展QUIC 协议还在演进后续可以做的方向包括支持更多 QUIC 版本的解析v2 已经出现GREASE 版本号也要兼容引入用户态协议栈做更深入的连接状态管理把 DPDK 收包和策略执行模块做成独立服务用 gRPC 下发规则方便跟已有安全平台对接。还有一个容易被低估的方向机器学习辅助的流量画像。但前提是先把前面这些规则引擎和数据链路做扎实否则模型再准也没有可靠的特征输入。数据质量决定了模型效果这个顺序不能反。我自己的体会是QUIC 域名管控这件事难点从来不在写一个能解析 SNI 的函数而在于当 SNI 拿不到的时候系统能不能不慌、不误伤、还能继续工作。当初在测试环境里跑通提取逻辑还挺兴奋等放到真实流量里才发现各种非标准实现、分片、ECH、奇怪的 UDP 端口把场景搅得很乱。如果你也要做这套系统先把可观测性做起来把每个决策的来源记录下来先看清自己再谈优化。规则引擎可以后续迭代但架构一旦定下来改起来就费劲了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →