WebSocket的出现使得浏览器具备了实时双向通信的能力。
本文由浅入深地详细介绍了WebSocket如何建立连接、交换数据以及数据帧的格式。
此外,还简要介绍了针对WebSocket的安全攻击,以及如何保护该协议免受类似攻击。
HTML5开始提供的一种浏览器与服务器全双工通信的网络技术,属于应用层协议。它基于TCP传输协议并重用HTTP握手通道。
对于大多数Web开发者来说,上面的描述有点无聊,其实只要记住几点即可:
【1】WebSocket可以在浏览器中使用
【2】支持双向通讯
【3】使用方便
Websocket是应用层第七层的应用层协议。它必须依赖HTTP 协议进行握手。握手成功后,数据直接从TCP通道传输,与HTTP无关。即:websocket分为握手和数据传输阶段,即HTTP握手+双工TCP连接。
有什么优点?
说到优点,这里对比的是HTTP协议。简而言之,它支持双向通信,更灵活、更高效、具有更好的扩展性。
【1】支持双向通讯,实时性更强。
[2] 更好的二进制支持。
[3] 更少的控制开销。连接建立后,ws客户端与服务器交换数据时,协议控制的数据包头很小。在不包含包头的情况下,从服务器到客户端的数据包头只有2~10个字节(取决于数据包的长度),而从客户端到服务器则额外增加4个字节的掩码必需的。 HTTP协议每次通信时都需要携带完整的头部。
【4】支持扩展。 ws协议定义了扩展,用户可以扩展协议或实现自定义子协议。 (比如支持自定义压缩算法等)
对于后两点,没有研究过WebSocket协议规范的同学可能不太直观地理解,但是并不影响WebSocket的学习和使用。
对于网络应用层协议的学习,最重要的往往是连接建立过程和数据交换教程。
当然,数据的格式是逃不掉的,因为它直接决定了协议本身的能力。良好的数据格式可以使协议更加高效和可扩展。
下面主要围绕以下几点展开:
【1】如何建立连接
【2】如何交换数据
【3】数据帧格式
【4】如何保持连接
在正式介绍协议细节之前,我们先看一个简单的例子来直观感受一下。示例包括WebSocket服务器、WebSocket客户端(网页)
这里服务器使用了ws库。与大家熟悉的socket.io相比,ws更加轻便,更适合学习用途。
服务端
代码如下,监听8080端口,当有新的连接请求到来时,打印日志,同时向客户端发送消息。当收到客户端的消息时,也会打印日志。
客户端
代码如下,发起8080端口的WebSocket连接。
连接建立后,打印日志,同时向服务器发送消息。收到服务器发来的消息后,同样打印日志。
运行结果
可以分别查看服务端和客户端的日志,这里不再展开。
服务端输出
server: 接收连接。
server: 收到问候客户端输出
client: ws 连接已打开
client: 收到world
如前所述,WebSocket 重用了HTTP 握手通道。具体地,客户端通过HTTP请求与WebSocket服务器协商升级协议。
协议升级完成后,后续数据交互遵循WebSocket协议。
1、客户端:申请协议升级
首先,客户端发起协议升级请求。
可以看到,采用标准的HTTP报文格式,仅支持GET方法。
获取/HTTP/1.1
主机: 本地主机:8080
产地: http://127.0.0.1:3000
连接:升级
升级: websocket
Sec-WebSocket-版本: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==重点请求首部意义如下:
【1】Connection: Upgrade:表示升级协议
【2】Upgrade: websocket:表示升级到websocket协议。
【3】Sec-WebSocket-Version: 13:websocket版本。如果服务器不支持该版本,则需要返回一个Sec-WebSocket-Version头,其中包含服务器支持的版本号。
[4] Sec-WebSocket-Key:与后面服务器响应头中的Sec-WebSocket-Accept相匹配,提供基本的防护,例如恶意连接或无意连接。
请注意,上述请求省略了一些非必要的请求标头。由于是标准的HTTP请求,所以Host、Origin、Cookie等请求头会照常发送。在握手阶段,可以通过相关请求头进行安全限制、权限验证等。2、服务端:相应协议升级
服务器返回的内容如下,状态码101表示协议切换。
至此,协议升级完成,后续数据交互将遵循新协议。
HTTP/1.1 101 切换协议
连接:升级
升级: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=
注意:每个标头都以\r\n 结尾,最后一行会添加一个额外的空行\r\n。另外,服务器响应的HTTP状态码只能在握手阶段使用。握手阶段之后,只能使用特定的错误代码。3、Sec-WebSocket-Accept的计算
Sec-WebSocket-Accept是根据客户端请求头的Sec-WebSocket-Key计算出来的。
计算公式为:
[1] 将Sec-WebSocket-Key 与258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接。
[2]通过SHA1计算摘要并转换为base64字符串。
伪代码如下:
toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) ) 验证之前的返回结果:
客户端和服务器之间的数据交换离不开数据帧格式的定义。因此,在真正解释数据交换之前,我们先来了解一下WebSocket的数据帧格式。
WebSocket客户端和服务器之间通信的最小单位是帧,帧由一个或多个帧组成,形成一条完整的消息。
[1]发送端:将报文切割成多帧发送给服务器;
【2】接收端:接收报文帧,并将关联帧重新组装成完整的报文;
本节重点讲解数据帧的格式。
1、数据帧格式概览
下面给出WebSocket数据帧的统一格式。熟悉TCP/IP协议的同学应该对这样一张图很熟悉。
【1】从左到右,单位为位。例如FIN和RSV1各占1位,操作码占4位。
[2]内容包括标识、操作码、掩码、数据、数据长度等。
2、数据帧格式详解
对于前面的格式概览图,这里逐个字段进行解释。如果还有不清楚的地方,可以参考协议规范或者留言交流。
FIN:1 位。
如果为1,则表示这是消息的最后一个分片,如果为0,则表示这不是消息的最后一个分片。
RSV1, RSV2, RSV3:各1 位。
通常都是0。当客户端和服务器协商采用WebSocket扩展时,这三个标志可以非零,值的含义由扩展定义。如果存在非零值,并且未使用WebSocket 扩展,则发生连接错误。
Opcode: 4 位数字。
操作码,Opcode的值决定了如何解析后续的数据负载(data payload)。如果操作码未知,接收方应导致连接失败。可选的操作代码如下:
%x0:表示连续帧。当Opcode为0时,表示本次数据传输采用数据分片,当前接收到的数据帧就是其中的数据分片之一。
%x1:表示这是一个文本框(frame)
%x2:表示这是一个二进制帧(frame)
%x3-7:为后续定义的非控制帧保留的操作码。
%x8:表示连接已断开。
%x9:表示这是一个ping 操作。
%xA:表明这是一个pong操作。
%xB-F:为后续定义的控制帧保留的操作码。Mask: 1 位。
指示是否屏蔽数据负载。客户端向服务器发送数据时,需要对数据进行屏蔽;服务器向客户端发送数据时,不需要对数据进行屏蔽。
如果服务器接收到的数据没有被屏蔽,则服务器需要断开连接。
如果Mask为1,则将在Masking-key中定义一个掩码密钥,并且该掩码密钥将用于对数据负载进行解密。对于从客户端发送到服务器的所有数据帧,Mask 均为1。
掩码的算法和使用将在下一节中解释。
Payload length:数据负载的长度,以字节为单位。是7位,或者7+16位,或者1+64位。
假设数字Payload length===x,如果
x为0~126:数据长度为x字节。
x 为126:接下来的2 个字节表示一个16 位无符号整数,其值为数据的长度。
x为127:接下来的8个字节表示一个64位无符号整数(最高位为0),无符号整数的值就是数据的长度。另外,如果净荷长度占用一个以上字节,则净荷长度的二进制表达采用网络顺序(大端,重要位在前)。
Masking-key:0或4字节(32位)
所有从客户端发送到服务器的数据帧,数据负载都被屏蔽,Mask为1,并携带4字节的Masking-key。如果Mask 为0,则没有Masking-key。
注意:负载数据的长度不包括掩码密钥的长度。
Payload data:(x+y)字节
加载数据:包括扩展数据和应用数据。其中,扩展数据为x字节,应用数据为y字节。
扩展数据:如果没有协商扩展,则扩展数据为0字节。所有扩展都必须声明扩展数据的长度,或者如何计算扩展数据的长度。此外,如何使用扩展必须在握手阶段进行协商。如果存在扩展数据,则有效负载数据的长度必须包括扩展数据的长度。
应用数据:任意应用数据,在扩展数据(如果有)之后,占据数据帧的剩余部分。通过从有效负载数据长度中减去扩展数据的长度来获得应用数据的长度。
3、掩码算法
屏蔽密钥(Masking-key)是客户端选择的一个32位随机数。屏蔽操作不影响数据有效负载的长度。屏蔽和反屏蔽操作使用以下算法:
首先,假设:
[1]original-octet-i:原始数据的第i个字节。
[2]transformed-octet-i:变换后数据的第i个字节。
[3] j:i mod 4的结果。
[4] masking-key-octet-j:掩码密钥的第j 个字节。
算法描述为:original-octet-i和masking-key-octet-j进行异或后,得到transformed-octet-i。
j=i MOD 4
Transformed-octet-i=Original-octet-i XOR masking-key-octet-j
WebSocket 客户端和服务器连接后,后续操作均基于数据帧的传输。
WebSocket根据操作码来区分操作类型。例如0x8表示断开连接,0x0-0x2表示数据交互。
1、数据分片
WebSocket的每条消息可能被分割成多个数据帧。 WebSocket的接收方收到一个数据帧时,会根据FIN的值判断是否收到了报文的最后一个数据帧。
FIN=1表示当前数据帧是报文的最后一个数据帧,此时接收方已收到完整的报文,可以处理该报文。 FIN=0,接收方需要继续监听并接收剩余的数据帧。
另外,opcode代表数据交换场景中数据的类型。0x01 表示文本,0x02 表示二进制。但0x00比较特殊,表示连续帧。顾名思义,还没有收到完整消息对应的数据帧。
2、数据分片例子
直接看例子更形象。下面的例子来自MDN,很好的演示了数据分片。客户端向服务器发送两次消息,服务器收到消息后响应客户端。这里我们主要看客户端发送给服务器的消息。
第一条消息
FIN=1,表示这是当前报文的最后一个数据帧。服务器收到当前数据帧后,即可处理该消息。 opcode=0x1,表示客户端发送的是文本类型。
第二条消息
【1】FIN=0,opcode=0x1,表示发送是文本类型,报文还没有发送完,还有后续的数据帧。
【2】FIN=0,opcode=0x0,表示报文还没有发送出去,还有后续的数据帧。当前数据帧需要连接在前一个数据帧之后。
【3】FIN=1,操作码=0x0,表示报文已经发送完毕,没有后续的数据帧。当前数据帧需要连接在前一个数据帧之后。服务器可以将相关的数据帧组装成完整的消息。
为了保持客户端和服务器之间的实时双向通信,WebSocket 需要保证客户端和服务器之间的TCP 通道保持连接。然而,对于一个长时间没有数据交换的连接,如果仍然长期维持,那么所包含的连接资源可能会被浪费。
不过,也不排除某些情况。尽管客户端和服务器长时间没有数据交换,但仍然需要保持连接。这时候就可以使用心跳来实现。
发送方-接收方:ping
接收方-发送方:pongping和pong操作对应WebSocket的两个控制帧,操作码分别为0x9和0xA。
例如,要从WebSocket服务器向客户端发送ping,只需要以下代码(使用ws模块)
ws.ping('', false, true);
前面提到,Sec-WebSocket-Key/Sec-WebSocket-Accept的主要作用是提供基础防护,减少恶意连接和意外连接。
功能大致总结如下:
[1]防止服务器接收非法的websocket连接(例如http客户端不小心请求连接websocket服务,此时服务器可以直接拒绝连接)
[2] 确保服务器理解websocket 连接。由于ws握手阶段使用http协议,因此ws连接可能由http服务器处理并返回。这时客户端就可以使用Sec-WebSocket-Key来保证服务器端能够识别ws协议。 (并不是100%安全,比如总有一些无聊的http服务器,只处理Sec-WebSocket-Key,却没有实现ws协议……)
【3】使用浏览器发起ajax请求,设置header时,禁止使用Sec-WebSocket-Key等相关header。这样可以防止客户端发送ajax请求时意外请求协议升级(websocket升级)
[4] 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理将第一个请求返回到缓存,然后在第二个请求到达时直接返回缓存的请求(无意义返回)。
[5] Sec-WebSocket-Key的主要目的不是保证数据安全,因为Sec-WebSocket-Key和Sec-WebSocket-Accept的转换计算公式是公开的,非常简单。主要功能是防止一些常见的意外(无意)。
强调:Sec-WebSocket-Key/Sec-WebSocket-Accept的转换只能带来基本的保护,但是连接是否安全,数据是否安全,客户端/服务器是否合法ws客户端,ws服务器,其实没有实际的保证。
在WebSocket协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,操作并不复杂。除了对通道本身进行加密之外,似乎没有太多有效的方法来保护通信安全。
那么为什么我们需要引入mask计算呢?看起来除了增加计算机的计算量之外并没有太大的好处(这也是很多同学的疑惑点)。
答案仍然是两个字:安全。但它并不是为了防止数据泄露,而是为了防止协议早期版本中存在的代理缓存中毒攻击等问题。
1、代理缓存污染攻击
在正式描述攻击步骤之前,我们假设以下参与者:
[1] 攻击者、攻击者控制的服务器(简称“邪恶服务器”)、攻击者伪造的资源(简称“邪恶资源”)
[2] 受害者,受害者想要获取的资源(简称“正义资源”)
[3] 受害者实际想要访问的服务器(简称“只是服务器”)
【4】中间代理服务器
攻击步骤一:
【1】攻击者浏览器向邪恶服务器发起WebSocket连接。根据上述,首先是协议升级请求。
[2] 协议升级请求实际到达代理服务器。
【3】代理服务器将协议升级请求转发至邪恶服务器。
【4】邪恶服务器同意连接,代理服务器将响应转发到攻击者。
由于升级实现上的缺陷,代理服务器认为之前转发的是正常的HTTP报文。因此,当协议服务器同意连接时,代理服务器认为会话结束。
攻击步骤二:
【1】攻击者在之前建立的连接上通过WebSocket接口向邪恶服务器发送数据,数据是精心构造的HTTP格式的文本。它包含正义资源的地址,以及一个假主机(指向正义服务器)。 (见下面的消息)
【2】请求到达代理服务器。虽然之前的TCP 连接被复用,但是代理服务器被认为是一个新的HTTP 请求。
【3】代理服务器请求邪恶服务器至邪恶资源。
【4】邪恶服务器返回邪恶资源。代理服务器缓存邪恶资源(url是正确的,但是host是正义服务器的地址)。
到这里,受害者可以登场了:
【1】受害者通过代理服务器访问正义服务器正义资源。
【2】代理服务器检查资源的url和host,发现有本地缓存(伪造)。
【3】代理服务器将邪恶资源返回到受害者。
【4】受害者死了。
2、当前解决方案
最初的建议是对数据进行加密。基于安全和效率的考虑,最终采用了一种折衷方案:对数据负载进行屏蔽。
需要注意的是,这只是限制浏览器屏蔽数据负载,但坏人可以完全实现自己的WebSocket 客户端和服务器,如果不遵守规则,攻击就可以照常进行。
但在浏览器中加入这个限制,可以大大增加攻击的难度和攻击的范围。没有这个
限制,只需要在网上放个钓鱼网站骗人去访问,一下子就可以在短时间内展开大范围的攻击。