深入记录协议:细节中的魔鬼
TLS 握手协商完密码套件之后,真正的数据传输就靠记录协议了。很多人觉得它不就是个封装吗?没那么简单。我记得刚接触 TLS 堆栈实现时,以为把数据包直接塞进加密通道就行,结果抓包一看,各种解析失败。原因就在我完全忽略了记录层的分段和 MAC 校验逻辑。
今天我们就来把记录协议从上到下扒一遍。说实话,这是 TLS 全栈里最容易被低估的一个环节。你想想看,它既要处理应用层传下来的任意长度数据,又要保证加密后不能被篡改,还得在握手阶段动态切换加密状态 —— 这就是个精细活。
4.1 记录层到底承载了什么
记录协议不是直接传输应用数据的。它位于握手协议、报警协议、Change Cipher Spec 协议的下方,给所有这些高层协议提供统一的传输服务。说白了,TLS 握手的每一次消息交换,最终都是通过记录协议封装后发出去的。
我见过不少开发者把记录层只当作“加密 + 传输”的简单封装,其实它做了四件事:
- 分片 —— 把应用数据切成合适的大小块
- 可选的压缩 —— 压缩后再加密(尽管现在基本不用)
- 添加 MAC —— 完整性校验,防止传输中被篡改
- 加密 —— 保证机密性
这四步顺序是严格固定的。不能乱,颠倒一步就废了。我曾经在定制化嵌入式 TLS 实现里,把加密和 MAC 的顺序搞反了一次,结果花了整整两天才定位到问题 — 所有数据都能正常加解密,但接收方永远报 bad_record_mac 错误。
4.2 记录格式:五个字节的头
每条 TLS 记录都有一个固定的 5 字节头部。嗯,这里很重要,抓包分析时你必须对这几个字节了然于胸。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| Content Type | 1 | 标识上层协议类型:20=ChangeCipherSpec,21=Alert,22=Handshake,23=Application |
| Protocol Version | 2 | 主版本号 + 次版本号。比如 TLS 1.2 是 0x0303 |
| Length | 2 | 后续载荷的字节长度(不包括头部本身),最大 2^14 字节 |
我习惯把这三个字段统称为记录头的“三板斧”。其中 Length 字段有个硬限制:不能超过 16384 字节(2^14)。为什么定这个值?我个人觉得是为了防止接收方一次性分配过大的内存缓冲区,降低拒绝服务攻击的风险。
但要注意,Length 只是记录层载荷的长度。如果应用层想发送 100KB 数据,单条记录装不下,就得靠分片处理了。
4.3 分片与重组:打散与拼接的艺术
应用层往下写数据时,记录协议不会直接原封不动地塞入记录体。它先判断数据长度:
- 如果数据 ≤ 2^14 字节:直接封装成一条记录。
- 如果数据 > 2^14 字节:必须拆成多个分片记录,每个不超过 2^14 字节。
为什么这么设计?一方面避免单条记录撑爆接收缓冲区,另一方面配合底层的 MTU 问题。我记得有一次调试一个移动设备上的 HTTPS 慢速问题,抓包发现很多 TLS 记录碎片化严重,每条才几百字节。后来我把应用层数据分批写入,让记录层的分片更整一些,性能一下上来了。
接收方怎么做重组?根据 Content Type 和序列号隐式关联。注意,记录协议本身不在头部标注分片的序列号,而是靠 TLS 连接状态中的序列号字段来区分先后顺序。这一点跟 TCP 分片的重组逻辑不太一样,得留个心。
4.4 压缩:曾经的优化,现在的鸡肋
TLS 1.2 及之前版本支持在记录层做数据压缩。流程是:分片之后 → 压缩 → 添加 MAC → 加密。
压缩的好处显而易见:减少加密后传输的数据量。但为什么现在基本没人用?
- 安全漏洞:CRIME 攻击利用 TLS 压缩中的明文长度变化推测秘密内容。说白了,敌人可以通过观察压缩后的包大小反推出加密前的数据模式。
- 计算开销:压缩本身消耗 CPU,在移动设备上尤其不划算。
- TLS 1.3 直接移除:2018 年发布的 TLS 1.3 干脆砍掉了压缩支持。
如果你的系统还跑在 TLS 1.2 且开启了压缩,我建议立刻关闭。这是一个典型的“为了提高一点点性能而牺牲安全”的决策,不值得。
4.5 MAC:完整性校验的最后防线
加密只能保证数据不被偷看,不能保证数据不被篡改。这就是 MAC(消息认证码)存在的原因。
TLS 1.2 使用 HMAC(基于哈希的消息认证码)。计算方式有些特别:它把序列号、记录头、明文数据和填充长度一起算进去。为什么要加序列号?为了防止重放攻击。你想想看,如果攻击者把之前的一条合法记录原封不动重放一遍,接收方拿它当新数据去处理,后果可能很严重。序列号的存在让每一条记录都是唯一的,重放的记录在 MAC 校验阶段就会被拒绝。
我记得调试过一个诡异的报错:某些数据包总是在对端解析时报 bad_record_mac。查了两小时发现是我们的加密实现里,MAC 计算时漏掉了 record header 中的 5 个字节。这个错误相当隐蔽,因为大部分数据走通,唯独某些边界条件下的几字节偏移导致了 MAC 验签失败。
4.6 加密与记录序列号
加密发生在 MAC 添加之后。对于 AES-CBC 模式:
加密内容 = 明文 + MAC + 填充(padding)
填充是必须的,因为块加密算法要求输入长度是块的整数倍。填充的格式有严格规范:填充内容 = 填充长度 - 1,且填充长度本身至少为1。举例来说,如果填充了 5 个字节,就用 0x04, 0x04, 0x04, 0x04, 0x04。为什么不是 0x05? 因为填充长度也算在填充字节里。
序列号我就不多展开了,只说一点:它是 64 位无符号整数,从 0 开始逐条递增,每条记录一个。当序列号达到 2^64 - 1 时,连接必须断开。我在生产环境中确实见过连接因为序列号溢出而中断的案例 — 虽然概率极低,但也提醒我们长连接的安全管理不能只看密钥。
好,下面这张图总结了记录协议的完整流程:
从图中可以清楚看到:发送时从应用数据开始,经过分片、压缩、MAC、加密、加头,最终进入 TCP。接收方则反向操作:先去掉记录头、解密、校验 MAC、解压、然后按照 Content Type 重组后交给上层。
4.7 关于记录层的一个小总结
记录协议是 TLS 中被忽视最多但出问题最多的地方。究其原因,是它对每一条记录都做了严格的格式、长度、顺序和校验控制。任何一个环节的偏移、遗漏或顺序颠倒都会导致握手失败或数据传输中断。
我给你三个实操建议:
- 开发阶段一定要开启抓包:用 Wireshark 检查记录层每个字段,跟规范对照。我通常会在握手和数据传输两个阶段都抓包,因为我发现很多 bug 只有在数据传输阶段才暴露。
- MAC 和加密的顺序不能错:先 MAC 后加密是标准流程。某些自研栈为了简化实现搞反了顺序,结果在互操作性测试中直接挂了。
- 分片不要超出 2^14 限制:这是硬性规定。即使你底层 TCP 缓冲区够大,TLS 规范也不允许单条记录载荷超过 16384 字节。超出部分会被对端丢弃或导致连接重置。
记录协议的设计核心是 拆分封装 + 分层校验。看似复杂,但每一步都有明确的安全考量。下节课我们会在此基础上深入握手协议,那是 TLS 安全架构真正精彩的章节。