《游戏服务端编程实践》18.2 封包验证与篡改检测

从长度、消息类型、校验和与序号四个层次拆解封包验证,解释非法包为什么必须直接断开而不是继续解析,并用可运行的 Go 解码器演示重复包、序号越窗、向前跳跃与校验和错误的拒绝逻辑,附单连接限速与解析失败封禁策略,最后给出把计数接入限流、封禁与证据留存的处置建议。

一、先校验,再解析

封包验证的第一原则是:在确认边界之前,不解释任何字段。客户端发来的长度字段只是“声明”,不是事实;消息类型只是“建议”,不是授权;校验和只是“提示”,不是信任凭据。解析器必须先证明“这一帧的字节数与声明一致、内容没有被随机破坏、类型在允许范围内”,然后才允许业务代码看到负载。

检查项位置拒绝原因失败后的动作
固定头长度最前 14 字节连包头都不完整断开连接
魔数头 0–1 字节不是本协议流量断开并记录来源
长度字段头 8–9 字节超过上限或与实际字节数不符断开,不尝试重新同步
CRC32头 10–13 字节传输损坏或被随意篡改断开并计数
消息类型头第 2 字节不在白名单内断开并计数
序号头 4–7 字节重复、过旧或跳跃过大按策略断开或丢弃
单连接速率连接级超过令牌桶容量限流,持续超限则封禁

CRC32 只适合发现随机位翻转,不能替代签名。攻击者可以修改内容后重新计算 CRC32,所以关键消息仍然需要 18.1 里的 HMAC。CRC 与 HMAC 的分工是:CRC 在解码早期快速丢弃损坏帧,HMAC 在业务层确认消息来源与完整性。两者都做,成本才可控。

二、失败场景:信任长度字段的解析器

某网关的读取循环先读 2 字节长度,再按长度分配缓冲区。长度字段来自客户端,代码只检查了“大于 0”,没有检查上限,也没有检查后面是否真的还有这么多字节。攻击者发送一个声明长度为 65535、实际只有 10 字节的帧,网关的读取逻辑要么阻塞等待剩余字节,要么在超时后把半帧交给下一个解析步骤,协议边界从此错位。

时间客户端行为服务端现象
T+0发送声明 65535 字节的短帧读取循环等待剩余字节
T+1s不再补发数据连接占用读缓冲,超时计时开始
T+2s同时建立大量类似连接读 goroutine 与内存上涨
T+5s混合发送合法帧半帧与合法帧粘连,解析错位
T+10s触发重连接入层连接数进一步上升

正确的做法是先检查“声明长度 ≤ MaxPayload”,再检查“当前缓冲区是否恰好包含一个完整帧”。长度不符时直接断开,不做“跳过若干字节继续找魔数”的猜测。继续同步等于把解析边界交给攻击者,任何偏移错误都会变成越界读或业务字段错位。

2.1 粘包与半包不是 TCP 的错

TCP 是字节流,不保留消息边界。一次 Read 可能拿到半个包,也可能拿到三个包。解析器必须自己维护缓冲区,按“读头 → 算长度 → 等够 → 切帧”的循环处理。把“一次 Read 就是一个包”当成前提,在低负载测试里可能碰巧成立,一到真实网络就会暴露。UDP 虽然保留消息边界,但仍有截断、重复、乱序和伪造,验证逻辑不能省。

三、序号、重复包与乱序窗口

序号解决三类问题:重复包、过期包和明显的伪造跳跃。接收端维护“已见到的最高序号”和窗口内的位图。新序号大于最高值时,窗口向前滑动;小于等于最高值时,落在窗口内就查位图判断重复,落在窗口外就视为过旧。窗口大小决定能容忍多少乱序,也决定位图的内存占用。

场景序号关系处理理由
正常递增seq > highest,差距小接受并滑窗常规路径
轻微乱序highest-window < seq < highest查位图,未见过则接受网络抖动允许有限乱序
重复包seq 在窗口内且位图已置位拒绝防止重复执行业务
过旧包highest - seq ≥ window拒绝无法判断是否重复,且已无业务价值
向前跳跃过大seq - highest > maxForwardGap拒绝可能是探测、注入或状态错乱

向前跳跃的上限需要按传输层特性调整。可靠有序的 TCP 上,序号本应连续,跳跃过大通常意味着客户端异常;丢包严重的 UDP 上,跳跃是正常现象,上限应放宽,或者干脆只做重复检测,把“是否可信”交给签名和业务校验。无论怎么调,都不要把窗口设成无限大:没有边界的位图无法实现,没有上限的容忍等于放弃检测。

3.1 序号不能替代签名

序号只证明“这条连接上大概按顺序收到了一个包”,不能证明“这个包是合法客户端发的”。攻击者可以在自己的连接里从任意序号开始,也可以伪造一段连续序号。把序号校验当成安全边界,只能挡住最简单的重放脚本。真正需要防重放的关键消息,仍然要用 nonce、时间窗和 HMAC,序号只是第一道便宜的过滤。

四、频率、体积与解析失败

单包合法不代表连接合法。一个连接每秒发一万条合法的小包,同样可以把业务服打满;一个连接反复发送校验和错误的包,可能是在探测解析器边界。服务端需要按连接统计消息速率、平均包体、解析失败次数,并设置明确的处置阈值。

指标计算方式异常信号处置
消息速率令牌桶或每秒计数持续超过正常玩家数倍限流,观察后封禁
单包大小解码后的负载长度长期贴近 MaxPayload记录并抽样检查
解析失败率失败数 / 接收数短时间内快速上升达到阈值直接封禁
类型分布按消息类型计数大量未授权类型断开并更新黑名单
连接时长建连到断开极短连接反复重连按 IP 或设备维度限速

阈值必须来自正常玩家的分布,而不是拍脑袋。可以先记录一周的分位数,把限流线放在 p99.9 之上,把封禁线放在明显不可能达到的位置。阈值还要允许活动、跨服战等特殊时段临时调整,否则正常玩家会在高峰期被误杀。

五、可运行实现:带序号与校验和的解码器

下面的解码器把头部固定为 14 字节:魔数、类型、标志、序号、长度和 CRC32。解码顺序与本节开头一致;序号用 64 位位图记录窗口内重复包;向前跳跃上限可配置,默认等于窗口。Decode 返回的 Payload 直接引用传入的 frame,调用方必须在缓冲区被复用前用完或自行复制。

// file: ch18/packet/packet.go
// tier: L1
// verify: go test ./ch18/packet/

// Package packet 演示带长度、消息类型、序号与校验和的封包编解码,
// 以及单连接速率与解析失败计数。
//
// 解码器对任何非法输入都返回错误,调用方应当直接断开连接,
// 而不是跳过若干字节后继续解析。继续解析会把协议边界交给攻击者。
package packet

import (
	"encoding/binary"
	"errors"
	"hash/crc32"
	"sync"
)

const (
	// Magic 是协议魔数,用于快速丢弃不属于本协议的字节流。
	Magic uint16 = 0x4753 // "GS"
	// HeaderSize 是固定包头长度:magic(2)+type(1)+flags(1)+seq(4)+length(2)+crc32(4)。
	HeaderSize = 14
	// MaxPayload 是单包负载上限,长度字段超过它直接拒绝。
	MaxPayload = 4096
	// maxWindow 是序号窗口上限,内部用 uint64 位图记录重复包。
	maxWindow = 64
)

var (
	// ErrShortFrame 表示字节数不足一个固定包头。
	ErrShortFrame = errors.New("packet: frame shorter than header")
	// ErrBadMagic 表示魔数不匹配。
	ErrBadMagic = errors.New("packet: bad magic")
	// ErrLength 表示长度字段与实际字节数不一致。
	ErrLength = errors.New("packet: length mismatch")
	// ErrTooLarge 表示负载超过 MaxPayload。
	ErrTooLarge = errors.New("packet: payload too large")
	// ErrChecksum 表示 CRC32 校验失败。
	ErrChecksum = errors.New("packet: checksum mismatch")
	// ErrUnknownType 表示消息类型不在白名单内。
	ErrUnknownType = errors.New("packet: unknown message type")
	// ErrDuplicate 表示序号已经在窗口内出现过。
	ErrDuplicate = errors.New("packet: duplicate sequence")
	// ErrTooOld 表示序号落后超过窗口,无法判断是否重复。
	ErrTooOld = errors.New("packet: sequence outside window")
	// ErrSeqJump 表示向前跳跃过大,可能被用于探测或注入。
	ErrSeqJump = errors.New("packet: forward sequence jump too large")
	// ErrRateLimited 表示单连接消息速率超过令牌桶容量。
	ErrRateLimited = errors.New("packet: rate limited")
	// ErrBanned 表示连接已因异常计数被封禁。
	ErrBanned = errors.New("packet: connection banned")
)

// Packet 是解码后的业务包。Payload 引用调用方传入的 frame,
// 调用方在下次读取前必须用完或自行复制,避免缓冲区被覆盖。
type Packet struct {
	Type    uint8
	Flags   uint8
	Seq     uint32
	Payload []byte
}

// Encode 按协议格式序列化一个包。
//
// 编码端不检查消息类型白名单,白名单属于接收端的策略;
// 但长度上限在这里就拒绝,避免生成无法被自己解码的包。
func Encode(msgType, flags uint8, seq uint32, payload []byte) ([]byte, error) {
	if len(payload) > MaxPayload {
		return nil, ErrTooLarge
	}
	frame := make([]byte, HeaderSize+len(payload))
	binary.BigEndian.PutUint16(frame[0:2], Magic)
	frame[2] = msgType
	frame[3] = flags
	binary.BigEndian.PutUint32(frame[4:8], seq)
	binary.BigEndian.PutUint16(frame[8:10], uint16(len(payload)))
	copy(frame[HeaderSize:], payload)
	binary.BigEndian.PutUint32(frame[10:14], checksum(frame[:10], payload))
	return frame, nil
}

func checksum(header, payload []byte) uint32 {
	h := crc32.NewIEEE()
	h.Write(header)
	h.Write(payload)
	return h.Sum32()
}

// Stats 记录单个解码器的累计计数,用于观测异常比例。
type Stats struct {
	Accepted       uint64
	Rejected       uint64
	ShortFrames    uint64
	MagicErrors    uint64
	LengthErrors   uint64
	ChecksumErrors uint64
	TypeErrors     uint64
	SequenceErrors uint64
}

// Decoder 维护一条连接上的收包状态。
//
// Decoder 不是并发安全的“多连接共享对象”:每条连接应各自创建。
// 内部加锁只是为了防止统计读取与解码并发时出现竞态。
type Decoder struct {
	mu            sync.Mutex
	allowed       map[uint8]struct{}
	window        uint32
	maxForwardGap uint32
	started       bool
	highest       uint32
	seen          uint64
	stats         Stats
}

// NewDecoder 创建解码器。
//
// types 是允许的消息类型白名单;window 是重复包检测窗口,
// 会被限制在 1 到 64 之间。向前跳跃上限默认等于窗口,
// 丢包严重的传输层可以调用 SetMaxForwardGap 放宽。
func NewDecoder(types []uint8, window int) *Decoder {
	if window < 1 {
		window = 1
	}
	if window > maxWindow {
		window = maxWindow
	}
	allowed := make(map[uint8]struct{}, len(types))
	for _, t := range types {
		allowed[t] = struct{}{}
	}
	return &Decoder{
		allowed:       allowed,
		window:        uint32(window),
		maxForwardGap: uint32(window),
	}
}

// SetMaxForwardGap 调整允许的向前序号跳跃上限。
//
// 上限越大,越能容忍丢包后的序号跳跃,但也越难区分正常丢包
// 与伪造序号。不要把它设成“无限大”。
func (d *Decoder) SetMaxForwardGap(gap uint32) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if gap == 0 {
		gap = d.window
	}
	d.maxForwardGap = gap
}

// Decode 校验并解析一个完整包。
//
// 检查顺序:固定头长度、魔数、长度字段、CRC32、类型白名单、
// 序号窗口。任何一步失败都返回错误并累加对应计数,调用方应
// 立即断开连接。
func (d *Decoder) Decode(frame []byte) (Packet, error) {
	d.mu.Lock()
	defer d.mu.Unlock()

	if len(frame) < HeaderSize {
		d.reject(&d.stats.ShortFrames)
		return Packet{}, ErrShortFrame
	}
	if binary.BigEndian.Uint16(frame[0:2]) != Magic {
		d.reject(&d.stats.MagicErrors)
		return Packet{}, ErrBadMagic
	}
	size := int(binary.BigEndian.Uint16(frame[8:10]))
	if size > MaxPayload {
		d.reject(&d.stats.LengthErrors)
		return Packet{}, ErrTooLarge
	}
	if len(frame) != HeaderSize+size {
		d.reject(&d.stats.LengthErrors)
		return Packet{}, ErrLength
	}
	payload := frame[HeaderSize:]
	want := binary.BigEndian.Uint32(frame[10:14])
	if checksum(frame[:10], payload) != want {
		d.reject(&d.stats.ChecksumErrors)
		return Packet{}, ErrChecksum
	}
	msgType := frame[2]
	if _, ok := d.allowed[msgType]; !ok {
		d.reject(&d.stats.TypeErrors)
		return Packet{}, ErrUnknownType
	}
	pkt := Packet{
		Type:    msgType,
		Flags:   frame[3],
		Seq:     binary.BigEndian.Uint32(frame[4:8]),
		Payload: payload,
	}
	if err := d.acceptSequence(pkt.Seq); err != nil {
		d.reject(&d.stats.SequenceErrors)
		return Packet{}, err
	}
	d.stats.Accepted++
	return pkt, nil
}

func (d *Decoder) reject(counter *uint64) {
	d.stats.Rejected++
	*counter++
}

// acceptSequence 在持有 d.mu 时调用,维护序号窗口位图。
func (d *Decoder) acceptSequence(seq uint32) error {
	if !d.started {
		d.started = true
		d.highest = seq
		d.seen = 1
		return nil
	}
	if seq > d.highest {
		gap := seq - d.highest
		if gap > d.maxForwardGap {
			return ErrSeqJump
		}
		if gap >= d.window {
			d.seen = 0
		} else {
			d.seen <<= gap
		}
		d.seen |= 1
		d.highest = seq
		return nil
	}
	delta := d.highest - seq
	if delta >= d.window {
		return ErrTooOld
	}
	mask := uint64(1) << delta
	if d.seen&mask != 0 {
		return ErrDuplicate
	}
	d.seen |= mask
	return nil
}

// Snapshot 返回当前统计快照。
func (d *Decoder) Snapshot() Stats {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.stats
}

限速与封禁逻辑放在同包的 guard.go 中:Guard 用令牌桶控制单连接速率,用失败计数触发封禁。测试覆盖往返编解码、重复序号、向前跳跃、过旧序号、校验和错误、未知类型、长度不符,以及令牌桶恢复和失败封禁。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch18/packet
go vet ./ch18/packet/
go test ./ch18/packet/ -count=1

实际运行结果是 gofmt -l 无输出、go vet 通过、测试全部通过。解码器在每条连接上独立创建,不跨连接共享序号状态;这一点很重要,否则不同玩家的序号会互相污染。

5.1 为什么失败后不继续解析

一旦长度、校验和或类型校验失败,当前字节流的边界就不再可信。继续解析可能把攻击者构造的负载当成下一个包头,造成“解析器混淆”。正确动作是记录原因、断开连接、让客户端重连。重连成本远低于一个被污染的长连接带来的风险。

六、把计数变成处置

统计只有进入处置流程才有意义。建议把拒绝原因分成“传输损坏”“协议违规”“疑似攻击”三类:偶发 CRC 错误可以只计数;类型白名单命中、长度字段反复越界、解析失败率异常,应该进入封禁;同一 IP 下多个连接同时出现相同模式,应该提升到风控工单。封禁要留证据:时间、连接 ID、消息类型、原始帧摘要、拒绝原因和计数快照。没有证据链的封禁,申诉时无法自证,也容易误伤。

小结

封包验证的顺序是固定头、魔数、长度、校验和、类型白名单、序号窗口,任何一步失败都应直接断开,而不是猜测边界继续解析。序号能挡住重复和过旧包,但不能替代签名;CRC 能发现随机损坏,但不能替代 HMAC。频率、体积和解析失败要按连接统计,并把统计接入限流、封禁和证据留存。下一篇 /game-server-in-action-18-3/ 会从单个封包上升到行为层,讨论服务端权威、滑窗统计和外挂防控。

常见错误与边界

  • 直接信任客户端的长度字段,不设上限,也不校验实际字节数。
  • 解析失败后跳过若干字节继续找魔数,导致协议边界错位。
  • 把 CRC32 当成防篡改手段,攻击者可以改内容后重算校验值。
  • 只校验包内容,不统计单连接速率和解析失败次数。
  • 序号窗口设成无限大,或者完全不做重复检测。
  • 在 UDP 上套用 TCP 的“序号必须连续”假设,把正常丢包当成攻击。
  • 多条连接共享一个解码器状态,序号和统计互相污染。
  • 封禁只写一行日志,没有原始帧摘要、时间线和复核记录。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist