使用 Wireshark 对 Tor 进行抓包
尝试从 TCP 层面来研究 Tor 的流量。
实验环境
由于整个实验涉及很多环境问题,因此首先对环境进行描述。
系统环境
Tor 本身运行于 Windows 10 的 Windows Subsystem for Linux 2(WSL2) 中,WSL2 为 Arch Linux。
$ screenfetch -` .o+` ohyee@OhYee-wsl `ooo/ OS: Arch Linux (on the Windows Subsystem for Linux) `+oooo: Kernel: x86_64 Linux 4.19.128-microsoft-standard `+oooooo: Uptime: 5h 26m -+oooooo+: Packages: 713 `/:-:++oooo+: Shell: zsh 5.8 `/++++/+++++++: Resolution: 2502x2413 `/++++++++++++++: WM: Not Found `/+++ooooooooooooo/` GTK Theme: Adwaita [GTK3] ./ooosssso++osssssso+` Disk: 873G / 1.2T (73%) .oossssso-````/ossssss+` CPU: Intel Core i7-6600U @ 4x 2.808GHz -osssssso. :ssssssso. RAM: 548MiB / 15932MiB :osssssss/ osssso+++. /ossssssss/ +ssssooo/- `/ossssso+/:- -:/+osssso+- `+sso+:-` `.-/+oso: `++:. `-/+/ .` `/
Tor 版本
Tor 为从 Arch 仓库下载的版本(自己编译的版本存在奇怪的bug,为了不引入不相关变量,使用更为稳定的分发)
$ tor -v Tor 0.4.3.5 running on Linux with Libevent 2.1.11-stable, OpenSSL 1.1.1g, Zlib 1.2.11, Liblzma 5.2.5, and Libzstd 1.4.5.
网络拓扑
WSL2 本身由 Windows 10 对自己的网络进行新的 NAT 转换,在实验过程中,Windows 的 IP 为172.24.16.1
,而 WSL 的 IP 为172.24.20.228
。
物理机通过 WLAN 连接至路由器,路由器系统为 OpenWRT,IP 为192.168.66.1
,物理机 IP 为192.168.10.1
。
路由器通过 WLAN 连接至北邮校园网(BUPT-mobile)中。
其中,物理机使用 V2RayN 在 1080 端口开放 Socks5 隧道,路由器使用 OpenClash 的 TUN 模式进行全局代理。
由于是使用 Wireshark 在物理机进行抓包,因此原则上,会影响实验的只有 Windows 和 WSL2 的 NAT,以及物理机中的 V2RayN。
抓包配置
首先关闭 WSL 中所有进程,使用 Wireshark 对 vEthernet(WSL) 虚拟网卡进行抓包。
静置十余秒无任何链接(说明几乎没有其他流量影响抓包效果),接着启动 Tor(Tor 被配置使用物理机的 V2RayN 连接网络)。等待提示初始化完成后,关闭 Tor,并静置一段时间后停止抓包。
抓包结果
共有 178 个分组被抓取
分组信息可从 CDN 下载
1~3 分组
由于 Tor 需要通过 Socks5 连接到 V2RayN,并通过 VMess 连接至 Tor 网络。因此,对 Tor 而言,其需要先连接到 V2RayN,后续所有操作都通过该 Socks5 隧道。
Socks5 协议本身基于 TCP 协议,因此前三个分组必然是标准的 TCP 三次握手。首先,客户端向服务端发送 SYN,服务端向客户端返回 ACK,并发送 SYN(这两个是一个分组),客户端收到服务端的 SYN 后返回 ACK。
原则上,这三个包应该不包含其他信息,只通过 TCP 头部的 Flag 设置相应的位来表明类型,但在这里,可以看到在 TCP 的可选头部中,对 TCP 窗口等信息进行了初步协商。
在这三个包完成后,即完成了 TCP 握手部分,TCP 连接正式建立完成。
4~8 分组
与 TCP 一样,Socks5 也需要握手,
第 4 个分组就是 Socks5 的握手请求。表明当前使用的是版本 5,客户端支持一个身份验证手段,并且不需要身份验证。
各种不同的身份验证方法如下1:
- 0x00: 不需要认证(常用)
- 0x01: GSSAPI认证
- 0x02: 账号密码认证(常用)
- 0x03 - 0x7F: IANA分配
- 0x80 - 0xFE: 私有方法保留
- 0xFF: 无支持的认证方法
这个包需要额外说明的是 TCP 的PSH
字段。PSH
字段表示的是将强制推送2。
在这里需要对 TCP 进行一些解释。尽管在逻辑上,我们会将 TCP 数据分割成一个个有意义的内容,比如这一部分是握手,这一部分是 HTTP 请求,但是实际上 TCP 是一个面向流的协议,在 TCP 中,各种数据可能是混杂在一起的。
如果有过 TCP 编程经验,应该有听说过“粘包”的概念。尽管这个名词严格意义上犯了基础性的错误,但是它很好地解释了面向流的不直观性。比如我们需要传输一个很长的文件,在文件传输完成后,发送一个额外意义的 finish 字符串。通常而言,代码应该会写成
write(file_data); write("finish")
看上去这是两端独立含义的包,但是实际使用中,由于 TCP 有着最大长度,我们的文件信息可能会被分成很多个分片。也即可能整个文件被包含在多个 TCP 分组中,接收端需要使用循环来保证可以接收到所有的包,而不是直接使用两次 read
file_date = read() finish = read()
上述的代码是完全不可用的,需要使用类似下面的代码才可以正常使用
file_data = new Buffer() for { buf = read() if isFinish(buf) { file_data.push(buf[:-6]) break; } else { file_data.push(buf) } }
简而言之,无论在程序中,如何发送内容,TCP 实际的传输可能会将数据分成多个不同的分组,甚至会将两次write
的内容写入到一个包中,应该将整个传输的数据视为一整个流。
要解决粘包的办法也很简单,一种是像 HTTP 协议一样,一个 TCP 请求只有一个请求和一个相应,但是这样写非常浪费 TCP 的性能;另一种是使用某种特殊的格式对内容进行封装,比如为数据添加头部,或使用特殊的结束字符。每次读取数据的判断是是否已经读入了头部所声称的文件长度,或者发现特殊的结束符。
回归原文,这里PSH
的含义是将发送缓冲区的内容推入网络,或者将接收缓冲区的数据传输给接收端程序。在大部分语言中,会将程序的一个write
视为一个具有单独含义的内容,将这些数据分组后,给最后一个分组添加PSH
,这样就不需要等待写出缓冲区满再发送了(因为前面的分组一定已经满了,所以自然不需要添加PSH
)。
比如这里的 Socks5 握手,请求只有三个字节内容,自然是不可能塞满整个 TCP 包的,而后续内容的的发送则需要握手被响应。因此这三个字节必须直接发送,不应该等待后续的write
。
第 5 个分组中,服务端返回响应,表明协议版本,以及验证信息(版本也是 5,且不需要验证)
如果需要进行验证,服务端会从客户端支持的验证方式中选择一个进行验证。
第 6 个分组是一个 TCP 的 ACK 包,由客户端(Tor)发送给服务端(V2RayN)。用于表明:我已收到你的数据包。如果在通信过程中长时间(超过滑动窗口大小个数据包)未接收到 ACK,那么则应该认为连接出现故障,需要重传甚至断开连接。
在这里可以发现,Sequence number 发生了跳变,与前面的每次增加 1 不同,这里直接增加了 3。这是因为 seq 本身代表的是:该 TCP 分组负载部分的第一个字节在整个 TCP 流中所属的位置。由于前面有三个字节的 Socks5 握手信息,因此这里序号应该为 4。
而 ACK 所代表的消息为期望对方下一次的 seq。如这里,期望下一个对方的携带 ACK 的 TCP 分组的 seq 是 3(因为服务端已经发送了 2 个字节的数据,下一次发送数据的 seq 是 3)。
第 7 个分组则是 Socks5 连接成功后,请求连接至指定服务器的请求。由于上一个 TCP 分组为纯 ACK,因此未携带数据,所以这一个分组的 seq 仍然是 4。
在这个分组中,首先发送了 Socks5 版本为 5,命令为 1(Connect),并且配置访问45.129.183.239
。
在这里,支持 IPv4、IPv6、以及域名:
- 0x01: IPv4地址
- 0x03: 域名,域名地址的第1个字节为域名长度,剩下字节为域名名称字节数组
- 0x04: IPv6地址
在这里,45.129.183.239
是我们的 Tor Onion Proxy(OP),选择的入口节点的地址3。也即,后续的 Socks5 内容都应该被转发至该地址。
第 8 个分组是 Socks5 服务端的响应,说明自己已经成功和目标服务器建立连接,并且返回了代理成功的服务器绑定地址和端口(server bound address)。
11~31
这些字段为由 Windows 物理机发送的服务发现协议,属于干扰流量,不需要考虑。
多播 DNS(multicast DNS, mDNS)4是一个在小型网络(如 NAT)内部的 DNS 系统,基于 UDP 协议,运行于 5353 端口。
局域网内的主机一般会拥有一个主机名,比如 xxx的MacBook、xxx的iPhone。如果在局域网内连接,实际上并不需要输入他们的 IP,只需要使用主机名即可连接。要维持这个 主机名-IP 的映射,就使用 mDNS 完成。设备们会定期向组播地址224.0.0.251
以及[FF02::FB]
,广播自己的主机名(会在末尾添加.local
)和 IP 地址,以供其他设备知晓(可以看到由于 NAT 会分配 IPv4 和 IPv6 地址,因此该协议跟同事在这两个协议下进行了组播)。当其他主机收到请求后,也会发回自己的响应,表明自己可能会进行连接。不过在抓到包中,似乎物理机 Windows 自己发送了广播,然后响应了自己的请求。
简单服务发现协议(Simple Service Discovery Protocol, SSDP)5是一个应用层协议,构成通用即插即用(UPnP)技术的核心协议之一。
其与上面的 mDNS 原理类似,主机加入到网络中后,会向239.255.255.250
和FF0X::C
发送 "ssdp:discover" 消息,来宣告自己的存在。其他应用在收到该信息后,会判断自己是否会用到其服务,进而判断是否需要与其进行连接。
位置解析协议(Address Resolution Protocol, ARP),相对于前两者,这个则比较知名。其是根据网络层地址(IP)来查找数据链路层地址(MAC)的协议。如果有系统学习过计算机网络,理论上都会见过该协议的介绍。可能更广为人知的是,如同内网 IP 在局域网内不能重复一样,MAC 地址在局域网内也不能
上述服务,都是在局域网内,主机互相发现的机制。后续在数据中仍然会有该数据包混杂
9~10,34~37
第 9 个分组是 TLS 的握手部分。TLS 本身是一个双层协议,底层为记录层,顶层为告警、握手、交换、数据协议。在记录协议中声明类型为 Handshark(22),采用 TLS 1.0 版本。
而握手协议则表明了这个握手的类型为 Client
Hello(1)——客户端发起握手。这里分别表明了握手协议为 TLS 1.2,并协商会话 ID、加密算法参数(客户端提供自己支持的协议,由服务端选择)、压缩方式、拓展字段。
第 10 个分组也是一个 ACK 分组,表明自己已收到 TCP 分组。
第 34 个分组是 服务端返回的信息。需要注意的是这个分组包含了多个 TLS 包:服务端握手相应,修改密码规程协议,加密传输数据
在 TLS 1.2 及之前版本中,当成功协商密钥后,就会发送修改密码规程协议(TLS 1.3 已经不需要该协议)
接下来就是双方使用前面协商好的密钥进行对称加密通信。所有的数据都通过 TLS 记录协议包裹后传输
32~33
这两个分组就是 TCP 的“心跳包”,学名为 Keep-alive。在这里的表现,其是一个设置了 ACK 标志,发送 1 字节 0x00 的 TCP 分组。
这里比较有趣的是,一般而言,我们认为心跳包不需要真正发送消息,但是这里的 Keep-alive 却发送了一个明显没有意义的 1 字节内容。这里实际上是为了避免部分 TCP 的错误实现做出的妥协(在 RFC 规范中是“应该”不发送内容)6。
在这里,心跳包的表现与 ACK 返回类似,只不过可能需要一个字节数据
38~174
数据传输部分。由于 TLS 本身数据已被加密,且尽管 Wireshark 处于中间人位置,但并未修改数据内容,因此无法执行中间人攻击。该部分暂时不做解析。
175~178
TCP 三次挥手,双方互相发送 FIN
并使用 ACK
回复