16.1 协议 fuzz 与畸形包
前 15 章一直在假设一件事:客户端发来的字节是「讲道理」的。可一旦服务端暴露在公网上,这个假设立刻失效。任何人都可以拿一个 nc 连上端口,往里面灌任意字节——不是合法帧、不是合法命令号、甚至不是合法长度。
分帧解析器是整条链路上第一道、也是最危险的一道门。它运行在「还没验证过任何东西」的位置,处理的却是完全不可信的数据。如果它写错,后果不是「报个错」这么轻:一个解析器对畸形输入的每一点不小心,都会变成远程可触发的崩溃或内存耗尽。
这一节只做一件事:把 aegis 的帧解析器按卷一共享约定(固定头 11 字节 length(4,BE) | cmd(2,BE) | seq(4,BE) | flags(1),length = 7 + N,HeaderSize = 11,MaxPayload = 1 << 20)写出来,然后用 Go 原生 fuzzer 证明它对任意字节序列都安全。
与站内 practice 文章的分工
站内 /posts/game/server/practice/ 专题里的方案文章负责「架构与取舍」,本书负责「从零实现 + 真跑」。本节对应下面三篇,建议对照阅读:
| 既有文章 | 它回答的问题 | 本节补上的实现 |
|---|---|---|
game-server-security-architecture-for-online-games | 游戏服务端的安全边界该切在哪一层 | 只做「字节 → 结构体」这一步的输入校验 |
game-server-protocol-gateway-architecture | 协议网关的分层与职责 | 把网关最底层那个解析函数写成可 fuzz 的纯函数 |
message-protocol-versioning-for-game-servers | 多版本协议如何共存 | 本节只做单版本解析,版本协商见 3.3 |
分工的边界很清楚:方案文告诉你「网关该在哪拦」,本节告诉你「拦的时候那一行代码不能崩」。
解析器必须对任意字节序列安全
先把规则说死。一个好的分帧解析器,对任意 []byte 都必须满足三条:
- 不 panic。 任何越界切片、任何类型断言失败都不能冒出来。
- 不按声明分配。 绝不能因为头里写了
length = 4 GiB就make([]byte, 4<<30)。分配必须由「实际到达的字节数」而不是「对方声明的字节数」驱动。 - 要么给出一个消耗字节数,要么给出一个错误,绝不允许「悄悄吞掉」或「反复返回同一位置」导致调用方死循环。
第 2 条是畸形包最常打的地方。下面这段是 L1,go test 可以直接跑:
// file: internal/proto/frame.go
// tier: L1
// verify: go test ./internal/proto/
// Package proto 定义 aegis 线上协议的固定头与长度前缀分帧(卷一共享约定)。
package proto
import (
"encoding/binary"
"errors"
"io"
)
// HeaderSize 是固定头的字节数:length(4) + cmd(2) + seq(4) + flags(1)。
const HeaderSize = 11
// MaxPayload 是单个帧载荷的字节上限(1 MiB)。
//
// 长度字段来自网络,是不可信输入。没有上限时,一个声明「长度 = 4 GiB」的
// 畸形包就能让服务端按声明预分配巨量内存——这是最省事的 DoS。
const MaxPayload = 1 << 20
// 分帧错误。三者都是「拒绝这一帧」而不是「崩溃」:
// 解析器对任意字节序列都必须安全返回。
var (
ErrShortFrame = errors.New("proto: buffer shorter than fixed header")
ErrBadLength = errors.New("proto: length field below fixed overhead")
ErrPayloadTooLarge = errors.New("proto: payload exceeds MaxPayload")
)
// Cmd 是 16 位命令号:高字节是模块号,低字节是模块内序号。
type Cmd uint16
// Flags 是 8 位标志位:方向位与载荷处理方式各占几位。
type Flags uint8
// Frame 是一条已经切干净的完整消息。
type Frame struct {
Cmd Cmd
Seq uint32
Flags Flags
Payload []byte
}
// DecodeFrom 从 buf 头部解出一帧,返回帧与消耗的字节数。
//
// 数据不足一整帧时返回 io.ErrUnexpectedEOF 且消耗字节数为 0,调用方应保留
// 未消费的 buf 等下一次读取补齐,而不是丢弃它。
func DecodeFrom(buf []byte) (Frame, int, error) {
if len(buf) < HeaderSize {
return Frame{}, 0, ErrShortFrame
}
length := binary.BigEndian.Uint32(buf[0:4])
if length < uint32(HeaderSize-4) {
// length 比它必须覆盖的 cmd+seq+flags 还短,是畸形包。
return Frame{}, 0, ErrBadLength
}
payloadLen := int(length) - (HeaderSize - 4)
if payloadLen > MaxPayload {
return Frame{}, 0, ErrPayloadTooLarge
}
total := HeaderSize + payloadLen
if len(buf) < total {
return Frame{}, 0, io.ErrUnexpectedEOF
}
payload := make([]byte, payloadLen)
copy(payload, buf[HeaderSize:total])
return Frame{
Cmd: Cmd(binary.BigEndian.Uint16(buf[4:6])),
Seq: binary.BigEndian.Uint32(buf[6:10]),
Flags: Flags(buf[10]),
Payload: payload,
}, total, nil
}
注意三处顺序,它们就是三条规则在代码里的落点。第一,len(buf) < HeaderSize 的判断必须在任何 buf[0:4] 之前,否则截断的包会直接越界 panic。第二,length < HeaderSize-4 必须在算 payloadLen 之前——length 是 uint32,先减出一个负数再转 int 会得到巨大值。第三,payloadLen > MaxPayload 必须在 make 之前,且 make 的长度用的是 payloadLen(由声明推出、已过上限检查),而不是直接用 length。
畸形包的四个入口
按 §3.5 的帧格式,一个恶意帧能攻击的点恰好有四个。把它们列成一张表,写解析器时逐条对照:
| 攻击面 | 畸形输入 | 解析器若写错会怎样 |
|---|---|---|
| 固定头长度 | 只发 3 字节就停 | buf[0:4] 越界 panic |
length 下溢 | length = 3(小于固定开销 7) | 减出负 payloadLen,转 int 成巨大值 |
length 上溢 | length = 0xFFFFFFFF | 按声明 make 4 GiB,OOM |
flags 保留位 | 任意 8 位组合 | 若把某位当指针或索引解释,越界 |
四类里三类是「整数边界」,一类是「位域」。这也解释了为什么畸形包防御的本质是整数与边界的纪律,而不是什么高深算法——能挡住这四类的代码,通常也就几十行。
用 testing.F 把「任意字节」变成可复现的输入
手写几个畸形样例只能覆盖你想到的情况。Go 1.18 起标准库自带 fuzzing,testing.F 会从种子出发做变异,替你搜索想不到的输入。关键认知是:fuzzer 不需要你写断言「应该报什么错」,它只需要你写「成功时必须满足什么不变量」,剩下的交给它去撞 panic 和 OOM。
下面这段是 L1,go test 与 -fuzz 都能跑:
// file: internal/proto/fuzz_test.go
// tier: L1
// verify: go test -run=^$ -fuzz=FuzzDecodeFrom -fuzztime=5s ./internal/proto/
package proto
import (
"bytes"
"encoding/binary"
"io"
"testing"
)
// FuzzDecodeFrom 要求 DecodeFrom 对任意字节序列都不 panic、不无限分配。
// 断言只看成功路径的不变量:消耗字节数等于帧总长,且不超过输入长度。
func FuzzDecodeFrom(f *testing.F) {
good := make([]byte, HeaderSize)
binary.BigEndian.PutUint32(good[0:4], uint32(HeaderSize-4))
f.Add(good)
f.Add([]byte{})
f.Add([]byte{0x00, 0x00, 0x00, 0x05}) // 截断的固定头
f.Add([]byte{0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0, 0, 0, 0, 0x01})
f.Add([]byte{0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0x20})
f.Add(bytes.Repeat([]byte{0xff}, HeaderSize))
f.Fuzz(func(t *testing.T, data []byte) {
fr, n, err := DecodeFrom(data)
if err != nil {
return
}
if n != HeaderSize+len(fr.Payload) {
t.Fatalf("consumed %d != frame size %d", n, HeaderSize+len(fr.Payload))
}
if n > len(data) {
t.Fatalf("consumed %d > input %d", n, len(data))
}
if len(fr.Payload) > MaxPayload {
t.Fatalf("payload %d > MaxPayload", len(fr.Payload))
}
})
}
// TestDecodeRejectsMalformed 用表格覆盖四类畸形包。
func TestDecodeRejectsMalformed(t *testing.T) {
cases := []struct {
name string
in []byte
want error
}{
{"empty", nil, ErrShortFrame},
{"truncated_header", make([]byte, 5), ErrShortFrame},
{"length_below_overhead", []byte{0, 0, 0, 3, 0, 1, 0, 0, 0, 0, 0x20}, ErrBadLength},
{"oversized", []byte{0xff, 0xff, 0xff, 0xff, 0, 1, 0, 0, 0, 0, 0}, ErrPayloadTooLarge},
{"payload_not_arrived", []byte{0, 0, 0, 10, 0, 1, 0, 0, 0, 0, 0}, io.ErrUnexpectedEOF},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := DecodeFrom(tc.in); err != tc.want {
t.Fatalf("err = %v, want %v", err, tc.want)
}
})
}
}
// TestDecodeFlagsAllCombinations 遍历 256 种 flags,确认解析器只透传、不 panic。
func TestDecodeFlagsAllCombinations(t *testing.T) {
for fl := 0; fl < 256; fl++ {
buf := make([]byte, HeaderSize)
binary.BigEndian.PutUint32(buf[0:4], uint32(HeaderSize-4))
buf[10] = byte(fl)
fr, n, err := DecodeFrom(buf)
if err != nil {
t.Fatalf("flags=%#x: unexpected err %v", fl, err)
}
if n != HeaderSize || Flags(fl) != fr.Flags {
t.Fatalf("flags=%#x: n=%d got=%#x", fl, n, fr.Flags)
}
}
}
种子里的五条输入对应了五类最典型的畸形包:截断的固定头、全 0xFF(length 被解读成 4 GiB)、length = 3(比固定开销还短)、以及合法的最小帧。fuzzer 会围绕它们做字节级变异,包括改写 flags 的任意一位。
实测结论
以下是本机(Go 1.26,macOS arm64)实测,同机相对量级,绝对数值不可移植:
| 指标 | 实测值 | 说明 |
|---|---|---|
| 5 秒执行次数 | 约 150 万次(两次实测 154 万 / 183 万) | -fuzztime=5s,10 个 worker,随负载浮动 |
| 吞吐 | 约 25 万–47 万次/秒 | 大部分输入在 len(buf) < HeaderSize 就被挡回,成本极低 |
| 新增 interesting | 0 | 7 条种子已覆盖全部错误分支,无新路径 |
| 崩溃 | 0 | 全程无 panic、无 OOM |
两个结论值得记住。其一,「没有崩溃」本身就是结论:上百万次变异输入里没有一条让解析器 panic,说明三条规则确实封住了越界与超量分配。其二,吞吐高不代表覆盖全:每秒几十万次里绝大多数输入连固定头都不满,真正考验解析器的是那些长度字段恰好卡在边界(7、MaxPayload+7)的输入——这正是 fuzzer 相对手写样例的价值。
最后提醒一点:fuzzer 发现崩溃时会把最小化后的输入写进 internal/proto/testdata/fuzz/,那条文件应当连修复一起提交,成为永久回归用例,而不是修完就删。
阅读导航:上一节:目录 · 下一节:16.2 洪泛与连接耗尽防护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。