《MMO 服务器开发》16.1 协议 fuzz 与畸形包

长度字段来自网络,是不可信输入:一个声明「长度 = 4 GiB」的畸形包就能让服务端按声明预分配巨量内存。本节用 Go 原生 testing.F 给 internal/proto 的帧解析写一个 fuzzer,覆盖长度越界、截断、超长与 flags 组合,讲清「解析器必须对任意字节序列都不 panic、不无限分配」,并给出本机 5 秒百万级执行的实测。

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 都必须满足三条:

  1. 不 panic。 任何越界切片、任何类型断言失败都不能冒出来。
  2. 不按声明分配。 绝不能因为头里写了 length = 4 GiB 就 make([]byte, 4<<30)。分配必须由「实际到达的字节数」而不是「对方声明的字节数」驱动。
  3. 要么给出一个消耗字节数,要么给出一个错误,绝不允许「悄悄吞掉」或「反复返回同一位置」导致调用方死循环。

第 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 就被挡回,成本极低
新增 interesting07 条种子已覆盖全部错误分支,无新路径
崩溃0全程无 panic、无 OOM

两个结论值得记住。其一,「没有崩溃」本身就是结论:上百万次变异输入里没有一条让解析器 panic,说明三条规则确实封住了越界与超量分配。其二,吞吐高不代表覆盖全:每秒几十万次里绝大多数输入连固定头都不满,真正考验解析器的是那些长度字段恰好卡在边界(7、MaxPayload+7)的输入——这正是 fuzzer 相对手写样例的价值。

最后提醒一点:fuzzer 发现崩溃时会把最小化后的输入写进 internal/proto/testdata/fuzz/,那条文件应当连修复一起提交,成为永久回归用例,而不是修完就删。

阅读导航:上一节:目录 · 下一节:16.2 洪泛与连接耗尽防护 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标