WebSocket通信协议基础原理与安全威胁

前言

最近在项目上遇到了 WebSocket 通信,由于原来没有了解过 WebSocket,所以一开始上来也比较懵,接着学习了下 WebSocket 的原理和攻击模式,发现了点有意思的问题。本文来记录学习下 WebSocket 全双工通信的基本原理与鉴权机制,并分析 WebSocket 常见的安全风险。

WebSocket

WebSocket 和 HTTP 一样,也是一种通讯协议,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

协议基础

有很多网站为了实现推送技术,所用的技术都是 Ajax 轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出 HTTP 请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而 HTTP 请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。

WebSocket 协议是从 HTML5 开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。它能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
在这里插入图片描述
WebSocket 是真正意义上的全双工模式,也就是我们俗称的「长连接」。当完成握手连接后,客户端和服务端均可以主动的发起请求,回复响应,并且两边的传输都是相互独立的。

以一个通俗的场景来理解下 HTTP 协议与 WebSocket 协议的差异:

(1)HTTP 小场景(模拟 ajax 轮询)

  • 客户端:啦啦啦,有没有新信息(Request)
  • 服务端:没有(Response)
  • 客户端:啦啦啦,有没有新信息(Request)
  • 服务端:没有。。(Response)
  • 客户端:啦啦啦,有没有新信息(Request)
  • 服务端:你好烦啊,没有啊。。(Response)

(2)WebSocket 小场景(模拟全双工)

  • 客户端:啦啦啦,有没有新信息(Request)
  • 服务端:额。。没有(Response)
  • 客户端:啦啦啦,有没有新信息(Request)
  • 服务端:你个烦人精,有消息的时候我会主动发给你的(Response)
  • 服务端:烦人精,你要的信息来了(Response)……

建立连接

WebSocket 的数据传输,是基于 TCP 协议,但是在传输之前,还有一个握手的过程,双方确认过眼神,才能够正式的传输数据。WebSocket 的握手过程,符合其 “Web” 的特性,是利用 HTTP 本身的 “协议升级” 来实现。

在建立连接前,客户端还需要知道服务端的地址,WebSocket 并没有另辟蹊径,而是沿用了 HTTP 的 URL 格式,但协议标识符变成了 “ws” 或者 “wss”,分别表示明文和加密的 WebSocket 协议,这一点和 HTTP 与 HTTPS 的关系类似。
在这里插入图片描述
以下是一些 WebSocket 的 URL 例子:

ws://cxmydev.com/some/path
ws://cxmydev.com:8080/some/path
wss://cxmydev.com:443?uid=xxx

为了创建 Websocket 连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”。

实现步骤:

1、发起请求的浏览器端,发出协商报文:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

具体字段含义:
在这里插入图片描述
里面的核心字段:

  • Connection: Upgrade 以及 Upgrade: websocket 这个就是告诉服务器,下一步我要对协议进行升级了,升级到 WebSocket;
  • Sec-WebSocket-Key 是由浏览器随机生成的字符串的 Base64 编码,提供基本的防护,防止恶意或者无意的连接。

2、服务器端响应 101 状态码(即切换到socket通讯方式),其报文:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

一行行来解释上面服务端的响应的含义:

  • 首先,101 状态码表示服务器已经理解了客户端的请求,并将通过 Upgrade 消息头通知客户端采用不同的协议来完成这个请求;
  • 然后,Sec-WebSocket-Accept 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key;
  • 最后,Sec-WebSocket-Protocol 则是表示最终使用的协议。

其中 Sec-WebSocket-Accept 的计算方法:

  • 将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接;
  • 通过 SHA1 计算出摘要,并转成 base64 字符串,伪代码为:toBase64(sha1(Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ))

以上 Sec-WebSocket-Key、 Sec-WebSocket-Accept 字段的主要作用是防止一些意外的、错误连接。举个例子,它可以防止反向代理服务器(并不理解 ws 协议)返回错误的数据。假设如下场景:Ngnix 反向代理服务器前后收到两次 ws 连接的升级请求,反向代理把第一次请求的返回给 cache 住,然后第二次请求到来时直接把 cache 住的请求给返回(无意义的返回),此时客户端会发现 Sec-WebSocket-Accept 字段对不上号、从而关闭连接。

注意:Sec-WebSocket-Key/Sec-WebSocket-Accept 的换算,只能带来基本的保障,但连接是否安全、数据是否安全、客户端 / 服务端是否合法的 ws 客户端、ws 服务端,其实并没有实际性的保证

一旦服务器端返回 101 响应,即可完成 WebSocket 协议切换。服务器端可以基于相同端口,将通信协议从 http:// 或 https:// 切换到 ws://或 wss://。协议切换完成后,浏览器和服务器端可以使用 WebSocket API 互相发送和收取文本和二进制消息。

而在连接建立后,WebSocket 采用二进制帧的形式传输数据,其中常用的包括用于数据传输的数据帧 MESSAGE 以及 3 个控制帧:

  • PING:主动保活的 PING 帧;
  • PONG:收到 PING 帧后回复;
  • CLOSE:主动关闭 WebSocket 连接。

示例程序

下面使用 Python 来编写 WebSocket 通信的示例程序。Python websockets是用于在 Python 中构建 WebSocket 服务器和客户端的库,它基于 asyncio 异步 IO 建立,提供基于协程的 API。

1、服务端 Server.py

用于构建 websocket 服务器,在本地 8765 端口启动,会将接收到的消息加上 I got your message: 返回回去。

import asyncio
import websockets


async def echo(websocket, path):
    async for message in websocket:
        message = "I got your message: {}".format(message)
        await websocket.send(message)

if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(websockets.serve(echo, 'localhost', 8765))
    asyncio.get_event_loop().run_forever()

2、客户端Client.py

跟指定 url 建立 websocket 连接,并发送消息,然后等待接收消息,并将消息打印出来。

import asyncio
import websockets


async def hello(uri):
    async with websockets.connect(uri) as websocket:
        await websocket.send("Hello world!")
        recv_text = await websocket.recv()
        print(recv_text)


if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(hello('ws://localhost:8765'))

先执行 Server.py,再执行 Client.py,客户端的输出结果如下:
在这里插入图片描述
3、服务端主动发送消息

上面的示例未实现服务端主动给客户端发送消息的全双工通信,下面来完善下代码,当建立连接之后,客户端可以随时接收服务器发来的消息。服务器可以依据逻辑,给客户端推送指定消息。服务器和客户端代码会有一点变化,在服务器回完第一条消息之后,开始轮询时间,当秒数达到0的时候,会主动给客户端回一条消息。

Server.py:

import asyncio
import websockets
import time


async def echo(websocket, path):
    async for message in websocket:
        message = "I got your message: {}".format(message)
        await websocket.send(message)
        while True:
            t = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
            if str(t).endswith("0"):
                await websocket.send(t)
                break

if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(websockets.serve(echo, 'localhost', 8765))
    asyncio.get_event_loop().run_forever()

Client.py:

import asyncio
import websockets


async def hello(uri):
    async with websockets.connect(uri) as websocket:
        await websocket.send("Hello world!")
        print("< Hello world!")
        while True:
            recv_text = await websocket.recv()
            print("> {}".format(recv_text))

if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(hello('ws://localhost:8765'))

先执行 Server.py,再执行 Client.py,客户端的输出结果如下:
在这里插入图片描述
最后一条消息则是服务端主动给客户端发送的。

Burp抓包

1、在浏览器上使用 WebSocket

如何在前端发送 Websocket 请求呢?看这段代码 Client.html,先建立连接,然后向服务端发送 Hello world,然后把接收到的所有消息依次展示出来。

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <title>websocket通信客户端</title>
    <script src="jquery-3.5.0.min.js"></script>
    <script type="text/javascript">
        function WebSocketTest() {
            if ("WebSocket" in window) {
                // 打开一个 web socket
                var ws = new WebSocket("ws://127.0.0.1:8765");
                // 连接建立后的回调函数
                ws.onopen = function () {
                    // Web Socket 已连接上,使用 send() 方法发送数据
                    ws.send("Hello world!");
                    $("#main").append("<p>" + "<=" + "Hello world!" + "</p>")
                };

                // 接收到服务器消息后的回调函数
                ws.onmessage = function (evt) {
                    var received_msg = evt.data;
                    if (received_msg.indexOf("sorry") == -1) {
                        $("#main").append("<p>" + "=>" + received_msg + "</p>")
                    }
                };
                // 连接关闭后的回调函数
                ws.onclose = function () {
                    // 关闭 websocket
                    alert("连接已关闭...");
                };
            } else {
                // 浏览器不支持 WebSocket
                alert("您的浏览器不支持 WebSocket!");
            }
        }
    </script>
</head>
<body onload="WebSocketTest()">
<div id="main">
</div>
</body>
</html>

点击 PyCharm 提供的如下按钮在浏览器打开上述 HTML 文件:
在这里插入图片描述
程序运行效果如下图:
在这里插入图片描述
2、BurpSuite 观察 WebSocket 报文

这上面的示例程序运行过程中,使用 BurpSuite 抓包观察,可以在 HTTP history 看到建立 WebSocket 的握手过程:
在这里插入图片描述
在这里插入图片描述
同时可以在 WebSocket history 看到客户端与服务端的通信过程:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

可以将第一个会话 Hello World 发送到 Repeater 进行重放,效果如下:
在这里插入图片描述
同时上述重放会触发浏览器也同步更新:
在这里插入图片描述

安全威胁

上文讨论完 WebSocket 的基本概念和用法后,下面来讨论下 WebSocket 所面临的安全威胁。

鉴权缺失

从上面的示例程序中,读者应该发现了上述程序的服务端和客户端并未存在鉴权机制,谁都可以向服务端发起连接,如果服务端提供的接口包含敏感数据或业务功能,那么后果可想而知……

WebSocket 协议没有规定服务器在握手阶段应该如何认证客户端身份。服务器可以采用任何 HTTP 服务器的客户端身份认证机制,如 cookie 认证,HTTP 基础认证,TLS 身份认证等。在 WebSocket 应用认证实现上面临的安全问题和传统的 Web 应用认证是相同的,如:

  1. CVE-2015-0201:Spring 框架的 Java SockJS 客户端生成可预测的会话ID,攻击者可利用该漏洞向其他会话发送消息;;
  2. CVE-2015-1482:Ansible Tower 未对用户身份进行认证,远程攻击者通过 Websocket 连接获取敏感信息。

所谓鉴权,其实就是为了安全考虑,避免服务端启动 WebSocket 的连接服务后,任谁都可以连接,这肯定会引发一些安全问题。其次,服务端还需要将 WebSocket 的连接实体与一个真实的用户对应起来,否则业务就无法保证了。

前文提到,WebSocket 在握手阶段,使用的是 HTTP 的 “协议升级”,它本质上还是 HTTP 的报文头发送一些特殊的头数据,来完成协议升级。那么实际我们在 WebSocket 握手阶段,也可以通过 Header 传输一些鉴权的数据,例如 uid、token 之类,具体方法:

  1. 方案A:在握手阶段 WebSocket 服务端返回 Response 响应包的时候,为其 Header 或 Body 增加鉴权字段传递给客户端 ;
  2. 方案B:部分业务功能集成了 WebSocket 协议的 Web 系统已单独有授权机制并已颁发 Token,那么在触发 WeSocket 握手阶段时携带该 Token 进行后台校验。

鉴权方案合理性分析

在方案 A 中,握手成功后,随后客户端与服务端进行 WebSocket 通信的 URL 需要携带鉴权参数来防止未授权访问,比如如下的鉴权参数 token:

wss://example.com?uid=xxx&token=xxx

但是这么干并不优雅,效率不高。WebSocket 协议相对于 HTTP 协议而言,优越性在于 HTTP 是无状态的短连接协议,而 WebSocket 是有状态的长连接协议!

  1. 对于无状态的短连接 HTTP 协议,每次客户端与服务端进行业务往来时,客户端都需要带着它的身份凭证信息(如Cookie)发给服务端,服务端才能知道客户端是谁,而且这次业务办完了,服务端马上就跟客户端“撇清关系”、不记得刚才给谁服务过了。就好比一个办事大厅的客服每天阅人无数、忙的起飞,根本记不住来到普通办事窗口人群里张三就是张三,所以张三需要带上身份证才能办事……而且办完事以后客服马上会忘记刚才为谁提供过服务……所以张三下次来办事依然得刷身份证而不是刷脸!
  2. 而对于有状态的长连接协议 WebSocket 而言,客户端与服务端的通信过程,只需要与 WebSocket 服务器进行一次 HTTP 协议的握手,服务端会一直知道你的信息,直到你关闭请求。接上面的场景,WebSocket 就像张三有一天发达了,在办事大厅办了 VIP 会员,办事大厅直接给他配置了专属客服,每次张三去办事大厅都有 VIP 通道直达他的专属客服,此时张三当然不用每次去办事大厅还需要带上自己的身份证并告诉他的专属客服他是谁了,毕竟都 VIP 通道 + 专属客服了……而且,专属客服属于持续性只为张三服务的,除非张三不续费 VIP 了(请求关闭连接),他的专属客服才能为其他人提供服务……

上述逻辑关系可以用下图来概括:
在这里插入图片描述
综上,建议采用上述方案 B 进行 WebSocket 通信的鉴权,客户端只需要在 HTTP 协议的握手阶段带上认证凭据或唯一标识符,告诉服务端自己的身份并完成认证后,后续的 WebSocket 通信不需要携带任何认证凭据!因为服务端已经给客户端单独建立一条带有客户端标识信息的、互不干扰的专属通道了!

More】与此同时,同鉴权缺失的风险一样,WebSocket 协议没有指定任何授权方式,应用程序中用户资源访问等的授权策略由服务端或开发者实现。WebSocket 应用也会存在和传统 Web 应用相同的安全风险,如:垂直权限提升和水平权限提升

最后附上一个用于建立 WSS 协议链接且携带 JSON 格式数据的攻击测试脚本:

import asyncio
import websockets
import json

msg = {
    "method": "SUBSCRIBE",
    "id": 1,
    "params": ["!bookTicker"]
}


async def call_api(msg):
    async with websockets.connect('wss://127.0.0.1:8765/ws') as websocket:
        await websocket.send(msg)
        while websocket.open:
            response = await websocket.recv()
            # do something with the response...
            print(response)


if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(call_api(json.dumps(msg)))

劫持漏洞

WebSocket 使用基于源的安全模型,在发起 WebSocket 握手请求时,浏览器会在请求中添加一个名为 Origin 的 HTTP 头,Oringin 字段表示发起请求的源,以此来防止未经授权的跨站点访问请求。WebSocket 的客户端不仅仅局限于浏览器,因此 WebSocket 规范没有强制规定握手阶段的 Origin 头是必需的,并且 WebSocket 不受浏览器同源策略的限制。

如果服务端没有针对 Origin 头部进行验证可能会导致跨站点 WebSocket 劫持攻击。该漏洞最早在 2013 年被Christian Schneider 发现并公开,Christian 将之命名为跨站点 WebSocket 劫持 (Cross Site WebSocket Hijacking)(CSWSH)。跨站点 WebSocket 劫持危害大,但容易被开发人员忽视。相关案例可以参考: IPython Notebook(CVE-2014-3429)、OpenStack Compute(CVE-2015-0259)、Zeppelin WebSocket 服务器等跨站 WebSocket 劫持。
在这里插入图片描述
上图展示了跨站 WebSocket 劫持的过程,某个用户已经登录了 WebSocket 应用程序,如果他被诱骗访问了某个恶意网页,而恶意网页中植入了一段 js 代码,自动发起 WebSocket 握手请求跟目标应用建立 WebSocket 连接。注意到,Origin 和 Sec-WebSocket-Key 都是由浏览器自动生成的,浏览器再次发起请求访问目标服务器会自动带上Cookie 等身份认证参数。如果服务器端没有检查 Origin头,则该请求会成功握手切换到 WebSocket 协议,恶意网页就可以成功绕过身份认证连接到 WebSocket 服务器,进而窃取到服务器端发来的信息,或者发送伪造信息到服务器端篡改服务器端数据。与传统跨站请求伪造(CSRF)攻击相比,CSRF 主要是通过恶意网页悄悄发起数据修改请求,而跨站 WebSocket 伪造攻击不仅可以修改服务器数据,还可以控制整个双向通信通道。也正是因为这个原因,Christian 将这个漏洞命名为劫持(Hijacking),而不是请求伪造(Request Forgery)。

问题来了,为什么能实现会话劫持、控制双向通道???因为 HTML 恶意链接借助受害者有效的 Cookie 建立了 WebSocket 通道后,跟 WebSocket 服务端通信的报文可以不需要带 Cookie 了!! 只需要欺骗受害者让他进原本只属于受害者的专属通道……后面攻击者不需要再让受害者给他提供任何认证凭据了!

到这里也许熟悉浏览器同源策略(Same-origin policy,简称 SOP)限制的读者可能会怀疑以上观点。如果 HTTP Response 没有指定 Access-Control-Allow-Origin 字段来实现跨域资源共享 Cross-Origin Resource Sharing(CORS) 的话,浏览器端的脚本是无法访问跨域资源的啊??!!

是的,这就是众所周知的浏览器同源策略限制,这确实也是 HTML5 带来的新特性之一。浏览器同源策略 SOP 并不是禁止跨域请求(但是会限制 Cookie 的使用,B 网站的 JS 脚本发起对 A 网站的请求,AOP 是不会允许将 A 网站的 Cookie 附加在该请求里的,否则就天下大乱了……),而是在请求后拦截了请求的回应。SOP 会使得非同源(域名、协议、端口等不同)的网站 B 的 JavaScript 脚本是无法读取网站 A 的服务端响应包数据!即网站 A 可能会受到来源于网站 B 的恶意链接所带来的 CSRF 攻击的威胁,但是网站 B 并无法读取到网站 A 的服务端相应数据,除非网站 A 在响应包中配置了 Access-Control-Allow-Origin 字段来实现跨域资源共享,否则浏览器会自动阻止网站 B 的 JS 脚本读取网站 A 的响应数据! 这也就导致了 CSRF 漏洞一般只能用于诱骗受害者带着他的合法 Cookie 去提交请求(如新增管理员账户的请求、删除日志的请求),但是并无法获取受害网站的敏感数据。

但是很不幸,浏览器同源策略与跨域资源共享并不适应于 WebSocket 协议 !!!WebSocket 没有明确规定跨域处理的方法。 这意味着 WebSocket 劫持漏洞下,虽然攻击者所发送的恶意链接所在的域名 B 与受害者网站 A 不同,但仍能借助 JS 脚本读取网站 A 服务器的响应数据,实现双向通信!由此也可见,WebSocket 劫持漏洞绝对不能当作普通 CSRF 漏洞来对待!

跨域劫持漏洞证明

空口无凭,来证明下 WebSocket 跨域劫持漏洞能“无视”浏览器同源策略,主要需要证明两点:

  1. B 网站的恶意链接中 JS 脚本向 A 网站发起 ws/wss 协议的连接请求,握手阶段的 HTTP 报文中,浏览器允许将 A 网站的 Cookie 附上该握手请求(只是附加上而已,B 网站的 JS 脚本读取不了 Cookie 的值),这是攻击者能成功借助恶意链接假冒用户身份发起 ws/wss 握手请求的关键;
  2. B 网站的恶意链接中 JS 脚本能读取 A 网站提供的 WebSocket 服务的响应包数据(HTTP 协议的话会遭受 SOP 组织),这决定了攻击者可以读取到 WebSocket 连接的响应数据,实现双向控制。

先来证明第一条,客户端脚本程序如下,发起对 CSDN 的 WSS 请求(假设 CSDN 有 WSS 服务……实际上有没有不要紧,关键是拿到 Cookie):
在这里插入图片描述
可以看到握手请求里面,浏览器将 CSDN 的 Cookie 成功附上了:
在这里插入图片描述
接着证明第二条,跨域读取响应包。很简单,本地 localhost 上 8765 端口起一个 WebSocket 服务,然后将访问 ws 服务的 Client.html 部署在 VPS 服务器,看看 Client.html 的 JS 脚本还能否正常读取 ws 服务的响应包:
在这里插入图片描述
可以看到,B 网站的 JS 脚本是可以读取到 A 网站的 WebSocket 服务的响应包数据的:
在这里插入图片描述
以上两点,证明了 WebSocket 跨域劫持漏洞的可行性,正式 WebSocket 协议不受浏览器同源策略的限制,才使其面临 HTTP 协议无需应对的风险和威胁。

理解了跨站 WebSocket 劫持攻击的原理和过程,那么如何防范这种攻击呢?处理也比较简单,在服务器端的代码中增加对 Origin 头的检查,如果客户端发来的 Origin 信息来自不同域,服务器端可以拒绝该请求。但是仅仅检查 Origin 仍然是不够安全的,恶意网页可以伪造 Origin 头信息,绕过服务端对 Origin 头的检查,更完善的解决方案可以借鉴 CSRF 的解决方案-令牌机制。

拒绝服务

WebSocket 设计为面向连接的协议,可被利用引起客户端和服务器端拒绝服务攻击,相关案例可参考: F5 BIG-IP 远程拒绝服务漏洞(CVE-2016-9253)。

1、客户端拒绝服务

WebSocket 连接限制不同于 HTTP 连接限制,和 HTTP 相比,WebSocket 有一个更高的连接限制,不同的浏览器有自己特定的最大连接数,如:火狐浏览器默认最大连接数为 200。通过发送恶意内容,用尽允许的所有 Websocket 连接耗尽浏览器资源,引起拒绝服务。

2、服务器端拒绝服务

WebSocket 建立的是持久连接,只有客户端或服务端其中一发提出关闭连接的请求,WebSocket 连接才关闭,因此攻击者可以向服务器发起大量的申请建立 WebSocket 连接的请求,建立持久连接,耗尽服务器资源,引发拒绝服务。针对这种攻击,可以通过设置单 IP 可建立连接的最大连接数的方式防范。

注入漏洞

WebSocket 应用和传统 Web 应用一样,都需要对输入进行校验,来防范来客户端的 XSS 攻击,服务端的 SQL 注入,代码注入等攻击。

来看一个靶场,点开靶场,我们移步到 Live Chat 的界面。
在这里插入图片描述
先抓包,接着发送一条数据,并在 WebSockets History 中查看数据,修改包,构造成 XSS 的 POC。
在这里插入图片描述
发包,再回到 Web 界面的时候成功实现 XSS。
在这里插入图片描述

验证猜想

以上对于 WebSocket 鉴权方案和劫持漏洞的讨论,读者可能会觉得有点颠覆认知、半信半疑。总不能我说是它就是吧……

没关系,下面来通过一个基于 SpringBoot 框架搭建的 WebSocket 服务程序(源于 Gitee 开源项目:spring-websocket)来证明我上述对 WebSocket 认识是否正确。

源码部署

项目结构很简单:
在这里插入图片描述

1、下载项目源码, 对于 pom.xml 需做如下修改,它自动下载项目依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.15.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.github.taven</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-websocket</name>
    <description>Demo project for Spring Boot</description>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

    </dependencies>
</project>

2、看下项目启动类 WebSocketApplication,很简单:

package cn.org.spring.tools.websocket;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Created with IntelliJ IDEA.
 * User:  wanghongjie
 * Date:  2020/9/26 - 15:20
 * <p>
 * Description:
 */
@SpringBootApplication
public class WebSocketApplication {
    public static void main(String[] args) {
        SpringApplication.run(WebSocketApplication.class);
    }
}

3、再看下握手阶段建立 WebSocket 链接前的权限验证拦截器 Interceptor,这里继承 HandshakeInterceptor,具体实现如下:

package cn.org.spring.tools.websocket.interceptor;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;

/**
 * Created with IntelliJ IDEA.
 * User:  wanghongjie
 * Date:  2020/9/26 - 22:59
 * <p>
 * Description:
 */
@Component
public class MyHandshakeInterceptor implements HandshakeInterceptor {
    /**
     * 握手之前,若返回false,则不建立链接 *
     *
     * @param request
     * @param response
     * @param wsHandler
     * @param attributes
     * @return
     */
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse
            response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
        //将用户id放入socket处理器的会话(WebSocketSession)中
        ServletServerHttpRequest serverHttpRequest = (ServletServerHttpRequest) request;
        //获取参数
        String userId = serverHttpRequest.getServletRequest().getParameter("userId");
        attributes.put("uid", userId);
        System.out.println("***********************************");
        //可以在此处进行权限验证,当用户权限验证通过后,进行握手成功操作,验证失败返回false
        if (userId.equals("123")) {
            System.out.println("[*]非法用户,握手失败!");
            return false;
        }
        System.out.println("[*]合法用户,开始握手!");
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse
            response, WebSocketHandler wsHandler, Exception exception) {
        System.out.println("[*]握手成功啦!");
    }
}

4、接着看下实现 WebSocket 通信具体业务逻辑处理的 Handle 类MyWebSocketHandler:

package cn.org.spring.tools.websocket.handler;

import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Created with IntelliJ IDEA.
 * User:  wanghongjie
 * Date:  2020/9/26 - 22:51
 * <p>
 * Description:
 */
public class MyWebSocketHandler extends TextWebSocketHandler {

    //静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
    private static AtomicInteger onlineNum = new AtomicInteger();
    //concurrent包的线程安全Set,用来存放每个客户端对应的WebSocketServer对象。
    private static ConcurrentHashMap<String, WebSocketSession> sessionPools = new ConcurrentHashMap<>();

    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message)
            throws IOException {
        System.out.println("     >> 用户 " + session.getAttributes().get("uid")+ " 说:" + message.getPayload());
        session.sendMessage(new TextMessage(String.format("[*]收到用户:【%s】发来的消息【%s】",
                session.getAttributes().get("uid"), message.getPayload())));
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws
            Exception {
        System.out.println("[*]新增成功登录的用户ID : " + session.getAttributes().get("uid"));
        String uid = session.getAttributes().get("uid").toString();
        //TODO: 重复链接没有进行处理
        sessionPools.put(uid, session);
        addOnlineCount();
        System.out.println("[*]用户 " + uid + " 已成功加入WebSocket!当前人数为" + onlineNum);
        session.sendMessage(new TextMessage("[*]欢迎连接到 WebSocket 服务! 当前人数为:" + onlineNum));
    }


    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status)
            throws Exception {
        String uid = session.getAttributes().get("uid").toString();
        sessionPools.remove(uid);
        subOnlineCount();
        System.out.println("[*]用户 " + uid +" 已断开连接!");
        System.out.println("***********************************");
    }

    /**
     * 添加链接人数
     */
    public static void addOnlineCount() {
        onlineNum.incrementAndGet();
    }

    /**
     * 移除链接人数
     */
    public static void subOnlineCount() {
        onlineNum.decrementAndGet();
    }

}

5、继续,再看下 webSocket 配置类 WebSocketConfig:

package cn.org.spring.tools.websocket.config;

import cn.org.spring.tools.websocket.handler.MyWebSocketHandler;
import cn.org.spring.tools.websocket.interceptor.MyHandshakeInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

import javax.annotation.Resource;

/**
 * Created with IntelliJ IDEA.
 * User:  wanghongjie
 * Date:  2020/9/26 - 22:54
 * <p>
 * Description:
 */
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    /**
     * 注入拦截器
     */
    @Resource
    private MyHandshakeInterceptor myHandshakeInterceptor;


    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {

        webSocketHandlerRegistry
                //添加myHandler消息处理对象,和websocket访问地址
                .addHandler(myHandler(), "/ws")
                //设置允许跨域访问
                .setAllowedOrigins("*")
                //添加拦截器可实现用户链接前进行权限校验等操作
                .addInterceptors(myHandshakeInterceptor);
    }

    @Bean
    public WebSocketHandler myHandler() {
        return new MyWebSocketHandler();
    }
}

至此,项目代码过完了,还剩一个配置服务端口的配置文件 application.yml:

server:
  port: 9001
spring:
  application:
    name: spring-websocket

最后启动服务很简单,回到项目启动类 WebSocketApplication,点击 run 项目即可:
在这里插入图片描述

业务测试

首先构建一个 Html Client 测试页面,用于建立连接 "ws://127.0.0.1:9001/ws?userId=111"

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <title>websocket通信客户端</title>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <script type="text/javascript">
        function WebSocketTest() {
            if ("WebSocket" in window) {
                // 打开一个 web socket
                var ws = new WebSocket("ws://127.0.0.1:9001/ws?userId=111");
                // 连接建立后的回调函数
                ws.onopen = function () {
                    // Web Socket 已连接上,使用 send() 方法发送数据
                    ws.send("Hello World!");
                    $("#main").append("<p>" + "<=" + "Hello World!" + "</p>")
                };

                // 接收到服务器消息后的回调函数
                ws.onmessage = function (evt) {
                    var received_msg = evt.data;
                    if (received_msg.indexOf("sorry") == -1) {
                        $("#main").append("<p>" + "=>" + received_msg + "</p>")
                    }
                };

                // 连接关闭后的回调函数
                ws.onclose = function () {
                    // 关闭 websocket
                    alert("连接已关闭...");
                };
            } else {
                // 浏览器不支持 WebSocket
                alert("您的浏览器不支持 WebSocket!");
            }
        }
    </script>
</head>
<body onload="WebSocketTest()">
<div id="main">
</div>
</body>
</html>

同样在 Pycharm 里面借助启动服务访问上述 Html 页面,效果如下:
在这里插入图片描述
修改 Client.html,分别尝试建立连接 "ws://127.0.0.1:9001/ws?userId=222" 与 连接 "ws://127.0.0.1:9001/ws?userId=123",如下:
在这里插入图片描述
在这里插入图片描述
此时查看服务端的日志信息如下:
在这里插入图片描述
以上过程可以关注到如下 HTTP 数据包:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
还记录了 111、222 用户与服务端通信的 WebSocket 数据包:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

安全思考

可以看到以上程序虽然握手阶段校验了 uid,但是握手成功以后, WebSocket 通信是不携带 uid 的。那么问题来了,先回顾上面服务端的日志信息:
在这里插入图片描述
此处服务端怎么知道是 111 还是 222 给它发的 ”Hello World" 呢?
在这里插入图片描述
回顾握手阶段拦截器实现的控制逻辑:
在这里插入图片描述
可以看到握手时,服务端给每个连接所在的会话增加了 uid 属性存放 userId 值,而这个会话属性会一直附属在所建立的特有的会话通道上,服务端在接收到通道发来的消息时,可以调用 session.getAttributes().get("uid") 来提取 uid 属性值(即 userId),从而实现了 WebSocket 报文虽然不带 userId,但是服务端能正常识别是哪个 userId 对应的用户发来的!

至此,我想足以证明我前文对 WebSocket 鉴权方案和劫持漏洞的理解和认识的正确性了吧。还云里雾里的话建议动手自己部署代码观察一遍……

补充一个细节,WebSocket 协议的握手请求(HTTP 请求)和后续的 WebSocket 通信报文在同一条 TCP 连接上进行传输,使用 WireShark 抓包观察:
在这里插入图片描述
这也可以解释为什么握手请求结束后,服务端能依靠 TCP 连接通道来识别客户端的信息,因为握手请求(携带Cookie鉴权)和后续的 WebSocket 通信始终在同一 TCP 连接通道进行。

总结

WebSocket 是一个基于 TCP 的 HTML5 的新协议,可以实现浏览器和服务器之间的全双工通讯。在即时通讯等应用中,WebSocket 具有很大的性能优势,并且非常适合全双工通信,但是和任何其他技术一样,开发 WebSocket 应用也需要考虑潜在的安全风险。

基于 WebSocket 的一系列漏洞的防御措施:

  1. 使用 wss 协议(WebSockets over TLS),防止中间人攻击;
  2. 通过设置 Cookie、Token 等鉴权字段来对 WebSockets 进行鉴权,纺防止未授权漏洞;
  3. 校验 Origin 字段来保护 WebSocket 握手消息免受 CSRF 的攻击,以避免跨站点 WebSockets 劫持漏洞;
  4. 将通过 WebSocket 接收的数据视为在两个方向上都不受信任,在服务器端和客户端安全地处理数据,以防止基于输入的漏洞,如 SQL 注入和 CSRF。

本文参考文章:

  1. WebSocket协议:5分钟从入门到精通
  2. 使用Python创建websocket服务和客户端请求
  3. WebSocket 劫持漏洞
  4. WebSocket应用安全问题分析
  5. PortSwigger 基于 WebSocket 的漏洞讲解
  6. 详解跨站点WebSocket劫持漏洞的原理
  7. Cross-Site WebSocket Hijacking (CSWSH)
  8. WebSocket 是什么原理?为什么可以实现持久连接?
  9. 前端网络安全必修——同源策略和CSRF
  10. SpringBoot整合webSocket并增加用户权限验证
  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Tr0e

分享不易,望多鼓励~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值