一、先校验,再解析
封包验证的第一原则是:在确认边界之前,不解释任何字段。客户端发来的长度字段只是“声明”,不是事实;消息类型只是“建议”,不是授权;校验和只是“提示”,不是信任凭据。解析器必须先证明“这一帧的字节数与声明一致、内容没有被随机破坏、类型在允许范围内”,然后才允许业务代码看到负载。
| 检查项 | 位置 | 拒绝原因 | 失败后的动作 |
|---|---|---|---|
| 固定头长度 | 最前 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 的“序号必须连续”假设,把正常丢包当成攻击。
- 多条连接共享一个解码器状态,序号和统计互相污染。
- 封禁只写一行日志,没有原始帧摘要、时间线和复核记录。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。