我们知道,墙封锁一个网站有 DNS 污染、IP 封锁、TCP Reset(TCP 连接重置)等手段。而一个网站一旦被墙,一般情况下是无法直接通过 301(或 302)跳转到其他网站的。如果只是 IP 被封还好说,换 IP 通常能解决问题。但如果是根据域名关键字进行的 TCP Reset,这时候不管怎么换 IP(除非是国内 IP)都无法解除封锁,当然也不可能进行 301 跳转(浏览器在收到 HTTP 服务器的 301 跳转 Response 之前 TCP 连接就已经被墙 Reset 而断开了,浏览器根本收不到 HTTP 服务器的任何 Response)。而 DNS 污染的话自然更不用多说,只能换域名了,301 跳转更不可能做到。然而,现在出现了很多号称可以解决域名被墙的服务,可以在网站被墙后通过 301 跳转到新的网站上。经过测试,还真能做到绕过墙的 TCP Reset 封锁,而这些服务的 IP 却都在海外(并非是使用了国内 IP 避免被墙的原因),而客户端只需要一个正常的浏览器即可(即客户端并不需要开启科学上网)。那么它们是怎么做到的呢?
要解释清楚其中的技术原理,还得回到 2010 年的 西厢计划 。很早就经常科学上网的同学们应该都对 西厢计划 并不陌生,它是一个只需要运行在客户端就能绕过很多封锁访问目标网站的工具,解决 TCP Reset 的原理是对本地的 TCP/IP 协议进行修改,在不伤害客户端和服务器之间的 TCP 连接的前提下让墙误以为 TCP 连接已经断开或者无法正确跟踪到 TCP 连接。之后出现的 INTANG 项目 同样是这个想法的延续。
不过,不管是 西厢计划 还是 INTANG,都是运行在客户端上的工具,理论上只在服务器上运行无法起到效果,经过测试也能看到实际和理论相符。那么有没有一种工具可以在只服务器上运行,修改 TCP/IP 协议从而绕过封锁的工具呢?这方面同样有团队做了研究,研究的成果就是Geneva 项目,GFW Report 也对其做了 详细介绍 。在 这篇文章 中,列举了 6 种可以绕过 TCP Reset 的规则,6 种规则都可以在只客户端部署生效(这时候服务器并不需要运行 Geneva),而前 4 种可以在只服务器部署生效(这时候客户端并不需要运行Geneva)。不过Geneva 的官方 Github 中只收录了客户端的规则,文章 中的服务器规则并没有被收录在 Geneva 的官方 Github 中。而且 文章 中的策略 3 只给出了客户端的规则,遗漏了服务器端的规则。经过阅读 Geneva 的规则介绍和策略 3 的描述,我已经重新还原了策略 3 的服务器规则,重新收录了 4 种服务器规则到我自己的 Github Fork 中。经过本地环境的模拟加上 tcpdump 抓包观察测试,看到还原的策略 3 服务器规则和 文章 中描述的行为一致,可以认为就是策略 3 本来的服务器规则。但是,在之后的真实环境的测试中发现这 4 种服务器策略全都失效了(不管 HTTP 还是 HTTPS 都已失效),墙依然对 TCP 进行了 Reset。经过抓包看到服务器的行为确实和 文章 中描述一致,所以可以确认并非是由于 Geneva 没有正常工作导致的,而是墙已经为了应对这 4 种策略进行进化了。所以,墙并不是一成不变的,而是会进化的,那我们又该怎么办呢?
讲到这里,就不得不提另一个策略发现工具 SymTCP 了。虽然现有的 4 种策略已经失效,但并不代表我们不能发现新的策略。而 SymTCP 就是新策略发现工具,通过自动学习可以自动发现新的策略绕过墙的 TCP Reset。之后我们就能把新的策略转换为 Geneva 的规则格式进行使用了。不过,这样的话我们就会陷入到和墙的无休止争斗中,不断发现新策略,而墙则不断封锁新策略。而且规则的转换也是一个麻烦事,暂时还没有工具可以自动从 SymTCP 的规则转换为 Geneva 的规则,需要人工转换。并且需要修改 SymTCP 使其不仅可以发现客户端规则同样也能发现服务器端规则。
那么,有没有一种一劳永逸的方法,使墙再怎么进化也无法避免这种策略的影响,而且这种策略只需要运行在服务器上,从而绕过封锁呢?在下结论之前,我们需要来研究一下一个正常的 HTTP 协议通讯是怎么进行的:
1、浏览器发起 TCP 连接,经过 3 步(次)握手建立和服务器的 TCP 连接。
2、浏览器发送 HTTP Request。
3、服务器收到 Request,发送 HTTP Response。
而墙通常在看到浏览器发送的 HTTP Request 中包含关键字就会进行 TCP Reset。讲到这里,聪明的同学或许已经想到了:如果服务器不等 HTTP Request,而在 TCP 连接建立后立即发送 HTTP Response,在墙进行 TCP Reset 之前就将 Response 送到浏览器进行抢答,是不是就能绕过 TCP Reset 了?而且还能无视之后墙的进化(因为浏览器的请求根本还没有经过墙)?说干就干,由于抢答模式不符合 HTTP 规范,所以常见的 HTTP 服务器无法实现抢答模式,所以让我们写个 Python 小程序来测试一下:import socket import threading import time def main(): serv_sock = socket.socket() serv_sock.bind(('0.0.0.0', 80)) serv_sock.listen(50) while True: cli_sock, _ = serv_sock.accept() # 关闭 Nagle 算法,立即发送数据 cli_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) cli_sock.sendall(b'''HTTP/1.1 302 Moved Temporarily\r\n''' b'''Content-Type: text/html\r\n''' b'''Content-Length: 0\r\n''' b'''Connection: close\r\n''' b'''Location: https://www.microsoft.com/\r\n\r\n''') def wait_second(): time.sleep(1) # 等待 1 秒钟,确保数据发送完毕 cli_sock.close() threading.Thread(target=wait_second).start() if __name__ == '__main__': main()
写完了来测试一下,发现依旧被 TCP Reset 了。那么,问题出在哪里?让我们重新回到上述 HTTP 协议通讯的 3 个步骤中的第 1 步——TCP 的 3 步握手:
从 TCP 的 3 步握手中,我们可以看到第 3 步中客户端发送了 ACK 就已经完成了 TCP 连接的建立,这时候客户端并不需要再等服务器的回复就能立即发送数据。也就是说,浏览器会在发送 ACK 后立即发送 HTTP Request,ACK 和 HTTP Request 几乎是同时发出的。而服务器在收到浏览器的 ACK 后基本也就代表着已经收到了 HTTP Request 了,抢答失败!
那么,有没有办法让浏览器在 TCP 连接建立后延迟发送 HTTP Request,而又不改动客户端行为呢?讲到这里,对 TCP 协议比较熟悉的同学或许已经想到了,那就是 TCP window size。而通过调用 setsockopt()
就能修改 TCP window size。让我们来修改一下 Python 小程序,把 window size 改为 1 再进行测试(TCP 连接建立完成后,客户端只能发送 1 个字节,等待服务器的确认后才能继续发送更多的数据):import socket import threading import time def main(): serv_sock = socket.socket() serv_sock.bind(('0.0.0.0', 80)) serv_sock.listen(50) while True: cli_sock, _ = serv_sock.accept() # 设置 TCP window size 为 1 cli_sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1) # 关闭 Nagle 算法,立即发送数据 cli_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) cli_sock.sendall(b'''HTTP/1.1 302 Moved Temporarily\r\n''' b'''Content-Type: text/html\r\n''' b'''Content-Length: 0\r\n''' b'''Connection: close\r\n''' b'''Location: https://www.microsoft.com/\r\n\r\n''') def wait_second(): time.sleep(1) # 等待 1 秒钟,确保数据发送完毕 cli_sock.close() threading.Thread(target=wait_second).start() if __name__ == '__main__': main()
改完测试,发现仍旧被 TCP Reset 了。什么原因?通过抓包,我们看到对 TCP window size 的修改并没有生效,window size 依旧很大。在查阅了 Linux man page 后我们看到关于 SO_RCVBUF
有这么一段话:
The minimum (doubled) value for this option is 256.
这也就意味着即使我们通过 setsockopt()
将SO_RCVBUF
设置为 1,Linux 内核也会将其作为 256 处理。而 Linux man page 中也对 SO_RCVBUFFORCE
做了说明:只能突破最大值的限制(but the rmem_max limit can be overridden
),但不能突破最小值的限制。而 256 字节基本就可以容纳整个 HTTP Request。看来通过 setsockopt()
行不通(不管 SO_RCVBUF
还是 SO_RCVBUFFORCE
都行不通),我们得找别的方法。
讲到这里,我们很自然地又想到了 Geneva:上述Geneva 的策略 2 中服务器规则正是利用了 TCP window size 做到的四字节分割(设置 window size 为 4)。这样,就绕过了 setsockopt()
的限制,直接对 TCP 数据包进行修改了。
在我们把四字节分割法部署到服务器运行 Geneva 后,再结合上述 Python 小程序,经过测试我们发现已经成功绕过了 TCP Reset,浏览器跳转到了微软网站。我们终于成功了!
然而,在浏览器第二次访问服务器时发现依然被 TCP Reset 了。不过,这已经影响不到 301 跳转(上述 Python 小程序还是 302 跳转,需要 301 的同学自行修改)了,301 跳转的话浏览器已经被重定向到新的网站了,不会再次访问这个服务器(需要保证新旧网站不能使用相同 IP),但这并不妨碍我们继续探究一下为什么第二次访问会被 TCP Reset:通过抓包我们看到,第一次访问时浏览器虽然在第一个附带用户数据的数据包中只发送了 4 个字节,但后续会将剩余的整个 HTTP Request 通过一个数据包发送到服务器导致 TCP Reset。而墙是有审查残留的,一段时间(几分钟)内不管是否出现关键字,对源 IP 和目标 IP 之间的 TCP 连接会进行无差别的 Reset。所以在之后的这段审查残留时间内,只要 TCP 连接建立就会被 Reset,抢答模式无法起到作用。
知道了原因我们就能采取对策了,我们知道客户端是因为收到了服务器确认数据包中的 TCP window size 很大,所以才能一次性把剩余的 Request 发送完毕,所以需要对后续的 TCP window size 做同样的修改,保证客户端看到的 window size 一直处于比较小的水平:通过对 TCP 协议的了解,我们知道连接建立时的 window size 是通过 SYN+ACK 包确定的,而后续的 window size 是通过 ACK 包确定的。所以,我们对规则 2 做少许的修改就能做到对后续 window size 的修改:
[TCP:flags:A]-tamper{TCP:window:replace:1}-|
在服务器上我们同时运行规则 2 和上述修改后的规则(需要开 2 个 Geneva 进程,注意第 2 个进程需要在命令行中指定 –in-queue-num 和 –out-queue-num 避免和第 1 个冲突),我们终于能稳定地运行上述抢答模式,再也不会被 TCP Reset 了。
实际上我们可以将 2 条规则中的 window size 都设置得更小一些,甚至设置为 0,避免客户端发送任何数据(实际上由于 window size 探测机制的原因,客户端仍旧会以极慢的速度一个字节一个字节地发送数据,不过不影响我们的抢答模式):[TCP:flags:SA]-tamper{TCP:window:replace:0}-| [TCP:flags:A]-tamper{TCP:window:replace:0}-|
至此,HTTP 的抢答模式就基本完成了。至于海外 301 跳转的那些服务可以同时服务于多个网站,原理也很简单:它们的名称虽然都是 301 跳转,但实际上并不一定必须使用 301 跳转——以上 Python 小程序可以修改为通过 HTTP 200 返回一个正常的 HTML 页面,其中嵌入一个 JavaScript,在 JavaScript 中就能判断浏览器的网址进行条件跳转了。至于跳转规则,那大家就能在 JavaScript 中充分发挥自己的想象了。另外,由于 Geneva 和上述小程序都是用 Python 编写的(甚至都没有使用 asyncio),性能会比较差一些。Geneva会自己添加 iptables 的 NFQUEUE 规则,不过规则太过于宽松,导致不需要处理的数据包也会经过 Geneva。所以大家可以在启动Geneva 后手动删除这些规则,自行添加更精确的规则(只处理 OUTPUT 链中 TCP 80 端口的 SYN+ACK 和 ACK)。不过经过我的测试,即使使用精确的 iptables 规则也差不多只能利用 20Mbps 左右的带宽。如果希望有更高的性能,可以使用 C /C++(结合 libev,或 asio,或直接 epoll;或者使用 golang、rust 等)结合 libnetfilter_queue,利用 iptables 的 NFQUEUE 来完成,可以跑满千兆带宽。其实Geneva 的底层用的也是 libnetfilter_queue 和 NFQUEUE。由于本系列只做概念验证,而且因为篇幅的限制(本文已经很长了),在此就不展开 C /C++ 的实现了,感兴趣的同学可以和我联系,如果感兴趣的同学比较多的话我就再新开一个系列讲一下这方面的内容。另外需要注意的是,Geneva的编译环境为 Python 3.6,使用 Debian 10 自带的 Python 3.8/3.9 会出现各种莫名其妙的问题,建议大家还是从 Python 官网下载 Python 3.6 的源码进行编译使用Geneva。
在解决了 HTTP 的 TCP Reset 问题后,我们还需要解决 HTTPS 的 TCP Reset。而 HTTPS 由于需要完成 TLS 握手才能发送 HTTP Response,所以抢答模式似乎无法应用于 HTTPS。在下一篇中,我会介绍几个绕过 HTTPS 的 TCP Reset 方法。敬请期待。
如果对本系列话题感兴趣的同学也可以联系我。对 Geneva 的使用有疑问的,或者对 C /C++ 实现 Geneva 类似功能感兴趣的都可以联系我。我的联系方式为:
1、Email: lehui99#gmail.com
2、Twitter: @davidsky2012
3、TG: @davidsky2000
4、本系列 Github: lehui99/articles
BTW,本来想将此文首发于 hostloc 的(因为 hostloc 上站长朋友比较多,而本系列是帮助站长解决 TCP Reset 问题的),不过由于 hostloc 需要邀请码,一直没有注册成功,而淘宝购买 hostloc 邀请码曾经有过不愉快的经历,遂作罢。(感谢 岁月去堂堂 赠送的邀请码,已注册hostloc。)
最后,本文欢迎转载,不过转载时还请保留 本文链接,因为之后我还会对本文进行完善(如错误修正、内容补充等)。