最近在逛 L 站的时候,看到有关 GFW 的一些帖子,提到"为什么使用代理之后能被成功溯源"的问题,顿时来了兴趣,想要琢磨琢磨这里面到底有哪些名堂,所以开始查阅有关资料,学习记录如下。
GFW 的检测原理
GFW 的检测方式有两种:
- 人工检测: 指的是有人向网信办举报特定站点,然后由相关人员针对该站点进行处理;
- 机器检测: 指的是针对跨境 IP 数据包的自动检测机制,根据某些特征对相应的 IP 数据包进行自动处理;
人工检测的方式不在技术范畴内,所以不去考虑了,重点来看 GFW 的机器检测机制是怎么实现的。
架构方式
关于 GFW 的架构,我想除了网信办的相关人员,外人都难以知晓。但有一种广泛流传的架构方式如下图:
GFW 作为旁路部署在出口路由上,将经过出口路由的 IP 数据包复制,即可获取所有跨境的流量。
重组流量
要从海量的流量中分析出非法流量,一个完整的 TCP 握手是必须的。GFW 会持续监听并记录流量,直到获得一个完整的 TCP 握手连接,等待 TCP 连接建立之后,只审查连接中的第一个数据包,并且 GFW 不会等到服务器的 ACK 响应时才去阻止一个连接。
审查机制
审查机制针对的就是使用代理的流量。在能够获取所有的出境流量之后,一个很重要的任务就是在这些海量的流量中识别出非法流量(例如代理流量)并对其进行阻断。目前 GFW 的审查机制主要有两种:主动探测和被动检测。
主动探测
GFW 会主动连接可疑的服务器,通过与可疑服务器进行深度交互,最大限度地获取流量特征。一个非常经典的例子就是 Tor 网桥。博客原文
使用国内的 IP 去连接一个境外的 Tor 网桥,会被 GFW 发现。然后 GFW 会亲自假装成客户端,用 Tor 协议去连接那个服务器,如果确认是 Tor 网桥,则会封堵当时使用的那个端口,即使更换端口,后续也将被 GFW 封堵。
被动检测
GFW 目前的工作重心应该是在被动检测上,也就是识别可疑的代理流量并予以阻断。
根据论文 《 中国的防火长城是如何检测和封锁完全加密流量的》,自 2021 年 11 月以来,GFW 就使用了一种新的审查机制,来对非法的出境流量进行封堵。这种新的审查机制可以实时地仅使用被动检测来阻断全加密流量(包括但不限于 Shadowsocks、VMess 等)。论文作者通过实验得出以下结论:
- GFW 并未明确定义什么是完全加密的流量;
- GFW 使用豁免规则来豁免不太可能是完全加密的流量,然后阻断其余未被豁免的流量;
豁免规则
作者通过大量的实验数据,推断出 GFW 可能使用的豁免规则如下:
- 基于熵的豁免规则
- 基于可打印字符的豁免规则
- 基于常见协议的豁免规则
基于熵的豁免规则
Popcount 豁免: GFW 计算每个连接的第一个请求,每个字节的平均 1 比特数(popcount),作为衡量熵的粗略标准,如果该值小于 3.4
或大于 4.6
,则豁免这个连接。
我对这条规则的理解是:“1” 的数量决定命运。GFW 似乎认为,全加密流量中,0 比特和 1 比特的数量是接近平衡的,而普通流量由于 0 填充的协议头或者可打印字符的存在,0 比特和 1 比特的数量是不太可能平衡的。又因为在信息论中,熵越高,数据越随机、越不可预测,由于全加密流量看起来就是随机数据一样,0 和 1 的分布接近均匀,熵值很高,所以,GFW 就采用了这种简单粗暴的方式,审查客户端的有效载荷(pkt)中的平均 1 比特数(popcount),当每字节中的 1 比特数小于 3.4 或大于 4.6 时,就豁免这个连接。
基于可打印字符的豁免规则
该豁免规则包含以下子规则:
- 前 6 个字节是可打印的: 如果一个连接的第一个请求中前 6 个字节都是可打印的,就豁免这个连接;
- 有一半的有效载荷是可打印的: 如果一个连接的第一个请求中有超过一半的有效载荷都是可打印的,就豁免这个连接;
- 超过 20 个连续的字节是可打印的: 如果一个连接的第一个请求中有连续 20 个可打印字节出现,就豁免这个连接;
基于常见协议的豁免规则
作者在论文中明确,GFW 目前已豁免的常见协议包括但不限于 HTTP 和 TLS。
对于 HTTP 协议,豁免的规则比较简单,如果一个连接请求以请求方法(GET、POST、PUT、HEAD)开头,并且请求方法后跟一个空格,那么这个连接就会被豁免,如果不包括这个空格字符,或用任何其他字节代替它,就不能豁免连接。
TLS 连接以 TLS ClientHello
消息开始,该消息的前三个字节会使 GFW 豁免连接,与之匹配的正则表达式如下:
[\x16-\x17]\x03[\x00-\x09]
GFW 的阻断措施
GFW 常用的阻断措施有以下几种:
- DNS 污染
- RST 阻断
- 封锁端口
- 封锁 IP
DNS 污染
通过 DNS 污染,导致无法通过域名正常请求服务器,例如 Google、GitHub 等常见境外网站,就是采用 DNS 污染的方式进行阻断,所以 GitHub 有时能直连,有时又不能。
污染的方式有两种:
- 直接在 ISP 提供的 DNS 解析服务中添加虚假的解析记录,当使用此 DNS 服务器的网络用户访问特定网站时,DNS 服务便给出虚假的 IP 地址,导致访问网站失败,甚至返回 ISP 运营商提供的出错页面和广告页面;
- GFW 设置黑名单,在使用 UDP 协议的 53 端口上进行过滤,遇到通往国外的使用 DNS 请求,就返回一个虚假的 IP 地址;
RST 阻断
根据 TCP 协议的约定,当客户端接收到服务端返回 RST 包时,就认为连接被中断了,浏览器里常看到的 Connection was reset 就属于这一类。这也应该是 GFW 最常使用的阻断方式。大部分的 RST 是条件触发的,例如 URL 中包含某些关键字,或是特定的 IP 地址和端口号,都会被无条件重置连接。
封锁 IP
封锁 IP 一般是人工检测之后的应对措施,全局性的 IP 封锁应当是需要人工介入的。封锁 IP 的实现方式大致如下:
- GFW 将需要拉黑的 IP 地址及其路由添加到主干路由器的路由表中;
- 主干路由器使用 BGP 协议广播路由表,其余主干路由器动态更新;
- 当主干路由器接收到黑名单中的路由时,直接丢弃该 IP 包即可;
封锁端口
封锁端口通常是针对代理服务器的,GFW 在检测到代理流量之后,不仅可以通过 RST 阻断连接,还可以通过骨干路由器,对特定 IP 的特定端口进行封禁,可疑理解为骨干路由器上具有 iptables
的能力,即网络层到传输层的实时拆包解析。
这种 iptables
的能力,在 CISCO 路由器上称作 ACL Based Forwarding (ABF),并且得益于分布式部署,这种规则的同步是全国实时的,一台路由器封了端口,其余的骨干路由器都会立刻同步封禁规则。
GFW 的位置探索
根据前面提到的 GFW 架构方式,GFW 应当是部署在出口路由附近,所以可以有以下方式粗略探测 GFW 的地理位置:
根据 TCP 协议,IP 数据包每经过一个路由器,就会将 TTL-1 ,直到 TTL 耗尽时,IP 数据包到达的路由器就不再将 IP 数据包转发到下一跳,而是返回一个 ICMP 报文,并将 ICMP 报文中的 src 设置为自己的 IP。所以,理论上通过枚举 TTL 值,向被封禁的境外主机发送 SYN 报文,当收到 GFW 要求返回的 RST 包(RST 阻断)或是长时间无法进行至下一跳(DNS 污染)时,就到达了出口路由,再提取 ICMP 报文中的源 IP,就得到了出口路由的 IP 地址,此时使用 IP 归属地数据库查询即可获得 GFW 的大致位置。
这种方式仅能粗略查询,并且可能存在谬误,并不能获得精确的位置。
脚本如下:
from scapy.all import *
from scapy.layers.inet import IP, TCP, ICMP
import time
timeout = 3
if __name__ == '__main__':
hostname = 'x.com'
dst_host = socket.gethostbyname(hostname)
dst_port = 443
for ttl in range(1, 128):
time.sleep(timeout)
# 创建SYN请求
pkt = IP(dst=dst_host, ttl=ttl)/TCP(dport=dst_port, flags="S")
reply = None
for retry in range(0, 16):
# 发送SYN请求
reply = sr1(pkt, verbose=0, timeout=timeout)
if reply is None:
print(f"{ttl:2d}: *")
time.sleep(timeout)
continue
else:
break
if reply is None:
print(f"{ttl:2d}: Retry failed, there may be DNS pollution")
break
if reply.haslayer(ICMP):
# 接收到ICMP报文,即TTL耗尽时到达的路由器
router_ip = reply[IP].src
print(f"{ttl:2d}: {router_ip}")
elif reply.haslayer(TCP) and reply[TCP].flags & 0x04:
# 接收到RST报文,即到达GFW
print(f"{ttl:2d}: RST received! GFW detected")
break
else:
print(f"{ttl:2d}: Unexpected packet received")