当前位置:首页 > 逆向工程 > 正文

WebSocket 帧怎么还原:18 个字节里,6 个是头,12 个被掩码过

约 4536 字 · 预计阅读 12 分钟
一句话总结:抓到一个 WebSocket 连接后,你看到的是一串二进制,不是 HTTP 那种能直接读的文本。这篇起了一个最小服务端抓真实帧,把一帧拆到字节级:2 字节基础头加 4 字节掩码密钥,剩下才是内容。掩码不是加密,但它会让所有直接 grep 的办法失效,这也是协议逆向里最容易卡住的一步。

一、先搞清楚它和 HTTP 的差别

HTTP 抓包能直接看懂,因为报文是文本:GET /api/user HTTP/1.1 一行一行摆在那儿。

WebSocket 只有握手那一下是 HTTP。101 之后,双方走的是二进制帧,一个字节一个字节地传。你抓到的原始数据长这样:

81 8c 11 22 33 44 79 47 5f 28 7e 02 40 21 63 54 56 36

看不出任何东西。这 18 个字节里,"hello server" 藏在后面。

想读懂它,得知道帧的格式。这份格式定义在 RFC 6455 里,不长,但有几个细节必须先搞对。

二、前置条件

Python 3.9 以上,标准库。不需要 websocket 库,因为我要拆的就是它封装掉的东西。

我写了个 100 多行的脚本:起一个本地服务端,完成握手,收客户端发来的真实帧,然后按 RFC 拆开打印。

三、握手还是 HTTP

客户端发的第一件事跟普通请求没区别:

GET /chat HTTP/1.1
Host: 127.0.0.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: MDEyMzQ1Njc4OWFiY2RlZg==
Sec-WebSocket-Version: 13

服务端回 101,带上 Sec-WebSocket-Accept。这个值是把客户端的 Key 拼上一个固定字符串、算 SHA-1、再 base64,用来证明服务端真的懂这个协议,不是随便回了个 101。

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()

这一步逆向上没什么价值,但如果你要写客户端模拟器,它绕不过去。

四、帧的结构,字节级

101 之后开始走帧。一帧的头部最少 2 个字节。

WebSocket 帧怎么还原:18 个字节里,6 个是头,12 个被掩码过

图 1:真实抓到的第一帧,81 8c 开头,后面 4 个字节是掩码密钥

第一个字节拆两半。 最高位是 FIN,表示这是不是消息的收尾帧;接着 3 位 RSV,留给扩展用,默认全 0;低 4 位是 opcode,标类型。常用的几个:0x1 文本、0x2 二进制、0x8 关闭、0x9 ping、0xA pong。

0x81 拆开就是 1000 0001:FIN=1,RSV=000,opcode=1(文本)。

第二个字节也拆两半。 最高位是 MASK,标这个帧有没有加掩码;低 7 位是长度。这个位置是个机关:低 7 位只能表示 0 到 127,超过就得借位。

  • 长度小于 126,直接写在这个位置
  • 等于 126,后面跟 2 个字节表示真实长度
  • 等于 127,后面跟 8 个字节表示真实长度

0x8c 拆开是 1000 1100:MASK=1,长度 12。

掩码密钥 4 个字节。 只在 MASK=1 时存在。客户端发往服务端的帧必须加,反过来不加。

载荷。 剩下的字节,加了掩码的要逐字节异或回去。

五、掩码不是加密,但会挡住所有直觉做法

这段是我认为逆向时最该理解的。

RFC 里写明,掩码的目的不是保密,是防止缓存投毒。中间有代理服务器的时候,攻击者可以构造一段看起来像 HTTP 请求的 WebSocket 载荷,骗代理缓存下来。给每个客户端帧加一个随机的 4 字节掩码,就让载荷没法被预测,这个攻击就不成立了。

所以掩码的强度很低,密钥就 4 个字节而且明文写在帧里。它不是给你防的,是给协议防的。

但对做分析的人来说,它足够挡住三件事:直接字符串搜索找不到内容、肉眼看不出来、按文本假设写的解析器全部失效。

解掩码很简单,循环异或:

if masked and key:
    payload = bytes(b ^ key[i % 4] for i, b in enumerate(raw))

拿真实数据算一遍:0x79 ^ 0x11 = 0x68,是字符 h;0x47 ^ 0x22 = 0x65,是 e;0x5f ^ 0x33 = 0x6c,是 l。三个字节解出来就是 "hel"。

站里之前写过从乱码里逆出 XOR 密钥,讲的是密钥未知怎么爆破。这里是密钥已知(就在帧里),只是要按位置循环用它。同一个运算,两种场景。

六、实测输出

--- 客户端发来的第一帧(带掩码) ---
原始字节:81 8c 11 22 33 44 79 47 5f 28 7e 02 40 21 63 54 56 36
FIN=1 RSV=0 opcode=0x1 TEXT
MASK=1 key=11223344 长度=12
载荷:hello server
整帧占用 18 字节

12 字节的内容,实际占了 18 字节。头部开销 6 字节,占了三分之一。

这个比例在小消息上很显著。如果你的场景是高频发小消息(心跳、状态同步),协议开销会吃掉相当一部分带宽。这不是缺陷,是设计取舍:换来的是双向、低延迟、无需重复握手。

再对照两种长度:

--- 短文本(长度 < 126) ---
原始字节:81 82 11 22 33 44 7e 49
FIN=1 RSV=0 opcode=0x1 TEXT
MASK=1 key=11223344 长度=2
载荷:ok
整帧占用 8 字节

--- 长文本(走 126 扩展长度) ---
原始字节:81 fe 00 c8 11 22 33 44 69 5a 4b 3c ...
FIN=1 RSV=0 opcode=0x1 TEXT
MASK=1 key=11223344 长度=200
整帧占用 208 字节

长帧那个开头是 81 fe 00 c8:0xfe 的低 7 位是 126,所以走扩展长度,紧接着的 00 c8 就是 200。

七、坑清单

按踩到的概率排。

1. 一个 recv 可能拿到多帧,也可能只有半帧。 TCP 是流,不是消息。我发了两条消息,服务端一次 recv 只拿到第一条,第二条还在缓冲区。解析必须循环处理,还要处理"拆了一半"的情况:把剩余字节留在缓冲区,等下个包拼起来再解。

2. 把 MASK 位当成长度的一部分。 第二个字节的高位和低 7 位是两个字段,直接读整字节会得到 140 这种不可能的长度。

3. 忘了服务端发来的帧没有掩码。 逆向时如果两边都按有掩码解析,服务端返回的内容会全变成乱码。掩码是单向的。

4. 忽略 opcode 直接全当文本解。 ping/pong 帧(0x9/0xA)的载荷可能是空的也可能是任意字节,close 帧(0x8)的前两个字节是状态码。按 UTF-8 硬解会抛异常。

5. 没处理分片。 FIN=0 表示后面还有帧,opcode=0 表示续帧。一条大消息会被拆成多个帧,不拼起来就是断的。

6. 压缩扩展被忽略。 服务端可能协商了 permessage-deflate,这时候载荷是 deflate 压缩过的。解完掩码还得解压,直接看是乱码。

八、边界

这套东西只够读懂明文协议。

如果业务层在帧里又套了一层加密(不少即时通讯产品这么做),解完 WebSocket 帧你还得面对第二层,那就得去客户端里找密钥,工作量上了一个量级。

还有 permessage-deflate 我没在脚本里处理,遇到协商了压缩的连接,需要额外解压一步。

另外,这个脚本是个最小实现,没做完整的握手校验、没处理控制帧、没实现分片重组。它能证明格式,不能直接当生产库用。

站里之前写过协议逆向入门,讲的是 HTTP 接口的签名怎么还原。这篇处理的是另一层:当协议根本不以文本形式出现时,你从哪里开始拆。

参考资料

目录导航
  • 一、先搞清楚它和 HTTP 的差别
  • 二、前置条件
  • 三、握手还是 HTTP
  • 四、帧的结构,字节级
  • 五、掩码不是加密,但会挡住所有直觉做法
  • 六、实测输出
  • 七、坑清单
  • 八、边界
    • 参考资料
  • 相关文章:

  • 从零写一个真能用的 Skill:目录、脚本、验证,外加一次被自己工具打脸的迭代
  • 照着 DeepSeek 的 Super-agent 写个最小版:Router 分流、两级过滤、Worker 并行
  • 从乱码里逆出 XOR 密钥:256 次爆破的实战,和一条会骗人的捷径
  • 交付前先测 Agent 的三件坏事:越权、编出处、谎报完成
  • 主动型 Agent 怎么自己写:定时唤醒、草案入队、审批后才动手
  • 协议逆向入门:从抓包到重放,把接口的签名还原出来
  • Playwright 实战:登录、翻页、截图对比,三件最常做的事
  • 写一个最小的 Agent 循环:80 行代码,和三个必须处理的边界
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。