Files
Mxmilu666andClaude Fable 5.1 cc6ad72706 feat(udp): add EasyTier analyzer for tcp:// and udp:// transports
Port of NoBrand-Cloud/OpenGFW-RS commit 1177ff7 to Go.

EasyTier's ciphers cover only the payload: the 16-byte PeerManagerHeader
stays in the clear, so peer ids, packet type and two redundant length
fields are readable even on an encrypted mesh. The plaintext handshake
also carries the network name, which lets a rule block one mesh and
leave the rest alone (easytier.network_name). A Syn answered by a Sack
echoing the same connection id and magic, or a handshake carrying the
0xd1e1a5e1 magic, settles a flow on its own; otherwise two consistent
frames are required. Hole-punch probes are reported but never set yes.

The wg://, quic:// and wss:// transports are left to the wireguard, quic
and tls analyzers. fet does not cover the tcp:// transport because it
classifies the plaintext handshake segment and is then done.

Rules must be written as `easytier != nil && ...`: the rule engine only
exposes analyzers that have published properties.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjQ1CTBn8j65cUZ2JKsY7L
2026-09-12 17:05:39 +08:00

13 KiB
Raw Permalink Blame History

EasyTier 分析器

识别 EasyTier 去中心化 Mesh VPN 的对等连接。

传输方式与可见性

EasyTier 的对等连接可以走六种传输,节点默认全部监听:

scheme 默认端口 线上是什么样 谁负责
tcp:// 11010 EasyTier 自己的定长帧,明文头 本分析器
udp:// 11010 EasyTier 自己的数据报头,明文头 本分析器
ws:// 11011 HTTP Upgrade,之后是 WebSocket 二进制帧 http(握手可见),帧内容不解
wss:// 11012 TLS(自签证书,CN=localhost) tls(SNI/证书层面)
wg:// 11013 标准 WireGuard 报文 wireguard
quic:// 11010 QUIC,未设置 ALPN quic

只有前两种把 EasyTier 的帧结构直接放在网络层上,也是唯二能拿到网络名、对端 ID 这些信息的。下面说的都是这两种。

为什么 wireguard 和 fet 不够

这两个现有分析器看起来应该能覆盖,实测都不行。

wireguard:只挡得住六分之一

它匹配的是真正的 WireGuard 报文,也就是 EasyTier 的 wg:// 传输(默认 11013)。问题在于节点默认把六种传输全部监听起来,连接方按拿到的 URI 列表逐个尝试,哪个通用哪个。只挡 wg://,mesh 换 udp:// 照样建起来,而 udp:// 恰恰是 NAT 穿透场景下的首选。

另外 WireGuard 报文里没有网络名,拦了也不知道拦的是哪个 mesh,而且会一并拦掉合规的 WireGuard VPN。粒度太粗。

TestEasyTier_WireGuardAnalyzerDoesNotClaimUDPTransport 这个用例把 udp:// 上的 Syn、握手、打洞包依次喂给 wireguard 分析器,确认它一个都不认。

fet:判在了整条连接熵最低的那一段上

fet 检测「全加密流量」,只看客户端第一个分段,判完就 done。而 EasyTier 连接的第一个分段永远是明文握手帧,整条连接上唯一不加密的东西。

那一帧长什么样:to_peer_id 四个零、flags/reserved 是零、两个长度字段的高位字节是零、liveness-echo-v1 和网络名是 ASCII。实测 ex1(平均 popcount)落在 3.4 这条豁免线附近,而且结果取决于网络名有多长:

网络名 fet.yes 命中率(200 组随机 digest) ex1 区间
a 0/200 3.045..3.258
mesh 0/200 3.076..3.283
office-mesh 3/200 3.242..3.434
my-home-network 52/200 3.320..3.505

握手不带真实 digest 时(跨网络/共享节点场景,字段填 32 个零)更低,四种名字全是 0/200,ex1 只有 1.6..2.2。

也就是说 fet 在这里是大概率漏、小概率中,而且能不能中取决于对方给自己的网络起了个多长的名字。而后续真正加密的数据帧 ex1 有 3.92,fet 本来能认,但它早就判完退出了。

更麻烦的是漏判的后果:fet 报 yes=false 且没有别的规则拦截时,引擎会把这条流放进快速通道(connmark bypass),之后的包根本不再进用户态。一次漏判就是永久放行,和 docs/tcp-window-evasion.zh.md 里描述的是同一个后果。

TestEasyTier_FETDoesNotCatchTheTCPHandshake 这个用例把这一点钉住了。

报文结构

三个头,全部小端,全部 #[repr(C, packed)],没有任何对齐填充。

UDPTunnelHeader(8 字节,仅 UDP)

偏移 字段 说明
0..4 conn_id u32 会话 ID,发起方随机选取
4 msg_type u8 1=Syn 2=Sack 3=Data 4=Fin* 5=HolePunch 6=V4HolePunch 7=V6HolePunch(* 枚举里有,当前实现从不发送)
5 padding u8 恒为 0,只为对齐长度字段而存在
6..8 len u16 后面的字节数,必须等于 数据报长度 - 8

TCPTunnelHeader(4 字节,仅 TCP)

一个 u32 长度前缀,值等于后面帧体的字节数。上游要求 16 ≤ len ≤ max_packet_size(TCP 隧道 2000,字节流隧道 4096)。

PeerManagerHeader(16 字节,两种传输都有)

偏移 字段 说明
0..4 from_peer_id u32 源节点 ID
4..8 to_peer_id u32 目的节点 ID,首包为 0
8 packet_type u8 见下表,取值 1..=21
9 flags u8 bit0 加密 bit1 延迟优先 bit2 出口节点 bit3 不代理 bit4 压缩 bit5/7 存活探测 bit6 不送 TUN
10 forward_counter u8 中继跳数,路由在 >7 时丢弃后再自增,所以线上最大 8
11 reserved u8 存活标志位(bit5/bit7)置位时是探测 token,否则恒为 0
12..16 len u32 载荷长度

packet_type 取值:1 Data、2 HandShake、3 RoutePacket*、4 Ping、5 Pong、6 TaRpc*、7 Route*、8 RpcReq、9 RpcResp、10 ForeignNetworkPacket、11 KcpSrc、12 KcpDst、13/14/15 NoiseHandshakeMsg1/2/3、16 QuicSrc、17 QuicDst、18/19 DataWith{Kcp,Quic}SrcModified、20 RelayHandshake、21 RelayHandshakeAck(* = 已废弃但仍占位)。

关键点:头不加密

EasyTier 的三种加密(AES-GCM / ChaCha20 / XOR)只覆盖载荷,PeerManagerHeader 自始至终是明文,加密只是把 flags 的 bit0 置位(tunnel/encrypt/aes_gcm.rs 等)。压缩同理,置 bit4。

所以哪怕整个 mesh 开了加密,对端 ID、报文类型、跳数、声明的载荷长度仍然一直挂在明面上。这是本分析器的立足点。

判定信号

分三档,从弱到强。

一、帧结构自洽(easyTierEvidenceFrame)

每一帧里有两个互相冗余的长度字段,必须和实际收到的字节数对得上:

UDP:  len(6..8)  == 数据报长度 - 8
TCP:  len(0..4)  == 帧体长度
两者: PeerManagerHeader.len 与实际载荷长度的关系

最后一条要分情况,因为加密和压缩都不更新 PeerManagerHeader.len:

flags 实际载荷长度
都没置位 == len,精确相等
仅加密 == len(XOR)或 == len + 28(AEAD 的 16 字节 tag + 12 字节 nonce)
压缩 ≤ len + 28(压缩器只在能省下自己那 1 字节尾时才压)

再加上 padding == 0、packet_type ∈ 1..=21、forward_counter ≤ 8、reserved 的取值约束,单帧大约值三十几个比特。分析器要求攒够 2 帧才把 yes 置真。

二、UDP 会话握手(easyTierEvidenceConclusive)

发起方发一个 16 字节的 Syn(len == 8,体是随机 u64 magic),对端回一个 16 字节的 Sack,同一个 conn_id、同一个 magic,方向相反。96 比特的巧合概率,一次就够。

单独一个 Syn 或 magic 对不上的 Sack 只算「见过」,不推进 yes。

三、对等握手(easyTierEvidenceConclusive)

默认(非 secure)模式下,连接建立后第一帧就是 packet_type = 2 的 protobuf HandshakeRequest,第一个字段是常量 magic = 0xd1e1a5e1。protobuf 编码后是固定的 6 字节:

08 e1 cb 86 8f 0d

在 TCP 上,它落在整条连接载荷的固定偏移 20..26:

0..4    TCPTunnelHeader.len
4..20   PeerManagerHeader(type=2, to_peer_id=0)
20..26  08 e1 cb 86 8f 0d

同一条消息还带着 network_name(字段 5,明文)和 32 字节的 network_secret_digest(字段 6)。digest 宽度不对的握手上游会直接拒连,所以分析器也把它当作否定条件,避免报一个根本连不起来的 mesh 名。

关于 secure mode

--enable-secure-mode 会把首帧换成 Noise XX 的 msg1(packet_type = 13)。Noise XX 的第一条消息此时还没有密钥,载荷是明文,所以 32 字节临时公钥后面的 protobuf 仍然能读出 a_network_name。

分析器对这段是「能读就读、读不出就不说」:protobuf 必须完整解析、version 合理、网络名是不含控制字符的 UTF-8,否则只置 secure,不填 network_name。secure mode 下没有 magic 可查,所以要靠第一档攒够 2 帧才判 yes。

属性

属性 类型 说明
yes bool 综合判定。一定存在
transport string udp / tcp。一定存在
tx_pkt_cnt / rx_pkt_cnt int 两个方向已解析的帧数。一定存在
msg_type uint8 UDP 隧道消息类型,仅 UDP
msg_type_name string syn / sack / data / fin / hole_punch / v4_hole_punch / v6_hole_punch
conn_id uint32 UDP 会话 ID,仅 UDP
packet_type uint8 PeerManagerHeader.packet_type
packet_type_name string 上表的可读形式,如 handshake、ping、noise_handshake_msg1
from_peer_id / to_peer_id uint32 对端 ID
forward_counter uint8 中继跳数
payload_len uint32 头里声明的载荷长度
encrypted / compressed / latency_first / exit_node / no_proxy bool flags 各位
magic_ok bool 明文握手 magic 匹配。粘性
secure bool true = 见到 Noise 握手,false = 见到明文握手。粘性
version uint64 握手里的协议版本。粘性
network_name string mesh 名。粘性

标「粘性」的字段一旦写入就不再被后续帧覆盖:网络名只在握手里出现一次,一条按 mesh 名写的规则必须在握手滚过去之后还能用。其余字段描述的是最近一帧。整数字段的具体宽度不影响规则,expr 比较时会自动对齐。

packet_type 及其后的字段只在该帧带 PeerManagerHeader 时存在(UDP 的 Data、全部 TCP 帧)。Syn/Sack/打洞包没有。

Note

规则里要写 easytier != nil && ...。规则引擎只把已经发布过属性的分析器放进表达式环境(见 ruleset/expr.go 的 streamInfoToExprEnv),流上一帧都没解析出来时 easytier 是 nil,直接取 easytier.yes 会报错。map 里不存在的键读出来是 nil,比较结果为假,不报错:所以 easytier.exit_node == true 在 Syn/Sack/打洞包上只是不匹配,exit_node 要等一个带 PeerManagerHeader 的帧到达才有值;同理 easytier.network_name == "..." 在握手到达之前一直不匹配。

规则示例

拦截所有 EasyTier 对等连接:

- name: block-easytier
  action: block
  log: true
  expr: easytier != nil && easytier.yes

只拦某一个 mesh,其余放行(网络名明文可见,这是最精确的一档):

- name: block-one-easytier-mesh
  action: block
  log: true
  expr: easytier != nil && easytier.network_name == "office-mesh"

只拦出口节点,即把本网流量往公网转发的那些,通常是策略上真正在意的:

- name: block-easytier-exit-node
  action: block
  log: true
  expr: easytier != nil && easytier.yes && easytier.exit_node == true

打洞包不参与 yes,要单独点名(见下节的取舍说明):

- name: log-easytier-hole-punch
  log: true
  expr: easytier != nil && easytier.msg_type_name == "hole_punch"

只观察不拦截,先看清网络里有什么:

- name: log-easytier
  log: true
  expr: easytier != nil && easytier.yes

规则按顺序匹配,第一条带 action 的命中即生效。想同时用「只拦一个 mesh」和「拦全部」,具体的要写在前面,否则被兜底的那条挡住永远到不了。

配套的 WireGuard 传输要另外拦,走 wireguard 分析器。

检不到什么

  • wg:// / quic:// / wss:// 传输。线上分别就是 WireGuard、QUIC、TLS,交给对应分析器。wss:// 的服务端用的是 rcgen 现签的自签证书,CN 为 localhost,这本身是个可用的特征,但它属于 tls 的范畴。
  • ws:// 帧内容。HTTP Upgrade 握手 http 能看到,之后的 WebSocket 二进制帧里才是 PeerManagerHeader(无长度前缀,ZCPacketType::DummyTunnel),本分析器不做 WebSocket 拆帧。
  • 中途接入的流。TCP 侧遇到 skip != 0(重组有空洞)会直接放弃,因为帧边界无从推断。
  • 只发打洞包的流。打洞探测是往可能根本没跑 EasyTier 的地址上喷的随机数据,除了长度没有任何结构(连通性路径 16 字节,UDP 会话层 32 字节,两个值都认),把它算作确证会在正常网络里产生误报,所以它只填 msg_type_name、不推进 yes。要抓就按上面的例子点名。

误报边界

  • UDP 侧连续 4 个无法解析的数据报就放弃该流(和 wireguard 分析器一致);TCP 侧一旦帧结构对不上立刻结束。
  • EasyTier 把 STUN 复用在同一个 UDP socket 上,并且在自己的帧解析之前先分流 STUN。分析器照做:识别出 STUN(data[4..8] == 21 12 A4 42 且 data[0] & 0xC0 == 0)的数据报既不算证据也不算错误,只计入总包数,免得一串 STUN 把流提前判死。
  • 任一传输超过 256 个包仍未定性就结束该流(和 openvpn 分析器一致),避免长流一直占着分析器状态。
  • Limit() 返回 0。这是个定长帧协议,按字节数截断会让帧读取失步;上面的包数上限替代它约束开销。

复现

字节级的边界条件都在 analyzer/udp/easytier_test.go 里,包括 magic 的编码、两个长度字段的关系、AEAD 尾、压缩、reserved 的取值规则、帧切在长度前缀中间的重组、逐字节投递、STUN 复用,以及上面提到的 fet 和 wireguard 对照用例:

go test ./analyzer/udp -run EasyTier -v