
WebSocket 拆解到字节从 HTTP 握手到掩码与心跳实验说明本篇的线缆字节抓取是在分配的 lab 服务器s1公网119.3.254.184上用纯 Python 标准库最小实现完成的同机回环127.0.0.1:8765服务端即本机。执行期间该机曾因源 IP 被云厂商 HSS 风控临时不可达恢复后在 s1 重跑抓到的字节与之前完全一致WebSocket 线缆格式是确定性的与运行平台无关。文中所有握手/帧字节均为真实程序输出逐字节做了人工核对含 Sec-WebSocket-Accept 客户端/服务端比对一致。0. 引言为什么有了 HTTP 还要 WebSocket假设你做一个聊天室或实时行情用HTTP 轮询客户端每 2 秒GET /msg问有新消息吗99% 的回答是没有。浪费带宽、延迟高、服务端被空轮询拖垮。用HTTP 长轮询服务端 holds 住请求直到有消息——但每次消息都结束一次 HTTP 事务要重新建连。根本矛盾是HTTP 是一问一答、服务端不能主动推的半双工模型。WebSocket 干的事就是借一次 HTTP 握手升级成一条全双工长连接之后客户端和服务端想发就发不必再带 HTTP 头。本篇目标把这次升级和之后的每个帧抓成原始字节逐位解读。HTTP 世界 客户端 ──请求──► 服务端 ──响应──► 客户端 服务端不能主动 WebSocket 客户端 ◄═════ 全双工长连接 ═════► 服务端 双方随时发1. 一、握手一次 HTTP Upgrade 到 101WebSocket 不是凭空出来的它复用 HTTP 的端口通常 80/443靠Upgrade头把连接升舱。1.1 客户端发出的握手请求真实字节我们用最小实现手工构造了握手下面是socket.sendall出去的原始字节hexdump0000: 47 45 54 20 2f 63 68 61 74 20 48 54 54 50 2f 31 GET /chat HTTP/1 0010: 2e 31 0d 0a 48 6f 73 74 3a 20 31 32 37 2e 30 2e .1..Host: 127.0. 0020: 30 2e 31 3a 38 37 36 35 0d 0a 55 70 67 72 61 64 0.1:8765..Upgrad 0030: 65 3a 20 77 65 62 73 6f 63 6b 65 74 0d 0a 43 6f e: websocket..Co 0040: 6e 6e 65 63 74 69 6f 6e 3a 20 55 70 67 72 61 64 nnection: Upgrad 0050: 65 0d 0a 53 65 63 2d 57 65 62 53 6f 63 6b 65 74 e..Sec-WebSocket 0060: 2d 4b 65 79 3a 20 4d 44 45 79 4d 7a 51 31 4e 6a -Key: MDEyMzQ1Nj 0070: 63 34 4f 57 46 69 59 32 52 6c 5a 67 3d 3d 0d 0a c4OWFiY2RlZg.. 0080: 53 65 63 2d 57 65 62 53 6f 63 6b 65 74 2d 56 65 Sec-WebSocket-Ve 0090: 72 73 69 6f 6e 3a 20 31 33 0d 0a 0d 0a rsion: 13....转成可读文本就是GET /chat HTTP/1.1 Host: 127.0.0.1:8765 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg Sec-WebSocket-Version: 13关键字段字段含义Upgrade: websocket我要把协议升级成 websocketConnection: Upgrade配合 Upgrade 使用HTTP/1.1 标准约定Sec-WebSocket-Key客户端随机生成的 16 字节Base64 后发送防缓存代理误把 WS 当普通 HTTPSec-WebSocket-Version: 13协议版本现行就是 131.2 服务端回 101 Switching Protocols真实字节0000: 48 54 54 50 2f 31 2e 31 20 31 30 31 20 53 77 69 HTTP/1.1 101 Swi 0010: 74 63 68 69 6e 67 20 50 72 6f 74 6f 63 6f 6c 73 tching Protocols 0020: 0d 0a 55 70 67 72 61 64 65 3a 20 77 65 62 73 6f ..Upgrade: webso 0030: 63 6b 65 74 0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e cket..Connection 0040: 3a 20 55 70 67 72 61 64 65 0d 0a 53 65 63 2d 57 : Upgrade..Sec-W 0050: 65 62 53 6f 63 6b 65 74 2d 41 63 63 65 70 74 3a ebSocket-Accept: 0060: 20 42 41 43 53 63 43 4a 50 4e 71 79 7a 2b 55 42 BACScCJPNqyzUB 0070: 6f 71 4d 48 38 39 56 6d 55 52 6f 41 3d 0d 0a 0d oqMH89VmURoA... 0080: 0a .可读文本HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: BACScCJPNqyzUBoqMH89VmURoA注意服务端没有回Sec-WebSocket-Key而是回了一个完全不同的Sec-WebSocket-Accept。这个值是怎么来的下面手算验证。1.3 手工计算 Sec-WebSocket-AcceptSHA1 Base64算法RFC 6455 规定死规定accept Base64( SHA1( 客户端Key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )所谓GUID魔法字符串就是固定的258EAFA5-E914-47DA-95CA-C5AB0DC85B11。我们用 Python 手工算一次并与服务端回的值比对importbase64,hashlib GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11keyMDEyMzQ1Njc4OWFiY2RlZg# 客户端发出的 Keyacceptbase64.b64encode(hashlib.sha1((keyGUID).encode()).digest()).decode()print(accept)# 输出BACScCJPNqyzUBoqMH89VmURoA程序里同时打印了客户端算的和服务端回的真实输出[验证] 客户端用 Key 算出的 Accept BACScCJPNqyzUBoqMH89VmURoA [验证] 服务端返回的 Accept BACScCJPNqyzUBoqMH89VmURoA [验证] 是否一致 True二者完全一致。这一步的意义客户端 Key 是明文、可逆Base64的 —— 不能拿它当认证。 服务端必须把 Key 拼接上固定的 GUID 再做 SHA1Base64 这个拼接哈希过程服务端才知道、客户端无法预知 从而证明对面确实是一个懂 WebSocket 握手的服务端 而不是某个缓存代理把 WS 请求当普通 HTTP 给糊弄了。抓包口诀看到101 Switching ProtocolsSec-WebSocket-Accept握手就成了此后的字节不再是 HTTP而是 WebSocket 帧。任何一方再按 HTTP 去解析都会失败。2. 二、帧格式FIN / opcode / MASK / 长度逐字节解读握手结束后所有数据都是帧frame。一个帧的头两个字节最关键字节0: 0 0 0 0 0 0 0 0 └┬┘ └──┬──┘ │ └── opcode(4bit)帧类型1文本 2二进制 8关闭 9ping Apong └──────── FIN(1bit)是否为消息的最后一个分片 RSV(3bit)扩展保留通常为 0 字节1: 0 0 0 0 0 0 0 0 └┬┘ └──┬──┘ │ └── payload len(7bit)包体长度0~125126/127 表示后面还有扩展长度 └──────── MASK(1bit)是否掩码客户端→服务端**必须**为 1服务端→客户端**必须**为 0完整帧布局长度字段分段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 len126/127) | | |1|2|3| |K| | | ------------------------- - - - - - - - - - - - - - - | Masking-key (4 bytes, 仅当 MASK1) ... | --------------------------------------------------------------- | Payload Data (XOR 掩码还原后) ... | ---------------------------------------------------------------2.1 客户端文本帧带掩码——逐字节解读客户端发HelloWebSocket14 字节真实抓到的帧字节0000: 81 8e 12 34 56 78 5a 51 3a 14 7d 63 33 1a 41 5b ...4VxZQ:.}c3.A[ 0010: 35 13 77 40 5.w我们把它拆开字节0 0x81 1000 0001 ├ FIN1这是消息的最后一片 └ opcode0x1文本帧 Text 字节1 0x8e 1000 1110 ├ MASK1客户端发出必须掩码 └ Payload len0x0e14包体 14 字节 字节2-5 12 34 56 78 ← 4 字节掩码密钥Masking-key 字节6 5a 51 3a 14 7d 63 33 1a 41 5b 35 13 77 40 ← 14 字节【已掩码】的包体掩码还原客户端用密钥12 34 56 78对每字节做 XOR密钥循环使用密文 5a 51 3a 14 7d 63 33 1a 41 5b 35 13 77 40 密钥 12 34 56 78 12 34 56 78 12 34 56 78 12 34 56 78 XOR ──► 48 65 6c 6c 6f 57 65 62 53 6f 63 6b 65 74 H e l l o W e b S o c k e t 明文 HelloWebSocket逐字节验证5a^1248H51^3465e3a^566cl14^786cl……40^3474t。2.2 服务端回显帧无掩码——对照服务端把同一句原样回显真实字节0000: 81 0e 48 65 6c 6c 6f 57 65 62 53 6f 63 6b 65 74 ..HelloWebSocket字节0 0x81 FIN1, opcode0x1文本 字节1 0x0e MASK0服务端发出禁止掩码, len14 字节2 48 65 6c 6c 6f 57 65 62 53 6f 63 6b 65 74 HelloWebSocket明文无掩码一句话对比客户端→服务端 81 8e [掩码4字节] [XOR后的密文] ← 必须掩码 服务端→客户端 81 0e [明文] ← 必须不掩码2.3 长度字段的三种情况Payload len只有 7 bit装不下大包于是规定字节1 的 len 字段真实长度怎么取0~125就是它自己126后面跟2 字节uint16 表示长度最大 65535127后面跟8 字节uint64 表示长度支持超大消息这也是为什么抓大消息时帧头会从 2 字节变长成 4 或 10 字节。3. 三、掩码机制为什么客户端必须掩码3.1 规则客户端发往服务端的帧MASK 位必须为 1RFC 6455 强制否则服务端应直接断开。服务端发往客户端的帧MASK 位必须为 0服务端禁止掩码。我们在实验里手工构造时严格遵循客户端make_frame(opcode, payload, maskTrue)服务端make_frame(opcode, payload, maskFalse)。3.2 掩码不是加密是为了防代理缓存污染很多人误以为掩码是安全加密其实掩码密钥就在帧里明文传见 2.1 的12 34 56 78任何抓包者都能还原。它的真实目的是历史背景早期有透明代理/缓存代理会改写它们看到的 HTTP 流量。 如果 WS 之前的伪装是普通 HTTP代理可能把客户端发的字节 误当成响应体缓存下来、甚至回给其他后来的连接。 掩码让每个连接的包体字节都看起来随机 代理无法把 A 连接的数据当作 B 连接的缓存 从而避免代理缓存污染攻击Proxy Cache Poisoning。 现代视角这是协议层的防御性设计代价是每个客户端帧多 4 字节密钥 一次 XOR。生产提醒如果你自己实现 WS 客户端忘了设 MASK1服务端会直接拒绝/断连若实现服务端却给对方发掩码帧合规的客户端也应断开。这是最常见的手写 WS 连不上原因。4. 四、Ping / Pong 心跳 与 Close 关闭帧4.1 Ping / Pong保活与探活WebSocket 用Pingopcode0x9/Pongopcode0xA做心跳。一方发 Ping另一方必须尽快回 Pong且 Pong 的包体要与 Ping 一致。客户端发出 Ping 帧真实字节0000: 89 80 12 34 56 78 ...4Vx0x89 1000 1001 → FIN1, opcode0x9Ping 0x80 1000 0000 → MASK1客户端必须掩码, len0空包体 12 34 56 78 → 掩码密钥服务端回 Pong真实字节0000: 8a 00 ..0x8a 1000 1010 → FIN1, opcode0xAPong 0x00 MASK0, len0服务端不掩码空包体心跳的作用1. 保活keep-alive长时间无业务数据时定时 Ping/Pong 让中间设备 不把看起来空闲的 TCP 连接回收掉。 2. 探活liveness发个 Ping 等 Pong超时没回说明对端已死主动断连。 3. 注意Ping/Pong 由实现层自动处理应用代码通常感知不到。4.2 Close关闭握手与状态码关闭 WebSocket 要走关闭握手一方发Close帧opcode0x8另一方回一个Close然后 TCP 才关闭。客户端发 Close带状态码 1000真实字节0000: 88 82 12 34 56 78 11 dc ...4Vx..0x88 1000 1000 → FIN1, opcode0x8Close 0x82 1000 0010 → MASK1, len2 12 34 56 78 → 掩码密钥 11 dc → 掩码后的 2 字节状态码 还原11^1203, dc^34e8 → 0x03e8 1000服务端回 Close真实字节0000: 88 02 03 e8 ...0x88 → Close, 0x02 → MASK0, len203 e8 1000状态码无掩码常用关闭状态码码含义1000正常关闭Normal Closure1001端点离开如页面关闭1002协议错误1003收到不支持的数据类型1006异常关闭保留码不会真正出现在线上帧里只用于本地表示连接断了1011服务端正忙/重启要点** Close 帧的包体前 2 字节是状态码大端 uint16之后才是可选原因文本**。双方都发 Close 才算完成关闭握手只发一次就直接RST关闭 TCP是不优雅的。2.4 消息分片Fragmentation大消息怎么切前面演示的文本帧FIN1表示一条消息一帧发完。但一条消息可能很大或边生成边发这时就要分片第一片 FIN0, opcode1(文本) 或 2(二进制) ← 声明这是条消息的开头 中间片 FIN0, opcode0(continuation) ← 续传 最后一片FIN1, opcode0(continuation) ← 续传 结束标记ASCII 示意把 “Hello World” 拆成两片发客户端 ──► [FIN0, opcode1, payloadHello ] 文本第一片未结束 客户端 ──► [FIN1, opcode0, payloadWorld] continuation 最后一片 服务端 ◄── 把两片按序拼成完整 Hello World 再交给应用铁律只有数据帧opcode 1 文本 / 2 二进制才允许分片控制帧8 关闭 / 9 Ping / A Pong必须FIN1单帧发完不得分片——否则接收方无法在控制帧之间插入别的帧会破坏控制帧优先的语义。分片是接收方的责任去重组中途若收到一个opcode1的新片表示上一条消息被丢弃/中断。我们的实验为了清晰刻意让每条消息FIN1单帧省去重组逻辑真实聊天/二进制流经常分片。2.5 协议在栈里的位置把 WebSocket 摆进网络栈看清它借 HTTP 握手、跑在 TCP 上┌──────────────────────────────────────────┐ │ 应用数据JSON / 二进制 │ ├──────────────────────────────────────────┤ │ WebSocket 帧FIN/opcode/MASK/len │ ← 本文逐字节拆解的层 ├──────────────────────────────────────────┤ │ TCP 握手后这条连接一直保持 │ ├──────────────────────────────────────────┤ │ IP │ └──────────────────────────────────────────┘ 升级路径HTTP 请求(80/443) ──101──► 之上直接跑 WS 帧 加密路径WSS WebSocket over TLS443TLS 插在 TCP 之上、WS 之下这也是为什么 nginx 反向代理透传 WS 时必须放行Upgrade/Connection——它本质是让这条 TCP 连接从 HTTP 语义切到 WS 语义。3. 三、掩码机制续密钥就在线上它真不是加密3.2补掩码密钥是明文传输的回看 2.1 的客户端帧81 8e [12 34 56 78] [密文]——12 34 56 78这 4 字节掩码密钥就在帧头里、明文跟着走。任何能抓包的人都能像我们那样密文 XOR 密钥一秒还原明文。所以请务必记住掩码MASK ≠ 加密Encryption · 目的防缓存代理污染让包体看起来随机 · 目的防窃听 · 密钥随帧明文传输零保密性 · 密钥经 TLS 协商保密 · 性能开销一次 XOR · 性能开销对称加密 · 提供方WebSocket 协议本身 · 提供方TLS即 WSS3.3补一个具体的缓存污染场景假设一台老旧透明代理看到它以为的HTTP 响应体就缓存无掩码的世界 客户端A 发 转账100给甲代理把它当响应缓存成 keyA 客户端B 发起同名请求代理直接把 A 的内容回给 B → 污染/错乱 有掩码的世界 客户端A 的发帧是 XOR 后的随机字节代理看不懂、也不会当成可缓存响应 → 即便误处理也只是一堆无意义的随机字节无法污染他人现代网络里这类老代理少了但 RFC 仍强制该规则手写客户端若漏设 MASK1合规服务端会立刻断连——这是我自己写的 WS 连不上的头号原因。4. 四、Ping/Pong 与 Close续4.3补子协议协商Sec-WebSocket-Protocol除了必填的Key握手还可以带子协议subprotocol用来让同一端口上的不同应用协议达成一致# 客户端我支持这两种应用子协议 Sec-WebSocket-Protocol: chat, superchat # 服务端我选 superchat并回显不回则客户端应断开 Sec-WebSocket-Protocol: superchat注意子协议不是WebSocket 本身的帧格式而是帧里面装的是什么应用语义的约定如graphql-ws、wamp。它和Sec-WebSocket-Version: 13一样是握手阶段一次定死的。4.4补控制帧的优先级规范规定控制帧Close / Ping / Pong可以插入到数据帧分片之间优先发送。例如正在收一个超大分片消息此时收到 Ping接收方应先回 Pong再继续收剩下的分片。这也是为什么控制帧禁止分片——它必须能见缝插针地完整发出。4.5 选型对比WebSocket / SSE / 长轮询 到底用谁实时通信不是只有 WS 一个选项按需不需要服务端主动推、单向还是双向来选方案方向底层重连/心跳典型场景长轮询半双工应答后断开再问HTTP自己实现老系统兼容、低频刷新SSEServer-Sent Events服务端→客户端单向HTTP长连接text/event-stream浏览器自动重连行情、通知、日志流WebSocket全双工双向HTTP 升级后跑 WS 帧需自己 Ping/Pong聊天、协作编辑、游戏、高频双向速选法则只需要服务端推、不需要客户端频繁发 → SSE更简单、自带重连 两端都要高频互发、低延迟 → WebSocket 无法升级协议、只要尽量新 → 长轮询兜底SSE 与 WebSocket 常被并列讨论SSE 本质是一条永远不关的 HTTP 响应所以天然走 HTTP/2 多路复用、自带断线重连WebSocket 则是一条独立长连接。生产里不要在同一域名下既大量用 SSE 又用 WS否则连接数会失控。5. 生产实践建议Checklist握手阶段确认返回101且Sec-WebSocket-Accept计算正确反向代理nginx要配置proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection Upgrade;才能把 WS 透传过去否则卡在握手。掩码铁律客户端帧必须MASK1服务端帧必须MASK0自己造轮子时这是头号坑。心跳必做在应用层或框架层启用 Ping/Pong 保活并设超时如 30s 无 Pong 即断避免半死连接占着资源。大消息分片超 125 字节要懂长度字段的 126/127 扩展生产框架如websockets、ws会自动处理分片别手撕。优雅关闭监听close事件区分1000正常与1006异常掉线做不同日志/重连策略。安全WS 明文易被窃听公网一律用WSSWebSocket over TLS并对消息做鉴权连接建立后第一时间校验 token别让任何人都能连。Nginx 透传配置示例location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # 关键透传 Upgrade proxy_set_header Connection Upgrade; # 关键把连接标为升级 proxy_read_timeout 3600s; # 长连接别被代理超时掐断 }6. 总结本篇把一次完整的 WebSocket 会话抓成原始字节并逐位解读握手GET带Upgrade: websocketSec-WebSocket-Key服务端回101Sec-WebSocket-Accept后者 Base64(SHA1(Key GUID))我们手算验证了一致。帧头字节0 的FINopcode、字节1 的MASKPayload len长度字段用 126/127 扩展。掩码客户端帧必须掩码MASK1密钥随帧明文传XOR 还原服务端帧禁止掩码掩码是防代理缓存污染不是加密。心跳Ping(0x9)→Pong(0xA)空包体、保活探活。关闭Close(0x8)带 2 字节状态码如1000正常双方各发一次完成握手。三篇博客走下来你已从报文结构 / 连接管理 / 状态码到Cookie / 同源 / 缓存 / 重定向语义再到WebSocket 全双工帧把 Web 协议的地基亲手摸了一遍。协议不再是你头顶的黑盒而是可以拆开、可以抓包、可以解释每一位的工程对象。说明本篇线缆字节在 lab 服务器s1公网119.3.254.184上用纯标准库最小实现抓取同机回环127.0.0.1:8765。执行期间该机曾因源 IP 被云厂商 HSS 风控临时不可达恢复后在 s1 重跑字节与前述完全一致。所有握手/帧字节均为真实程序输出未伪造且未包含任何密钥/口令。