使用WebSocket进行网络穿透

最后更新时间: 2018-05-27 | 作者: AberSheeran | 捐助

网络穿透的本质就是代理,而想要稳定的翻墙,必须要把代理伪装成一个正常的网络请求,而这一点上,在拜读了Shadowsocks的源码之后,我觉得它还不够,因为Shadowsocks的连接只能让GFW知道这个是个未知的协议。虽然SSR的混淆做的比较好,然而Breakwa11都删库了,他那个神仙代码我实在是没法维护,还是自己写吧。

在之前的一篇博文里,我写了一个Sock5代理,但单纯的Socks5代理是无法翻墙的,因为GFW能轻易的分析出你是一个代理,从而封掉你的海外IP。

在研究完了Shadowsocks的混淆代码之后,我把目光盯上了WebSocket这个新协议。

WebSocket

WebSocket是一种基于TCP的长连接,它会使用一次HTTP的报文握手,在那之后便是WebSocket自身规定的方式进行通讯了。在这里不提WebSocket的好处在哪 (或许我会写一篇博文来介绍这个🙂),但可以知道的是,这个玩意是个在各种网站都可能被用到的协议。既然它常见,那么就安全。并且,我们可以把一个基于WebSocket的代理服务藏在正常的网站的某个url中,隐秘性Max。

握手

首先,我们需要使用HTTP的方式进行一次握手,才能建立WebSocket的长连接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import base64
import random

def _random_bytes(length: int) -> bytes:
    result = bytes()
    for _ in range(length):
        result += random.randint(0, 255).to_bytes(1, 'big')
    return result

def pack_handshake(proxy: tuple) -> bytes:
    req = f"GET ws://{proxy[0]}:{proxy[1]}/websocket HTTP/1.1\r\n"
    headers = {
        "Host": proxy[0],
        "Upgrade": "websocket",
        "Connection": "Upgrade",
        "Origin": "http://" + proxy[0],
        "Sec-WebSocket-Key": base64.b64encode(_random_bytes(16)),
        "Sec-WebSocket-Version": "13",
    }
    req += "\r\n".join([f"{key}:{value}" for key, value in headers.items()]).encode("UTF-8")
    return req + b"\r\n\r\n"

对于小众翻墙工具而言,直接在URI里带上目标网址和端口是没问题的。如果要保证更好的抗审查,可以在建立长连接之后按照自己的协议来交互。

在握手包发过去之后,服务端会做出如下响应:

1
2
3
4
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept的计算方法是将客户端上报的Sec-WebSocket-Key和一段GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)进行拼接,再将这个拼接的字符串做SHA-1 hash计算,然后再把得到的结果通过base64加密,最后再返回给客户端,RFC 6455 定义的算法如下:

Sec-WebSocket-Accept = base64-value-non-empty
base64-value-non-empty = (1*base64-data [ base64-padding ]) | base64-padding
base64-data = 4base64-character
base64-padding = (2base64-character "==") | (3base64-character "=")
base64-character = ALPHA | DIGIT | "+" | "/"

交互

在握手完成之后,一个WebSocket长连接就建立起来了。为了保证使用任意的符合标准的WebSocket服务器都能够作为我们的服务端,我们将严格按照标准来进行数据包的传递。

WebSocket有自己的传递规则。数据传输按帧来算,每一帧格式如下:

第一个byte:

FIN RSV OPCODE
长度 1bit 3bit 4bit
作用 标识本帧数据是否为最后一帧 留作日后扩展用 传输数据的类型

第二个byte:

MASK PAYLOAD_LEN
长度 1bit 7bit
作用 是否使用掩码 本帧数据长度

数据长度小于等于125时,PALYLOAD_LEN本身即是实际的数据长度。

但数据长度大于125时:

  1. PAYLOAD_LEN为126即表示实际长度由其后两个字节(16bit)来表达。
  2. PAYLOAD_LEN为127即表示实际长度由其后八个字节(64bit)来表达。

如果MASK是0b1,那么在其后的四个字节(32bit)为MAKING_KEY,用以对客户端发出的数据进行异或运算。
WebSocket协议规定,客户端发出的数据帧必须与掩码进行异或运算,而服务端发出的数据帧必须不能与掩码进行异或运算,否则必须主动关闭WebSocket连接。

那么一个数据包进来,我们进行如下处理即可变为WebSocket通讯的数据帧形式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def analyze(data: bytes) -> bytes:
    length = len(data)
    result = bytes()
    FIN = 1
    RSV = 0
    OPCODE = 2
    MASK = 1
    if length <= 125:
        PAYLOAD_LEN = length
        EXTEND = None
    elif length < 65536:
        PAYLOAD_LEN = 126
        EXTEND = struct.pack('>H', length)
    else:
        PAYLOAD_LEN = 127
        EXTEND = struct.pack('>Q', length)
    header = struct.pack('>H', (((FIN << 7) + (RSV << 4) + (OPCODE)) << 8) + (MASK << 7) + (PAYLOAD_LEN))
    MASKING_KEY = struct.pack(">I", 13)
    # 使用掩码进行异或运算
    for index, item in enumerate(data):
        result += (item ^ MASKING_KEY[index % 4]).to_bytes(1, byteorder="big")
    # 信息长度不超过125
    if EXTEND is None:
        return header + MASKING_KEY + result
    # 信息长度大于125
    return header + EXTEND + MASKING_KEY + result

其实这个代码有一点瑕疵,我并没有对数据超过2^64的时候进行处理,但18446744073709551616个byte,光内存都炸了,还处理个屁咧(笑)。


一个代理就这样写好了,除了资源占用高于Shadowsocks以外,亲测速度十分不错。最重要的是,它的所有表现都是一个正常的Websocket——我直接把host和port抄了抄socks5的传递方式,塞进数据包里。

最后,不打算开源。肉身在大陆,告辞。

收录于#Hack