下一站 - Ihcblog!

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

0%

ShadowTLS——更好的 TLS 伪装代理

This article also has an English version.

本文主要分析当前流行的 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 规范的返回结果。

勘误:经网友 RPRX 指正,这里的说法确实有误。此处读写的不是裸 TCP 流而是 TLS 流量。TLS 流量本身分帧,理论上有保证读写一对一对应的能力。

Golang 官方 TLS 库对外暴露 io.Reader, io.Writer 接口,该接口是流式接口且官方实现的 TLS 库并不保证对应关系,所以此处说法应当纠正为对特定条件下的特定行为而非接口的依赖。

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 了)~

V3 Protocol

本节内容更新 2023-02,完整内容见链接

版本演进

在 2022 年 8 月的时候我实现了第一版 ShadowTLS 协议。当时 V1 协议的目标非常简单,仅仅通过代理 TLS 握手来逃避中间人对流量的判别。V1 协议假定中间人只会观察握手流量,不会观察后续流量、不会做主动探测,也不会做流量劫持。

但这个假设并不成立。为了防御主动探测,在 V2 版本的协议中添加了通过 challenge-response 方式来验证客户端身份的机制;并新增了 Application Data 封装来更好地伪装流量。

V2 版本目前工作良好,在日常使用中我没有遇到被封锁等问题。在实现了对多 SNI 的支持后,它甚至可以作为一个 SNI Proxy 工作,看起来完全不像是一个偷渡数据用的代理。

但是 V2 协议仍假设中间人不会对流量做劫持(参考 issue)。流量劫持成本比较高,目前没有被广泛应用,目前中间人的手段仍以旁路观测和注入以及主动探测为主。但这并不意味这未来流量劫持不会被大规模使用,协议设计上能够抵御流量劫持一定是更好的方案。面临的最大的一个问题是,服务端很难隐蔽地表明身份。

这个 issue 提出的 restls 提供了一个极具创新性的思路。借鉴这个思路我们可以解决服务端身份鉴定问题。

V3 协议目标

  1. 能够防御流量特征检测、主动探测和流量劫持。
  2. 更易于正确实现。
  3. 尽可能地弱感知 TLS 协议本身,实现者无需 Hack TLS 库,更不需要自行实现 TLS 协议。
  4. 保持简单:仅作为 TCP 流代理,不重复造轮子。

关于对 TLS 1.2 的支持

V3 协议在严格模式下仅支持使用 TLS1.3 的握手服务器。你可以使用 openssl s_client -tls1_3 -connect example.com:443 来探测一个服务器是否支持 TLS1.3。

如果要支持 TLS1.2,需要感知更多 TLS 协议细节,实现起来会更加复杂;鉴于 TLS1.3 已经有较多厂商使用,我们决定在严格模式下仅支持 TLS1.3。

考虑到兼容性和部分对防御连接劫持需求较低的场景(如使用特定 SNI 绕过计费系统),在非严格模式下允许使用 TLS1.2。

握手流程

这部分协议设计借鉴 restls 但存在一定差别:弱化了对 TLS 细节的感知,更易于实现。

  1. 客户端的 TLS Client 构造 ClientHello,ClientHello 需要生成自定义的 SessionID。SessionID 长度需为 32,前 28 位是随机值,后 4 位是 ClientHello 帧(不含 TLS 帧的 5 字节头,SessionID 后 4 byte 填充 0)的 HMAC 签名数据。HMAC 实例仅为一次性使用,直接使用密码创建实例。同时需要一个 Read Wrapper 负责提取 ServerHello 中的 ServerRandom 并转发后续流。
  2. 服务端收到包后,会对 ClientHello 做鉴定,如果鉴定失败则直接持续性与握手服务器进行 TCP 中继。如果鉴定成功,也会将其转发至握手服务器,并持续劫持握手服务器的返回数据流。服务端会:
    1. 记录转发的 ServerHello 中的 ServerRandom。
    2. 对所有 ApplicationData 帧的内容部分做处理:
      1. 对数据做变换,将其 XOR SHA256(PreSharedKey + ServerRandom)。
      2. 添加 4 byte 前缀 HMAC_ServerRandom(处理后的帧数据),HMAC 实例需事先灌入 ServerRandom 作为初始值,对此后从握手服务器转发的 ApplicationData 需要复用这个 HMAC 实例。注意帧长度需要同时 + 4。
  3. 客户端的 ReadWrapper 需要解析 ApplicationData 帧,判定前 4 byte HMAC:
    1. 符合 HMAC_ServerRandom(帧数据),则证明服务端是可靠的。在握手完成后这类帧需要过滤掉。
    2. 符合 HMAC_ServerRandomS(帧数据),则证明数据已经完成切换。需要将内容部分转发至用户侧。
    3. 都不符合,此时可能流量已被劫持,需要继续握手(握手失败则作罢),并在握手成功后发送一个长度随机的 HTTP 请求(糊弄性请求),在读取完响应后正确关闭连接。

安全性验证

  1. 流量劫持时,Server 会返回没有做 XOR 的数据,Client 会直接进入糊弄流程。
  2. ClientHello 可能会被重放,但无法使用其正确握手(restls 的讨论),所以无法鉴别我们返回的带前缀的 XOR 数据是否可解密。
  3. 若 Client 假装数据解密成功,直接发送数据,由于存在数据帧校验,其也无法通过。

数据封装

V2 版本的数据封装协议事实上也无法抵御流量劫持,如中间人可能会在握手完成后对这部分数据做篡改,我们需要能够响应 Alert;中间人也可能会按照 V2 协议的样子将一个 ApplicationData 封装拆成两个,如果连接正常,则也可以用于识别协议。

要应对流量劫持,除了要优化握手流程,数据封装部分也要重新设计。我们需要能够对数据流做验证,并且抵御重放、数据篡改、数据切分、数据乱序等攻击。

数据除了最外层继续使用 ApplicationData 封装外,内层添加了 4 byte 的 HMAC 计算值。我们在使用 preshared key 创建 HMAC 实例后,会灌入 ServerRandom+"C"ServerRandom+"S" 作为初始值,前者对应 Client 的发送数据流,后者对应 Server 的发送数据流(目的是防止中间人将我们发送的数据发回来,或者将不同连接的数据重放)。在转发过程中,首先将纯数据灌入 HMAC 实例,之后计算 4 byte 值后放于纯数据最前面。封装出的数据帧格式:(5B tls frame header)(4B HMAC)(data)。封装结束后将 4 byte 数据输入 HMAC 实例(避免中间人剪切拼接请求)。

当数据校验失败时,我们需要立刻发送 TLS Alert 正确关闭连接。在连接断开时也需要能够正确关闭。

安全性验证

  1. 对于中间人的数据篡改,HMAC 会直接验证出来,会响应 Alert。
  2. 对于中间人乱序攻击,HMAC 会直接验证出来,会响应 Alert。
  3. 对于剪切拼接攻击(合并两个 AppData),虽然 HMAC 处理的是数据流,但是由于我们在处理完成后又额外 update 进去一个 4 byte 的值,所以可以打断两个连续的流,防御这种攻击。

总结

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

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

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