0x00 TL;DR

论文:这里

本来 SS/SSR 的非 AEAD 加密就已经死的差不多了,不缺这么一种攻击方式。不过攻击的思路确实很新颖。

单就这种攻击方法而言,不安全的 SS 加密方法:主要是 aes-*-cfbaes-*-ofb, aes-*-ctr;安全的 SS 加密方法主要是 chacha20aes-*-gcm*-poly1305

如何补救:将加密方法换成 aes-*-gcm,或是转战其它工具。

0x01 攻击方法详解

写在开始之前:

我写这篇博文只是想仔细剖析一种新的重放攻击的具体过程,其中内容仅作学术讨论用。

对于 SS 和 SSR 的开发、维护团队的爱恨情仇,我无意参与;大家如果心中已有定论,也不要询问我相关的内容及我本人的意见,且本篇博文中也不会涉及相关内容。

攻击方法其实真的很简单。

才怪嘞

写了一半,回来补充:尊敬的论文作者,您能不能把细节写清楚啊……给您跪了啊……

密文修改目标

流式加密的数据是被分装在不同的数据包里的,将这些数据包拼接在一块就能得到所有的密文。

密文格式如下:

1
[IV][encrypted payload]

其中的 IV 在解密 encrypted payload 时会用到,不需要操心它具体是干什么用的。

代理内容以明文 HTTP 请求为例,根据 SS 所使用的协议,将要向服务器发送的原始数据(加密前)的格式如下:

1
[target data][payload]

payload 部分储存着所有的 HTTP 请求的数据,target data 中储存着关于最终目的服务器(想要访问的网站所在的服务器)的重要信息。target data 的结构如下:

1
[type][destination address][port]

type 可能为 0x010x020x04(参见 RFC 1928type == 0x01 表示随后的 destination address 部分是个 4 字节的 IPv4 地址,也就是目标服务器的 IP 地址。port 是 2 字节的端口号。

需要知道的知识:由于 AES 流式加密的特性,明文的每个 block 在加密后出现在密文的相同位置,类似于“先入先出”概念。因此,target data 对应的密文一定会出现在第一个数据包(“初始化数据包”)中。

每个 block 的长度为 16 字节,一般与 IV(初始化向量)的长度相同,详见这个链接

如果,我们能够修改密文,使得解密后的明文的 destination address 部分储存的 IPv4 地址指向我们自己控制的服务器的 IPv4 地址,不就能够使得服务器端的 SS 将解密后的 HTTP 请求发给我的服务器了吗?

normal-procedure.jpg attacked-procedure.jpg

备注:图 2 中对于 GFW 节点的工作方式有一定的简化,与实际情况不完全一致,仅作示意用途。

从上图中可以大概了解到这种攻击的流程。攻击者在收到境外服务器发送给境内用户的可疑数据包时,将数据包复制进内存,并在一定的延时(几十毫秒到几十秒)后,将修改过密文的数据包反向发送给 ss-server,用一定的手段使 ss-server 把这个数据包解密后的明文发送给受攻击方控制的服务器。当然,由于不知道秘钥,攻击者不可能通过加密明文的方式得到他想要的密文。但是,由于 aes-*-cfbaes-*-ofb 的特性,可以通过异或密文的最开始的一个 block 来对应地修改明文的第一个 block

aes-cfb-decryption

aes-*-cfb 为例,上图的解密流程可以转化为:

密文、明文下标均从 1 开始,解密函数与加密函数都是 \(\mathrm{Encrypt}\) 函数。

\(K\) 是解密所需的秘钥(\(Key\))。

\(P_i = \mathrm{Encrypt}(C_{i-1}, K)\,\,\,\mathtt{xor}\,\,C_i\),其中 \(C_0=\mathrm{IV}\)

我们不妨假设所有 ss-server 发送给 ss-local 的数据包都是 Encrypted HTTP Response(未经 TLS 加密),也即解密后的明文遵循如下格式:

1
2
3
4
5
6
HTTP 1.1 404 Not Found                 # HTTP 版本 状态码 状态文字
Date: Thu, 13 Feb 2020 06:51:25 GMT # HTTP Header,一行一个
Server: nginx
...

Response Body

然而这些数据在网络上是加密传输的,我们如何在不知道 \(K\) 的情况下获得明文呢?

答案是:ss-server\(K\),让 ss-server 帮我们解密,把明文发给我们的服务器就可以了~

换言之,我们要想办法修改 Encrypted HTTP Response,使得修改后的密文解密所得的明文遵循如下格式:

为什么是修改 Encrypted Response 而不是修改 Encrypted Request 我会在后文的适当位置解释。

1
2
3
4
5
6
[type (1 byte)][fake address (4 bytes)][port (2 bytes)]1 404 Not Found
Date: Thu, 13 Feb 2020 06:51:25 GMT # 剩余的明文不做改动,一律视为 [payload]
Server: nginx
...

Response Body

为什么是替换而不是在最前面添加内容我会在后文的适当位置解释。

Ta-da!ss-server 收到这个数据包,解密之后发现前七个字节符合 [type][address][port] 规则,认为这个数据包是 ss-local 发送给它的请求数据包,于是把解密后的明文发送到了 fake_address:port。就这样,我们在 fake_address:port 监听的程序收到了如下解密后的明文:

1
2
3
4
5
6
1 404 Not⛬졷.ꖵ�                    # 版本(残缺) 状态码 状态文字(乱码)
İ차﯐Thu, 13 Feb 2020 06:51:25 GMT # HTTP Header(最开始的部分是乱码),一行一个
Server: nginx
...

Response Body

为什么解密修改后的密文得到的明文会有乱码我会在后文的适当位置解释。

顺便说一句,这三个“为什么”,原论文里一个字都没提……我佛了……鲨了我吧……

具体密文修改方法

不难发现的是,aes-*-cfb 的加密方法会导致密文与明文逐字节一一对应,修改密文的第 \(k\) 个字节就会导致明文的第 \(k\) 个字节相应地被修改。

由于我们想要修改的是解密出的明文的第一个 block,也就是 \(P_1\),因此我们只需要考虑 \(i=1\) 的情况:

\(P_1 = \mathrm{Encrypt}(C_0, K)\,\,\,\mathtt{xor}\,\,C_1\),等价于:

\(P_1 = \mathrm{Encrypt}(\mathrm{IV}, K)\,\,\,\mathtt{xor}\,\,C_1\)

其中,\(\mathrm{IV}\) 可以从数据包的最开头(\(C_1\) 之前的部分)直接得到,\(K\) 是未知的且无法得到,但是 \(K\) 是个固定的值,因此 \(\mathrm{Encrypt}(\mathrm{IV},K)\) 也是个固定的值,设这个值为 \(E_0\)。因此,我们可以将式子改写为:

\(P_1=E_0\,\,\mathtt{xor}\,\,C_1\)

接下来的操作就很有意思了。根据异或运算的运算规律,我们可以得知:如果 \(x\,\,\mathtt{xor}\,\,y=z\),那么一定有 \(x\,\,\mathtt{xor}\,\,(y\,\,\mathtt{xor}\,\,m)=z\,\,\mathtt{xor}\,\,m\),记为性质①。因此,有:

\(P_1\,\,\mathtt{xor}\,\,m = \mathrm{Encrypt}(\mathrm{IV}, K)\,\,\,\mathtt{xor}\,\,(C_1\,\,\mathtt{xor}\,\,m)\),其中的 \(m\) 就是我们接下来要想办法构造的东西。构造目标是让 \(P_1\,\,\mathtt{xor}\,\,m\) 的值等于我们控制的服务器的 IPv4 地址。同样是根据异或运算的性质,我们可以计算出 \(m\)

\(m = P_1\,\,\mathtt{xor}\,\,\mathtt{IPv4}\)

有鉴于 \(P_1\) 的格式如下所示:

1
[type][fake address][port][other data]

备注:前文已经计算过,[type][fake address][port] 总长度为 7 字节,一定会出现在第一个 block

因此,我们其实只需要关心 \(P_1\) 的前 7 个字节。初始的 \(P_1\) 的前七个字节——根据 HTTP 协议——一定是 HTTP 1. 这七个字符,所以:

1
2
3
m = "HTTP 1.\x00\x00\x00\x00\x00\x00\x00\x00\x00" ^ [0x01][addr][port][9 bytes data]
# 一个 block 长度为 16 bytes,每次必须根据 block 长度按位异或。
# 因此需要用 9 个 \x00 补齐。

HTTP Request 的前 7 个字符是不确定的,无法计算出 \(m\)。这就是为什么一定要操作 Encrypted HTTP Response

计算出 \(m\) 之后,就可以计算出修改后的密文 \(C_1'\)\(C_1'=C_1\,\,\mathtt{xor}\,\,m\)

没想明白?再讲一遍:

\(C_1\) 解密后为 \(P_1\),由于 aes-*-cfb 的性质,\(C_1\,\,\mathtt{xor}\,\,m\) 解密后也为 \(P_1\,\,\mathtt{xor}\,\,m\)。(上文提到的性质①)

经过精心构造,\(P_1\,\,\mathtt{xor}\,\,m\) 的值是 [0x01][fake addr][port][other data],正好是我们想要的 \(P_1'\)

因此 \(P_1'\) 对应的密文 \(C_1'\) 就是 \(P_1\,\,\mathtt{xor}\,\,m\) 对应的密文 \(C_1\,\,\mathtt{xor}\,\,m\)

最后,将 \(C_1'\) 拼接回密文中:

\(C=\mathrm{IV}+C_1'+C_2+C_3+\cdots\)

得到的最终的密文直接发送给 ss-serverss-server 就会解密出如下内容了:

1
2
3
4
5
6
[0x01][fake address (4 bytes)][port (2 bytes)]1 404 Not⛬졷.ꖵ�
İ차﯐Thu, 13 Feb 2020 06:51:25 GMT # [payload]
Server: nginx
...

Response Body

接下来,ss-server 会根据 SOCKS5 协议,把从第八个字节(1)开始的 [payload] 原封不动地发给 fake_address:port,无秘钥获取明文至此成功。

Q & A

Q:为什么是修改 Encrypted HTTP Response 而不是修改 Encrypted HTTP Request?

A:不是不想,是不能。Encrypted HTTP Response 的明文前七个字节固定且已知,可以依据此计算 \(m\);Encrypted HTTP Request 的明文前七个字节可能性太多,GET url...POST url... 等,穷举成本太大。

Q:在修改密文以修改对应的明文时,为什么是替换而不是在最前面添加内容?

A:不是不想,是不能。AES 加密以块为单位,在最前面添加字符会导致整体的错位,解密出来完全变成乱码。

Q:解密修改后的密文得到的明文为什么会有乱码?

A:其实也不一定,如果是用 aes-*-ofbaes-*-ctr 加密的,就不会有乱码。再看一遍 aes-*-cfb 解密的模式图,可以发现,\(C_{i-1}\) 参与了 \(C_i\) 的解密。我们修改了 \(C_1\),因此在解密 \(C_2\) 时,会解密出乱码;但是我们没有修改 \(C_2\) 以及更往后的其它 block,因此在解密 \(C_3\) 以及更往后的其它 block 时,不会出现乱码。(补充阅读:错误扩散)

aes-*-ofb 在解密 \(C_i\) 时,不需要 \(C_{i-1}\)\(P_{i-1}\) 参与(请看这个链接),因此就不会出现乱码。

Q:如果加密前本身就不是明文(比如HTTPS),会受影响吗?

A:攻击者仍能知道你在使用 SS,但是你的数据安全和隐私不会受到威胁。攻击者仅能实现最外层的解密。

0x02 结语

还是要感慨一句,SS、SSR 占领全世界的时代已经过去了。不论是 SS 的 aes-*-*fb 还是已经弃用的 OTA,抑或者是停止维护的 SSR,显然已经不适合作为安全的选项。即使是理论上安全的 aes-*-gcm 类的 AEAD 算法,仍旧躲不过非常时期的大范围阻断(除非配合 *-obfs)。以后的世界必将是伪装类软件的天下,无论是伪装成 HTTP/HTTPS(TLS),还是伪装成 WebSocket,都已经有了较为成熟的软件和较为活跃的社区。此次新提出的攻击方法,不过是旧时代的又一声残响,仅此而已。

来源:https://blog.jiejiss.com/