前言
最近看到v2ex上有人分享了一个有意思的技术
https://v2ex.com/t/875975
简单来说,这个技术可以在tls握手阶段实现完全合法有效的与指定域名网站的握手,而后续的传输数据阶段则是传输自身的恶意payload。
这样我可以让tls握手阶段,SNI以及证书同步伪装,使得流量更加可信。
对应的demo项目 https://github.com/ihciah/shadow-tls
tls协议
分析之前,先搞清楚tls的协议结构。
- tls分为两层,记录层和握手层,记录层只有一种记录协议;握手层有4种协议,Handshake、Alert、ChangeCipherSpec、ApplicationData。
- 协议流程,握手阶段和数据传输阶段;握手阶段,常用到的握手层协议有Handshake、Alert、ChangeCipherSpec,而数据传输阶段就是ApplicationData。
先说下分层,如下图所示。
记录层
记录层的协议只有记录协议,长度5字节。
记录协议负责在传输连接上交换的所有底层消息,并且可以配置加密。每一条 TLS 记录以一个短标头开始。标头包含记录内容的类型 (或子协议)、协议版本和长度。原始消息经过分段 (或者合并)、压缩、添加认证码、加密转为 TLS 记录的数据部分。
- Content Type(1 bytes):用于标识握手层协议类型
- Version(2 bytes):tls版本信息
- Length(2 bytes):握手层数据包长度
PS: 简单来说,记录协议主要功能是对握手层进行数据压缩、加密、完整性保护等等。
Content Type有4个值,参考go官方库说明如下,可以看到这些类型在上面数据包中也有记录
1 2 3 4 5 6 7 8 | type recordType uint8 const ( recordTypeChangeCipherSpec recordType = 20 recordTypeAlert recordType = 21 recordTypeHandshake recordType = 22 recordTypeApplicationData recordType = 23 ) |
Version
1 2 3 4 5 6 7 8 9 10 | const ( VersionTLS10 = 0x0301 VersionTLS11 = 0x0302 VersionTLS12 = 0x0303 VersionTLS13 = 0x0304 // Deprecated: SSLv3 is cryptographically broken, and is no longer // supported by this package. See golang.org/issue/32716. VersionSSL30 = 0x0300 ) |
握手层
Handshake
Alert
ApplicationData
ChangeCipherSpec
数据包交互
如果了解过tls协议,会知道tls分为握手阶段以及数据传输阶段。
交互流程如下,握手阶段主要进行共享密钥生成以及身份认证,数据传输阶段就使用生成的共享密钥进行加密传输。
数据包
代码实现层面
在通过tls封装后,write实际操作如下,会进行Handshake
判断握手是否完成
未完成握手会调用握手函数,但这里可以看到只是一个函数签名,因为对于server和client的握手处理是不一样的,需要传入不同的函数实现。
比如,clientHandshake
,会生成clientHello发送,并读取serverHello等一系列操作。
分析
根据上面的简单分析,握手阶段,服务端会返回一个Certificate包,包含了该服务端的tls证书,其中还包含了证书链,这也是我们浏览器上能查看服务端证书的原因,并且可以根据证书链来校验证书合法性。
而数据传输阶段,数据包格式较为固定,均为Application Data,并且握手层一般是通过握手阶段协商好的密钥进行加密传输的。
所以shadow tls的实现原理也就出来了。
- 握手阶段,服务端将客户端的请求转发到一个可信域名上,这样保证流量侧看到的服务端证书是一个可信域名的证书
- 等握手完成后,数据传输阶段,停止转发,客户端和服务端之间加密传输恶意payload即可。那么这里就有一个疑问了,由于tls的防中间人攻击,使用的是非对称算法进行握手协商出共享密钥,我的服务端是拿不到的,其实这个无所谓,我看不到,中间设备也同样看不到,那么我的客户端和服务端用一个假的密钥加密数据伪造一个Application Data进行传输,在中间设备看起来也是完全正常的。
实现
原理就这么简单,实现的话,只需要注意一下握手结束的标识,将转发模式切换成恶意payload通信模式即可,我这里选择的是判断接收到第一个application data协议的包,则切换模式。
client
编写前,review了下官方tls库,写的针不戳,这里参考他的写法,也是将普通conn封装一层。
同样,握手也是在write和read时,先判断是否完成握手,未完成则先进行握手。
完成的话,write就构造application data,格式如下;read就读取后,解密数据,key目前暂时写死,后续考虑一些其他协商方式。
1 | type(1) + version(2) + len(2) + encryptData |
因为主要功能是在于服务端转发和切换模式,而客户端握手就相对简单了,将conn封装到tls.Client
中,然后调用Handshake()
,即可发送握手,而这里有个小trick,封装后的tlsConn
,只进行握手,而数据通信还是使用原来的conn,这样就不会受tlsConn自身协商的算法以及key限制了。
server
server端代码如下,先和可信域名建立一个tcp连接,然后起一个goroutine,等待可信域名响应数据,写入客户端连接。
再一个循环等待读取conn连接,将他写入到可信域名连接里,一旦判断到ContentType是ApplicationData则退出循环,表明握手结束。
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 | // 服务端初始化 func (c *ShadowTLSConn) serverHandshake() error { defer c.conn.SetReadDeadline(time.Time{}) shadowDomainConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%s", c.domain, c.domainPort), 10*time.Second) if err != nil { return err } defer shadowDomainConn.Close() handshakeOver := make(chan bool) // 接收服务端数据,转发到客户端 shadowInput := new(bytes.Buffer) go func() { for { select { case <-handshakeOver: return default: } shadowDomainConn.SetReadDeadline(time.Now().Add(5 * time.Second)) data, err1 := c.read(shadowInput, shadowDomainConn) if err1 != nil { return } //time.Sleep(10 * time.Millisecond) _, err1 = c.conn.Write(data) if err1 != nil { return } } }() // 接收客户端的数据,转发到真实服务端 for { c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) data, err1 := c.read(&c.rawInput, c.conn) if err1 != nil { c.conn.Close() return err1 } if recordType(data[0]) == recordTypeApplicationData { c.version = data[1:3] close(handshakeOver) break } _, err1 = shadowDomainConn.Write(data) if err1 != nil { c.conn.Close() return err1 } } return nil } |
过程看起来很简单,但测试发现读取tls数据时老是出问题,暂时不得而知原因,正常来说根据tls数据包格式,先读取5字节,然后根据长度字段再继续读取剩余部分,应该正常。
最后还是参考了官方tls库的方法,通过一个bytes.Buffer
从conn
中读取数据,应该是atLeastReader
的实现比较巧妙吧。
测试
封装好库,测试代码也就很简单了
数据包,握手阶段转发可信域名,后续application data用固定密钥加密伪造
在9995端口监听,可以看到客户端通过该端口可以正常上线,而通过浏览器访问,会返回可信域名的页面(这里是做了一个伪装,区分了下上线流量和浏览器访问流量,增加迷惑性),并且证书还是有效的。
PS: 这里绑定host,是为了更直观证书的有效性,不绑定也不会有区别,只是IP访问无法直观看到证书有效性。
这样在原来的SNI欺骗之上,增加了可信域名证书,让通信流量更加趋于正常。