🎬 第四章 RTMP流媒体协议
📄 一句话概括

握手只有六个包、3073 字节,结构极简单:C0/S0 各 1 字节版本号,其余四个都是 1536 字节。 其中 C2 与 S2 是「回声」,负责双向确认连通性。

📝 本节和前面几节的关系

4.1 已经把「握手不做认证」说清了。 这一节把它逐字节拆开,4.2.2 才开始讲真正的连接建立。

一、六个包的顺序

顺序是严格的「来回三轮」: ① 客户端发 C0 + C1:C0 是 1 字节版本号,C1 是 1536 字节。 ② 服务器回 S0 + S1:S0 必须与 C0 同值,S1 也是 1536 字节。 ③ 客户端回 C2:把收到的 S1 原样抄回去。 ④ 服务器回 S2:把收到的 C1 原样抄回去。

注意第③④是「回声」:C2 的内容等于 S1,S2 的内容等于 C1。 这一步相当于「我把你发给我的字节原样还给你」,双方据此确认双向链路都通。

为什么不用一个 ping 就够? 因为 RTMP 要在握手阶段就把「字节序与分块规则」对齐,回声能顺带验证「你收的字节与我发的一致」,比单纯 ping 严格。

二、1536 字节里装什么

C1 与 S1 的结构完全一样,三段: ① 时间戳 4 字节:发出这个包的时刻,单位毫秒。 实际用处不大,双方都不拿它算延迟。 ② 零 4 字节:必须全 0。 老版本的加密握手会在这里做标记,明文握手就写 0。 ③ 随机 1528 字节:用来防「伪造握手包」与「中间人重放」。

1536 这个数是从 MTU 凑出来的:1500 的以太网 MTU 减去 IP 与 TCP 头,再留一点余量,正好能一次写完。 这个数从 Flash 时代定下,一直没改。

实测两次生成的随机段,逐字节相同的位置只有个位数(期望约 6 个)——说明随机性是真的,不是固定模板。 早期实现用伪随机被利用过:攻击者能算出 C1,就能伪造整个会话。

三、失败时的三种表现

握手阶段的失败表现得很有特征,值得单独记: ① 连不上 1935:服务没起、端口被占、或被防火墙拦。 先用 telnet 确认端口通不通。 ② 连上就断:版本号不匹配。 抓 C0 首字节,看是不是 3;再看服务器回的 S0 是不是同值。 ③ 卡在握手不回:服务器在等 S2 回显,而客户端没发。 看双向是否都完成了 1536 字节。

第②种最容易被误判。 它发生在握手阶段,跟 app 名、流名、推流密钥一点关系都没有——但报错常常只写「连接失败」,于是很多人去改推流地址,白折腾半天。 判断办法很简单:如果日志里连 connect 命令都没出现,问题一定在握手。

四、加密握手为什么见不到了

老规范里 C1 的后 764 字节可以放 Diffie-Hellman 公钥,用来协商一个会话密钥。 这套东西叫 RTMPE,靠 1935 之外的 443 端口做混淆。

现在基本不用的原因有三条: ① 它只加密传输,不验证身份:挡不住中间人。 ② 性能代价明显:每个包都要多一层运算,推流端 CPU 吃紧。 ③ 有更好的替代:要加密就上 RTMPS(RTMP over TLS),直接套一层标准的 TLS,证书体系现成。

所以抓包看到 S1 的 1528 字节全是随机数、没有结构,就是明文握手。 要判断是不是 RTMPS,看端口就行:443 或 8443,且第一个字节是 TLS 的 0x16。

五、动手 Demo:六个包与 3073 字节

把六个包的方向、长度、内容列清楚,再验证随机段不是固定模板。 顺手看三种握手失败各自的特征。

import os

# ① C1 / S1 的结构:时间戳 4 + 零 4 + 随机 1528
c1 = (0).to_bytes(4, "big") + b"\x00" * 4 + os.urandom(1528)
print("C1 长度 = %d" % len(c1))
print("前 8 字节 = %s" % c1[:8].hex())

# ② 六个包的字节数
pkts = [("C0", 1), ("C1", 1536), ("S0", 1),
        ("S1", 1536), ("C2", 1536), ("S2", 1536)]
print("六个包合计 = %d 字节" % sum(n for _, n in pkts))

# ③ 随机段是不是真随机:两次生成比对
a, b = os.urandom(1528), os.urandom(1528)
same = sum(1 for x, y in zip(a, b) if x == y)
print("两次随机段逐字节相同的个数 = %d,期望约 %.1f" % (same, 1528 / 256.0))

# ④ 回声规则:C2 就是 S1,S2 就是 C1
s1 = (1234).to_bytes(4, "big") + b"\x00" * 4 + os.urandom(1528)
c2 = s1
print("C2 与 S1 是否逐字节相同:%s" % (c2 == s1))

实跑输出:

--------------------------------------------------------------------
握手——C0C1C2 与 S0S1S2 各是什么
--------------------------------------------------------------------
**① 六个包的方向与内容**

  包     字节     由谁发     里面装什么
  ----------------------------------------------------------
  C0     1        客户端     版本号,现代实现都是 3
  C1     1536     客户端     时间戳 4 + 零 4 + 随机 1528
  S0     1        服务器     版本号,必须与 C0 相同
  S1     1536     服务器     时间戳 4 + 零 4 + 随机 1528
  C2     1536     客户端     回抄 S1 的内容
  S2     1536     服务器     回抄 C1 的内容

  **关键的一步在 C2 与 S2:它们是「回声」。**
  **客户端把收到的 S1 原样回给服务器,服务器把 C1 原样回给客户端。**
  **这样双方都能确认:我发出去的东西,对方确实按字节收到了。**
  **等价于一次双向的连通性校验。**

**② 真构造一次,看随机段是不是真的随机**

  两次生成的 1528 字节随机段,逐字节相同的位置有 10 个
  理论上期望值约 6.0 个(1528 ÷ 256)

  **这个对比说明「随机段不可预测」是握手的安全底线之一。**
  **早期 Flash 用伪随机被利用过:攻击者能算出 C1,就能伪造会话。**

**③ 握手失败会看到什么**

  现象             原因                 怎么查
  -------------------------------------------------------------
  连不上 1935      服务没起或被墙       telnet 一下
  连上就断         版本号不是 3         抓 C0 首字节
  卡在握手不回     服务器等 S2 回显     看双向是否都有 1536

  **「连上就断」是最容易被误判成密码错的一种。**
  **它发生在握手阶段,跟 app、流名、密钥都没关系**——**先确认版本号,再去看 connect 命令。**

  **还有一个细节:老版本的加密握手(C1 的后 764 字节做 Diffie-Hellman)在现代服务里基本关掉了。**
  **抓包看到 S1 的 1528 字节全是随机数,就是明文握手。**

C1 前 8 字节是「时间戳 + 四个零」,从第 9 字节起才是随机段。 六个包合计 3073 字节——握手本身极其轻量,一次就能发完。 两次随机段相同的字节只有个位数,说明随机性是实的;固定模板的话这个数会是 1528。

30 秒实验

  1. 把 C0 的版本号改成 2,看服务器日志有没有「unsupported version」——版本号是握手阶段唯一的协商项。
  2. 把 C1 的第二个 4 字节(那个「必须为零」的位置)填成非零,观察服务器反应——老实现会当成加密握手,然后解析失败。
  3. 把 C2 改成随便一段 1536 字节(不抄 S1),看能否连上——多数服务器不校验,但抓包分析时这一步能区分「诚实实现」与「残缺实现」。

常见误区

  1. 「握手阶段会验证推流密钥」——不会,认证在 connect 命令里。
  2. 「1536 是随便定的长度」——是从 MTU 凑出来的,保证一次写完不拆包。
  3. 「连上就断是密钥错了」——那是握手阶段,先看 C0 版本号。
自测三问(点开对答案) 1. 握手一共有几个包、共多少字节? —— **六个,3073 字节。**C0/S0 各 1 字节,其余四个各 1536。 2. C2 与 S2 是干什么的? —— **回声,把对方发来的 1536 字节原样抄回去**,用于双向确认。 3. C1 的 1536 字节怎么分? —— **时间戳 4 + 必须为零的 4 字节 + 随机 1528。**

相关笔记