WebSocket協議深入探究

程式猿小卡發表於2018-01-05

一、內容概覽

WebSocket的出現,使得瀏覽器具備了實時雙向通訊的能力。本文由淺入深,介紹了WebSocket如何建立連線、交換資料的細節,以及資料幀的格式。此外,還簡要介紹了針對WebSocket的安全攻擊,以及協議是如何抵禦類似攻擊的。

二、什麼是WebSocket

HTML5開始提供的一種瀏覽器與伺服器進行全雙工通訊的網路技術,屬於應用層協議。它基於TCP傳輸協議,並複用HTTP的握手通道。

對大部分web開發者來說,上面這段描述有點枯燥,其實只要記住幾點:

  1. WebSocket可以在瀏覽器裡使用
  2. 支援雙向通訊
  3. 使用很簡單

1、有哪些優點

說到優點,這裡的對比參照物是HTTP協議,概括地說就是:支援雙向通訊,更靈活,更高效,可擴充套件性更好。

  1. 支援雙向通訊,實時性更強。
  2. 更好的二進位制支援。
  3. 較少的控制開銷。連線建立後,ws客戶端、服務端進行資料交換時,協議控制的資料包頭部較小。在不包含頭部的情況下,服務端到客戶端的包頭只有2~10位元組(取決於資料包長度),客戶端到服務端的的話,需要加上額外的4位元組的掩碼。而HTTP協議每次通訊都需要攜帶完整的頭部。
  4. 支援擴充套件。ws協議定義了擴充套件,使用者可以擴充套件協議,或者實現自定義的子協議。(比如支援自定義壓縮演算法等)

對於後面兩點,沒有研究過WebSocket協議規範的同學可能理解起來不夠直觀,但不影響對WebSocket的學習和使用。

2、需要學習哪些東西

對網路應用層協議的學習來說,最重要的往往就是連線建立過程資料交換教程。當然,資料的格式是逃不掉的,因為它直接決定了協議本身的能力。好的資料格式能讓協議更高效、擴充套件性更好。

下文主要圍繞下面幾點展開:

  1. 如何建立連線
  2. 如何交換資料
  3. 資料幀格式
  4. 如何維持連線

三、入門例子

在正式介紹協議細節前,先來看一個簡單的例子,有個直觀感受。例子包括了WebSocket服務端、WebSocket客戶端(網頁端)。完整程式碼可以在 這裡 找到。

這裡服務端用了ws這個庫。相比大家熟悉的socket.iows實現更輕量,更適合學習的目的。

1、服務端

程式碼如下,監聽8080埠。當有新的連線請求到達時,列印日誌,同時向客戶端傳送訊息。當收到到來自客戶端的訊息時,同樣列印日誌。

var app = require(`express`)();
var server = require(`http`).Server(app);
var WebSocket = require(`ws`);

var wss = new WebSocket.Server({ port: 8080 });

wss.on(`connection`, function connection(ws) {
    console.log(`server: receive connection.`);
    
    ws.on(`message`, function incoming(message) {
        console.log(`server: received: %s`, message);
    });

    ws.send(`world`);
});

app.get(`/`, function (req, res) {
  res.sendfile(__dirname + `/index.html`);
});

app.listen(3000);

2、客戶端

程式碼如下,向8080埠發起WebSocket連線。連線建立後,列印日誌,同時向服務端傳送訊息。接收到來自服務端的訊息後,同樣列印日誌。

<script>
  var ws = new WebSocket(`ws://localhost:8080`);
  ws.onopen = function () {
    console.log(`ws onopen`);
    ws.send(`from client: hello`);
  };
  ws.onmessage = function (e) {
    console.log(`ws onmessage`);
    console.log(`from server: ` + e.data);
  };
</script>

3、執行結果

可分別檢視服務端、客戶端的日誌,這裡不展開。

服務端輸出:

server: receive connection.
server: received hello

客戶端輸出:

client: ws connection is open
client: received world

四、如何建立連線

前面提到,WebSocket複用了HTTP的握手通道。具體指的是,客戶端通過HTTP請求與WebSocket服務端協商升級協議。協議升級完成後,後續的資料交換則遵照WebSocket的協議。

1、客戶端:申請協議升級

首先,客戶端發起協議升級請求。可以看到,採用的是標準的HTTP報文格式,且只支援GET方法。

GET / HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==

重點請求首部意義如下:

  • Connection: Upgrade:表示要升級協議
  • Upgrade: websocket:表示要升級到websocket協議。
  • Sec-WebSocket-Version: 13:表示websocket的版本。如果服務端不支援該版本,需要返回一個Sec-WebSocket-Versionheader,裡面包含服務端支援的版本號。
  • Sec-WebSocket-Key:與後面服務端響應首部的Sec-WebSocket-Accept是配套的,提供基本的防護,比如惡意的連線,或者無意的連線。

注意,上面請求省略了部分非重點請求首部。由於是標準的HTTP請求,類似Host、Origin、Cookie等請求首部會照常傳送。在握手階段,可以通過相關請求首部進行 安全限制、許可權校驗等。

2、服務端:響應協議升級

服務端返回內容如下,狀態程式碼101表示協議切換。到此完成協議升級,後續的資料互動都按照新的協議來。

HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

備註:每個header都以
結尾,並且最後一行加上一個額外的空行
。此外,服務端回應的HTTP狀態碼只能在握手階段使用。過了握手階段後,就只能採用特定的錯誤碼。

3、Sec-WebSocket-Accept的計算

Sec-WebSocket-Accept根據客戶端請求首部的Sec-WebSocket-Key計算出來。

計算公式為:

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通過SHA1計算出摘要,並轉成base64字串。

虛擬碼如下:

>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 )  )

驗證下前面的返回結果:

const crypto = require(`crypto`);
const magic = `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`;
const secWebSocketKey = `w4v7O6xFTi36lq3RNcgctw==`;

let secWebSocketAccept = crypto.createHash(`sha1`)
    .update(secWebSocketKey + magic)
    .digest(`base64`);

console.log(secWebSocketAccept);
// Oy4NRAQ13jhfONC7bP8dTKb4PTU=

五、資料幀格式

客戶端、服務端資料的交換,離不開資料幀格式的定義。因此,在實際講解資料交換之前,我們先來看下WebSocket的資料幀格式。

WebSocket客戶端、服務端通訊的最小單位是幀(frame),由1個或多個幀組成一條完整的訊息(message)。

  1. 傳送端:將訊息切割成多個幀,併傳送給服務端;
  2. 接收端:接收訊息幀,並將關聯的幀重新組裝成完整的訊息;

本節的重點,就是講解資料幀的格式。詳細定義可參考 RFC6455 5.2節

1、資料幀格式概覽

下面給出了WebSocket資料幀的統一格式。熟悉TCP/IP協議的同學對這樣的圖應該不陌生。

  1. 從左到右,單位是位元。比如FINRSV1各佔據1位元,opcode佔據4位元。
  2. 內容包括了標識、操作程式碼、掩碼、資料、資料長度等。(下一小節會展開)
  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data continued ...                |
 +---------------------------------------------------------------+

2、資料幀格式詳解

針對前面的格式概覽圖,這裡逐個欄位進行講解,如有不清楚之處,可參考協議規範,或留言交流。

FIN:1個位元。

如果是1,表示這是訊息(message)的最後一個分片(fragment),如果是0,表示不是是訊息(message)的最後一個分片(fragment)。

RSV1, RSV2, RSV3:各佔1個位元。

一般情況下全為0。當客戶端、服務端協商採用WebSocket擴充套件時,這三個標誌位可以非0,且值的含義由擴充套件進行定義。如果出現非零的值,且並沒有採用WebSocket擴充套件,連線出錯。

Opcode: 4個位元。

操作程式碼,Opcode的值決定了應該如何解析後續的資料載荷(data payload)。如果操作程式碼是不認識的,那麼接收端應該斷開連線(fail the connection)。可選的操作程式碼如下:

  • %x0:表示一個延續幀。當Opcode為0時,表示本次資料傳輸採用了資料分片,當前收到的資料幀為其中一個資料分片。
  • %x1:表示這是一個文字幀(frame)
  • %x2:表示這是一個二進位制幀(frame)
  • %x3-7:保留的操作程式碼,用於後續定義的非控制幀。
  • %x8:表示連線斷開。
  • %x8:表示這是一個ping操作。
  • %xA:表示這是一個pong操作。
  • %xB-F:保留的操作程式碼,用於後續定義的控制幀。

Mask: 1個位元。

表示是否要對資料載荷進行掩碼操作。從客戶端向服務端傳送資料時,需要對資料進行掩碼操作;從服務端向客戶端傳送資料時,不需要對資料進行掩碼操作。

如果服務端接收到的資料沒有進行過掩碼操作,服務端需要斷開連線。

如果Mask是1,那麼在Masking-key中會定義一個掩碼鍵(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),該無符號整數的值為資料的長度。

此外,如果payload length佔用了多個位元組的話,payload length的二進位制表達採用網路序(big endian,重要的位在前)。

Masking-key:0或4位元組(32位)

所有從客戶端傳送到服務端的資料幀,資料載荷都進行了掩碼操作,Mask為1,且攜帶了4位元組的Masking-key。如果Mask為0,則沒有Masking-key。

備註:載荷資料的長度,不包括mask key的長度。

Payload data:(x+y) 位元組

載荷資料:包括了擴充套件資料、應用資料。其中,擴充套件資料x位元組,應用資料y位元組。

擴充套件資料:如果沒有協商使用擴充套件的話,擴充套件資料資料為0位元組。所有的擴充套件都必須宣告擴充套件資料的長度,或者可以如何計算出擴充套件資料的長度。此外,擴充套件如何使用必須在握手階段就協商好。如果擴充套件資料存在,那麼載荷資料長度必須將擴充套件資料的長度包含在內。

應用資料:任意的應用資料,在擴充套件資料之後(如果存在擴充套件資料),佔據了資料幀剩餘的位置。載荷資料長度 減去 擴充套件資料長度,就得到應用資料的長度。

3、掩碼演算法

掩碼鍵(Masking-key)是由客戶端挑選出來的32位的隨機數。掩碼操作不會影響資料載荷的長度。掩碼、反掩碼操作都採用如下演算法:

首先,假設:

  • original-octet-i:為原始資料的第i位元組。
  • transformed-octet-i:為轉換後的資料的第i位元組。
  • j:為i mod 4的結果。
  • masking-key-octet-j:為mask key第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根據opcode來區分操作的型別。比如0x8表示斷開連線,0x00x2表示資料互動。

1、資料分片

WebSocket的每條訊息可能被切分成多個資料幀。當WebSocket的接收方收到一個資料幀時,會根據FIN的值來判斷,是否已經收到訊息的最後一個資料幀。

FIN=1表示當前資料幀為訊息的最後一個資料幀,此時接收方已經收到完整的訊息,可以對訊息進行處理。FIN=0,則接收方還需要繼續監聽接收其餘的資料幀。

此外,opcode在資料交換的場景下,表示的是資料的型別。0x01表示文字,0x02表示二進位制。而0x00比較特殊,表示延續幀(continuation frame),顧名思義,就是完整訊息對應的資料幀還沒接收完。

2、資料分片例子

直接看例子更形象些。下面例子來自MDN,可以很好地演示資料的分片。客戶端向服務端兩次傳送訊息,服務端收到訊息後回應客戶端,這裡主要看客戶端往服務端傳送的訊息。

第一條訊息

FIN=1, 表示是當前訊息的最後一個資料幀。服務端收到當前資料幀後,可以處理訊息。opcode=0x1,表示客戶端傳送的是文字型別。

第二條訊息

  1. FIN=0,opcode=0x1,表示傳送的是文字型別,且訊息還沒傳送完成,還有後續的資料幀。
  2. FIN=0,opcode=0x0,表示訊息還沒傳送完成,還有後續的資料幀,當前的資料幀需要接在上一條資料幀之後。
  3. FIN=1,opcode=0x0,表示訊息已經傳送完成,沒有後續的資料幀,當前的資料幀需要接在上一條資料幀之後。服務端可以將關聯的資料幀組裝成完整的訊息。
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!

七、連線保持+心跳

WebSocket為了保持客戶端、服務端的實時雙向通訊,需要確保客戶端、服務端之間的TCP通道保持連線沒有斷開。然而,對於長時間沒有資料往來的連線,如果依舊長時間保持著,可能會浪費包括的連線資源。

但不排除有些場景,客戶端、服務端雖然長時間沒有資料往來,但仍需要保持連線。這個時候,可以採用心跳來實現。

  • 傳送方->接收方:ping
  • 接收方->傳送方:pong

ping、pong的操作,對應的是WebSocket的兩個控制幀,opcode分別是0x90xA

舉例,WebSocket服務端向客戶端傳送ping,只需要如下程式碼(採用ws模組)

ws.ping(``, false, true);

八、Sec-WebSocket-Key/Accept的作用

前面提到了,Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在於提供基礎的防護,減少惡意連線、意外連線。

作用大致歸納如下:

  1. 避免服務端收到非法的websocket連線(比如http客戶端不小心請求連線websocket服務,此時服務端可以直接拒絕連線)
  2. 確保服務端理解websocket連線。因為ws握手階段採用的是http協議,因此可能ws連線是被一個http伺服器處理並返回的,此時客戶端可以通過Sec-WebSocket-Key來確保服務端認識ws協議。(並非百分百保險,比如總是存在那麼些無聊的http伺服器,光處理Sec-WebSocket-Key,但並沒有實現ws協議。。。)
  3. 用瀏覽器裡發起ajax請求,設定header時,Sec-WebSocket-Key以及其他相關的header是被禁止的。這樣可以避免客戶端傳送ajax請求時,意外請求協議升級(websocket upgrade)
  4. 可以防止反向代理(不理解ws協議)返回錯誤的資料。比如反向代理前後收到兩次ws連線的升級請求,反向代理把第一次請求的返回給cache住,然後第二次請求到來時直接把cache住的請求給返回(無意義的返回)。
  5. Sec-WebSocket-Key主要目的並不是確保資料的安全性,因為Sec-WebSocket-Key、Sec-WebSocket-Accept的轉換計算公式是公開的,而且非常簡單,最主要的作用是預防一些常見的意外情況(非故意的)。

強調:Sec-WebSocket-Key/Sec-WebSocket-Accept 的換算,只能帶來基本的保障,但連線是否安全、資料是否安全、客戶端/服務端是否合法的 ws客戶端、ws服務端,其實並沒有實際性的保證。

九、資料掩碼的作用

WebSocket協議中,資料掩碼的作用是增強協議的安全性。但資料掩碼並不是為了保護資料本身,因為演算法本身是公開的,運算也不復雜。除了加密通道本身,似乎沒有太多有效的保護通訊安全的辦法。

那麼為什麼還要引入掩碼計算呢,除了增加計算機器的運算量外似乎並沒有太多的收益(這也是不少同學疑惑的點)。

答案還是兩個字:安全。但並不是為了防止資料洩密,而是為了防止早期版本的協議中存在的代理快取汙染攻擊(proxy cache poisoning attacks)等問題。

1、代理快取汙染攻擊

下面摘自2010年關於安全的一段講話。其中提到了代理伺服器在協議實現上的缺陷可能導致的安全問題。猛擊出處

“We show, empirically, that the current version of the WebSocket consent mechanism is vulnerable to proxy cache poisoning attacks. Even though the WebSocket handshake is based on HTTP, which should be understood by most network intermediaries, the handshake uses the esoteric “Upgrade” mechanism of HTTP [5]. In our experiment, we find that many proxies do not implement the Upgrade mechanism properly, which causes the handshake to succeed even though subsequent traffic over the socket will be misinterpreted by the proxy.”

[TALKING] Huang, L-S., Chen, E., Barth, A., Rescorla, E., and C.

          Jackson, "Talking to Yourself for Fun and Profit", 2010,

在正式描述攻擊步驟之前,我們假設有如下參與者:

  • 攻擊者、攻擊者自己控制的伺服器(簡稱“邪惡伺服器”)、攻擊者偽造的資源(簡稱“邪惡資源”)
  • 受害者、受害者想要訪問的資源(簡稱“正義資源”)
  • 受害者實際想要訪問的伺服器(簡稱“正義伺服器”)
  • 中間代理伺服器

攻擊步驟一:

  1. 攻擊者瀏覽器 向 邪惡伺服器 發起WebSocket連線。根據前文,首先是一個協議升級請求。
  2. 協議升級請求 實際到達 代理伺服器
  3. 代理伺服器 將協議升級請求轉發到 邪惡伺服器
  4. 邪惡伺服器 同意連線,代理伺服器 將響應轉發給 攻擊者

由於 upgrade 的實現上有缺陷,代理伺服器 以為之前轉發的是普通的HTTP訊息。因此,當協議伺服器 同意連線,代理伺服器 以為本次會話已經結束。

攻擊步驟二:

  1. 攻擊者 在之前建立的連線上,通過WebSocket的介面向 邪惡伺服器 傳送資料,且資料是精心構造的HTTP格式的文字。其中包含了 正義資源 的地址,以及一個偽造的host(指向正義伺服器)。(見後面報文)
  2. 請求到達 代理伺服器 。雖然複用了之前的TCP連線,但 代理伺服器 以為是新的HTTP請求。
  3. 代理伺服器邪惡伺服器 請求 邪惡資源
  4. 邪惡伺服器 返回 邪惡資源代理伺服器 快取住 邪惡資源(url是對的,但host是 正義伺服器 的地址)。

到這裡,受害者可以登場了:

  1. 受害者 通過 代理伺服器 訪問 正義伺服器正義資源
  2. 代理伺服器 檢查該資源的url、host,發現本地有一份快取(偽造的)。
  3. 代理伺服器邪惡資源 返回給 受害者
  4. 受害者 卒。

附:前面提到的精心構造的“HTTP請求報文”。

Client → Server:
POST /path/of/attackers/choice HTTP/1.1 Host: host-of-attackers-choice.com Sec-WebSocket-Key: <connection-key>
Server → Client:
HTTP/1.1 200 OK
Sec-WebSocket-Accept: <connection-key>

2、當前解決方案

最初的提案是對資料進行加密處理。基於安全、效率的考慮,最終採用了折中的方案:對資料載荷進行掩碼處理。

需要注意的是,這裡只是限制了瀏覽器對資料載荷進行掩碼處理,但是壞人完全可以實現自己的WebSocket客戶端、服務端,不按規則來,攻擊可以照常進行。

但是對瀏覽器加上這個限制後,可以大大增加攻擊的難度,以及攻擊的影響範圍。如果沒有這個限制,只需要在網上放個釣魚網站騙人去訪問,一下子就可以在短時間內展開大範圍的攻擊。

十、寫在後面

WebSocket可寫的東西還挺多,比如WebSocket擴充套件。客戶端、服務端之間是如何協商、使用擴充套件的。WebSocket擴充套件可以給協議本身增加很多能力和想象空間,比如資料的壓縮、加密,以及多路複用等。

篇幅所限,這裡先不展開,感興趣的同學可以留言交流。文章如有錯漏,敬請指出。

十一、相關連結

RFC6455:websocket規範
https://tools.ietf.org/html/rfc6455

規範:資料幀掩碼細節
https://tools.ietf.org/html/rfc6455#section-5.3

規範:資料幀格式
https://tools.ietf.org/html/rfc6455#section-5.1

server-example
https://github.com/websockets/ws#server-example

編寫websocket伺服器
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers

對網路基礎設施的攻擊(資料掩碼操作所要預防的事情)
https://tools.ietf.org/html/rfc6455#section-10.3

Talking to Yourself for Fun and Profit(含有攻擊描述)
http://w2spconf.com/2011/papers/websocket.pdf

What is Sec-WebSocket-Key for?
https://stackoverflow.com/questions/18265128/what-is-sec-websocket-key-for

10.3. Attacks On Infrastructure (Masking)
https://tools.ietf.org/html/rfc6455#section-10.3

Talking to Yourself for Fun and Profit
http://w2spconf.com/2011/papers/websocket.pdf

Why are WebSockets masked?
https://stackoverflow.com/questions/33250207/why-are-websockets-masked

How does websocket frame masking protect against cache poisoning?
https://security.stackexchange.com/questions/36930/how-does-websocket-frame-masking-protect-against-cache-poisoning

What is the mask in a WebSocket frame?
https://stackoverflow.com/questions/14174184/what-is-the-mask-in-a-websocket-frame


相關文章