下一站 - Ihcblog!

远方的风景与脚下的路 | 子站点:ihc.im

0%

ShadowTLS——更好的 TLS 伪装代理

本文主要分析当前流行的 Trojan 协议,并针对当前中间人的特点,尝试提出一个更好的解决方案。

该方案的实现是 ShadowTLS,你可以在 Github 上找到完整代码和预编译二进制。

要隐藏流量特征,一个方式是不暴露任何特征,即 shadowsocks 这类:这类协议将协议头也加密传输,所以观测不到任何明显的特征。第二个方式是将自己隐藏在众人之中,最简单的是伪装为 HTTP 或 TLS 流量,分别对应 simple-obfs 和 Trojan 的做法。

方式一现在已经比较容易识别了,未命中任何协议且时序特征符合 web 流量,无脑认为是该类型流量即可。方式二近年来越来越成为主流方式,其中使用最广的就是 Trojan 协议(simple-obfs 只是在最开始加一个 http 协议头,过于容易识别,在此不做分析)。

Trojan 是怎么工作的

Trojan 想做到的事情是将流量封装为一个正常的 TLS 流量。由于 TLS 流量是加密的,所以中间人不易识别出这到底是普通 web 流量还是封装过一层的代理流量。为了更像一点,Trojan 还对主动探测做了防御,浏览器直接打开对应网页可以正常响应。

那么这里它要解决的问题主要是这几个:

  1. 代理请求承载:要能够将代理请求编码为二进制,server 侧要能解码这个请求,并根据请求来建立远程连接并中继流量。
  2. 区分客户端和主动探测者的流量:需要某种手段来区分客户端的请求和主动探测者的请求,并做不同的处理。
  3. 对客户端和主动探测者流量的后续处理:对区分后的流量做分别处理。客户端流量需要用 TLS 协议承载,主动探测者的流量也需要能够 act like http。

官方的协议规范在这里有写:The Trojan Protocol。解决问题 1 很简单,因为上层暴露是 socks5 代理,所以直接把 socks5 代理请求头打包进去就可以了(和 shadowsocks 类似)。

重点在于问题 2 和 3。这里的方式是先建立 TLS session,之后在 TLS 连接内发通过前 56 byte 做鉴权,如果这 56 byte 符合我们 preshared key 的某种 hash 结果,那么我们就认为这个流量是我们 client 发出的。

这里很明显会让人注意到一个问题:我作为一个攻击者,在建立 TLS session 后发送一个小于 56 byte 的 HTTP 请求,就可以通过判定是否卡住来判别是否是 Trojan server 了呀?因为需要 56 byte 才能区分我是谁,那在数据到达 56 byte 之前是不能做路由的。

事实上这个问题并不存在。我们来看一下协议设计的细节:这个 56 byte 是 hex(SHA224(password)),后面会发送 CRLF。是不是很奇怪?一个二进制协议为什么要 CRLF 这种文本协议才会使用的东西?并且直接发 SHA224 二进制结果不是要比发 hex 效率更高?其实这就是协议设计的精妙所在。

这个 CRLF 其实是为了对应 HTTP 流量的。在 server 侧处理时,直接 read_until CRLF,之后就可以做路由。因为 HTTP 流量要得到处理,一定是在其发送了 CRLF 之后。

所以读到第一个 CRLF 后,要么 hex(SHA224(password)) 发完了,要么 HTTP 请求的第一行发完了。无论哪一种情况,我们都已经可以做路由区分了。比如如果我们发现数据不够 56 byte,那么可以直接判定为主动探测流量,而不用非要等待接收完 56 byte。而为什么要 hex,就是为了避免 hash 结果中意外包含 CRLF 影响我们的判定。

顺便说一个题外话:在调研这个协议时,我读了 trojan c 和 go 版本的实现。事实上 golang 版本的实现是有问题的,可能作者没有 get 到协议设计里的这些 trick,它直接做了一次 read,如果数据不够或 hash 不符就判定为主动探测流量。但我们并不能将 read 一次读够 56 byte 视为理所当然,tcp 是流协议,一次读 1 byte 就是符合 posix 规范的返回结果。

Trojan 有什么问题

看起来一切正常?我们将所有的数据全部包装进 TLS,外界区分不出加密的数据到底是什么,仿佛我们一直在请求某个 web 站,并且如果我们浏览网页的话,代理流量的时序特征也是 web 流量。

如果不考虑实现上的一些特征,这里唯一暴露的东西是 SNI 和对应证书。在 TLS Client Hello 中会暴露我们请求的目标域名,而长时间大流量地请求一个小众域名,这可能并不正常。

更好地伪装方式

还有更好的伪装方式吗?我们要用 TLS,就得自己处理握手;要握手就得用自己的域名签发证书。似乎是个无解的问题。。

诶等等!我们只是伪装为 TLS 流量,谁说真的要用 TLS 了?

那么我们能不能做一次 “TLS 表演” 给中间人看呢?server 可以直接将这个表演数据代理到某些大公司或机构的白名单的服务器上,这样中间人看到的握手就是证书合法的、和白名单域名的握手。在握手结束后,client 和 server 切换模式,利用已建立的连接传输自定义数据即可。

切换模式需要双方都能感知到握手结束,这里我们强制使用 TLS1.2,在观测到一次 Change Cipher Spec 包后,再读一个 Handshake 包即标记握手完成。

我们不想自己实现数据加密和代理协议封装,所以这里的自定义数据就直接采用了 shadowsocks 来处理。我们的 ShadowTLS 作为 shadowsocks 流量的一层 wrapper 工作,对于 client 就是在流量上加一层握手数据,对于 server 就是把这层握手数据剥除掉。

到此为止,如果我们假定中间人:

  1. 不对握手后的流量做分析
  2. 不进行主动探测

那么我们的协议可以很有效地工作。抓包可以看出,中间人视角下我们真的在和一个受信任的域名进行 TLS 通信。根据反馈,这个版本从 2022 年 8 月末到 10 月初已经帮助了一些人摆脱了针对域名的 QoS 的问题。

ShadowTLS 协议(v1)的问题

前面我们只做了一层很简单的“表演”,并有两个假定,但事实上这两个假定并不成立。我们需要能够应对这两个问题。

应对流量分析

正常的 TLS 数据,在握手结束后会使用 Application Data 封装包来进行通信。而直接转发 shadowsocks 数据流完全不符合 TLS 协议,甚至 wireshark 会将后续的数据包高亮出来以表示有问题。解决这个问题并不难,我们只需要在双边分别做封装和解封装即可。

应对主动探测

如果要能应对主动探测,我们就要能够做两件事(和 Trojan 需要做的一样):

  1. 区分客户端流量和主动探测流量
  2. 正确响应主动探测流量

我们需要客户端给出一个特殊的东西,以此来判断这个是我们的客户端流量。为了避免主动探测,我们必须引入一个预共享 key。但是怎么做呢?

Trojan 协议中,直接发送密码的 hash 即可。但是我们这里只有明文信道可以用,所以直接发送密码的 hash 显然暴露了密码,等于说密码不再有意义;并且完全无法防御数据重放。

ShadowTLS v2 协议设计

Server Challenge

基于明文信道我们只能通过 challenge-response 的形式做鉴定。正常来讲,我们要鉴定 client,就需要 server 侧发送一个 challenge。但事实上我们并不能这么做,因为正常的 https server 不可能在 TLS 握手后就发回一个 challenge。

那么能不能将 challenge 藏在正常的握手中呢?对 challenge 的要求很简单,随机且 client 不可控就行。我的思路是,其实握手过程本身中 server 发送的数据就可以作为 challenge:它有随机数据,如 server random,它也不是 client 可控的。

这里我将握手过程中 server 发送的所有数据作为 challenge(当然也可以使用 server random,但是这样需要 parse TLS 包,需要感知 TLS 协议细节,实现上有点麻烦并且可能引入细节上的特征区分性),这样可以尽可能地弱化对 TLS 协议细节的依赖,所以不再需要依赖 TLS1.2 的握手行为细节。

Client Response

我们有个 challenge,那么如何 response 呢?显然我们需要鉴定预共享 key,那么我们直接使用 hmac(data, key) 作为 response 即可(可以简单理解为 hash(data+key),但安全性上更好,都可以流式计算得到,不需要缓存数据)。

这个 Response 数据怎么发回呢?如果作为单独的数据包,则会引入新的区分性特征。所以我们这里将这个 Response 放在第一个 Application Data 包的头部发送至 Server 侧。

这个 hmac 我这里使用 hmac-sha1 的前 8 byte,安全性已经足够良好。

Application Data

在数据转发的过程中,会做 Application Data 封装和解封装。这里需要考虑的问题是,正常情况下单个 Application Data 数据包是多大?当前实现中直接拍脑袋定了一个 buffer size,但是为了避免这个包大小成为特征,后续需要调研一下 TLS 库的实现并抓包观测一下,定一个合理的最大值。

处理主动探测流量

我们可以将服务端模型简化为:默认连接至 handshake server;如果 hmac 鉴定通过则切换至 data server。

对于主动探测流量,它是不可能刚好猜对 8 byte 的 hmac 的,所以它永远不会切换至 data server。为了避免不必要的 hash 计算,在前 N 个 Application Data 包验证 hmac 不通过的时候(这里取 N 而不是取 1 是因为不确定是不是发送了 Application Data 就一定标记握手结束),会直接切换至直接代理,后续不再尝试 hmac 计算和验证。

详细的协议设计写在 这里 了,感兴趣可以参考。

ShadowTLS 与 Trojan 的对比

对比 Trojan,ShadowTLS 不需要自行签发证书(可以直接使用大公司或机构的可信域名),也不需要自行启动伪装的 HTTP 服务(因为数据直接转发至可信域名对应网站),使用可信域名可以进一步弱化特征,藏木于林。

ShadowTLS 和 Trojan 都可以应对主动探测,当使用浏览器直接打开时,都可以正常访问到 HTTP 页面。

更进一步

UPDATED AT 2022-11-13

距离 v2 实现发布一个多月过去了,ShadowTLS 取得了不错的结果:在过去一段时间 Trojan 被大规模封禁时,ShadowTLS 依旧可用。当前 ShadowRocket 和 Surge 都支持了这个协议(虽然我还是没钱买 Surge)。

但其实也有很多可以完善的地方:

TLS 指纹问题

对于 Server,我们直接转发流量,不存在指纹问题;但 Client 是我们自己实现的,我们预期它看起来和浏览器或其他正常客户端一样,但事实上可能并没有足够地像。如果抓包查看 Chrome 发出的 Client Hello 包,可以明显看出里面包含了非常多的 Extension 字段,而这些字段在我们使用 rustls 时是不会自动附加的;并且,不同客户端的默认选择的 Cipher、Hash 列表等是有差别的。

所以一个可以改进的地方是,提供多份 Client TLS Profile 供用户选择使用。

流量劫持问题

这个 issue 里提到了一个确实存在的问题:如果有人将 Client 侧的流量劫持到握手服务器上怎么办?

首先是 Client 信任谁?在它完成 TLS 握手前,它的表现其实和普通的 TLS Client 一样。要得到 Client 的信任,首先需要能过证书验证,能完成 TLS 握手。能做这件事的人除了我们的 Server,还有握手服务器本身,以及其他代理握手的中间人。

我认为我们这里可以假定握手服务器是中间人不可控的,其证书也不可能被中间人持有。所以现在重点就在于和我们一样代理握手的中间人了。中间人不需要解密流量,它的目标是鉴别我们是不是正常的连接。所以虽然它没有拿到解密 key,它依旧可以对流量做劫持和重放来达到目的:

  1. 直接劫持整个连接到握手服务器(这个是 issue 里提到的攻击方式):在 Client 完成协议切换后就会露馅,握手服务器会返回 Encrypted Alert。
  2. 正常代理流量,但偷偷丢掉或者乱序一个 Application Data:正常应当返回 Encrypted Alert,但因为我们并不做消息 Authentication,也不做 Encryption,所以我们其实是感知不到这件事的,这件事会被丢给下层服务。我们依赖下层服务断开连接来返回 Encrypted Alert。
  3. 观测连接断开:2 中也提到,我们需要妥善处理连接断开的问题,无论是正常关闭还是异常关闭。但当前实现上并没有发送 Encrypted Alert。
  4. 合并相邻 Application Data:正常情况下 TLS 协议内部会有序列号和 MAC,但我们的封装当前是没有的,所以如果劫持者合并了相邻的 Application Data 后连接仍旧正常,那么也可以发现是伪装的 TLS。

但是这些问题(除了问题 3)需要能够在主链路上劫持流量才能生效,如果没有其他 hint 的话,对所有出国 tls 流量做劫持,还是有很高风险的。所以该问题我认为其实问题并不大。

What’s Better Protocol?

我们可以简单 fix 前面提到的一部分问题(这些是一些实现问题,而非协议问题):提供 Client TLS Profile、连接关闭时发送 Encrypted Alert。但如何应对剩下的流量劫持问题呢?

针对直接劫持至握手服务器的攻击

我们可以看出,问题的关键在于 Client 没有对 Server 做鉴权(只鉴定了证书)。Server 需要表明身份,如果放在 Server Hello 中的某个 Extension 中则可能会成为明显特征;如果直接夹带在后续流量中,则同样会使探测者困惑,无法正常解密。

我们需要这么一个地方:它是 Server 发送的,本身就是随机数,并且我们修改它没什么影响,最好是在发送完 Server Random 后发送的(这样可以利用 Server Random 防御重放攻击)。在 IP 包上其实我们可以藏一些东西,但这样就要求我们有系统管理员权限,会引入更强的环境限制,所以我们尽可能地在 TCP 之上寻找这样的地方。

于是我们可以找到一个符合条件的隐藏点:Session ID(仅限 TLS 1.3 的 Session ID)。由于我们完全信任 Server Random 一定是 Random 的,所以这里可以做的更简单:如果中继的 Server Hello 包中包含一个 32 位的 Session ID,则替换该 ID 为 Server Random 的 HMAC。由于 TLS 1.2 默认这个字段是空的,所以我们不能贸然地为 TLS 1.2 插入这个值,以避免成为特征。

针对流量的 Reshape

我们的 Application Data 封装可以被 reshape,但真实的 TLS 流量不行,所以我们需要在这层封装内也参考 TLS 的做法,携带一些数据用于校验。这种校验有助于发现前面提到的问题 2 和 4,之后只需要响应 Alert 并断开连接即可。

当然,这些都还没实现(until 2022-11-13),如果你感兴趣,欢迎提 issue 认领贡献!

实现 fix 和对直接劫持至握手服务器的防御可以对旧版本 Client 保持兼容,但要向增加 Application Data 增加 MAC 就不得不改协议啦(可能是 v3 了)~

总结

ShadowTLS 基于 Rust + Monoio 实现,基于 io_uring 和 thread-per-core 模型可以带来更好的 IO 性能(但是由于目前 Monoio 尚未支持 Windows,所以 Windows 用户暂时无法使用,建议使用 wsl)。

综上,本文尝试分析了主流的基于 TLS 的代理协议,并针对它的可能缺陷提出了更好的协议设计,并提供了对应的实现,你可以在 这里 找到对应代码。

欢迎关注我的其它发布渠道