5.3 移动校验与防穿墙
5.1 让位置由服务器算,5.2 让服务器知道路怎么走。但这两节都默认了一件事:客户端发来的请求是善意的。现实恰恰相反——CmdMoveReq 是攻击者最容易伪造的一条报文,它来自客户端、承载着「我想去哪」的意图,而意图可以随便编。
最省事的作弊是直接谎报落点:客户端不按 5.1 的规矩发「附近的目标点」,而是发一个墙对面的坐标,甚至一个跨半张地图的坐标。如果服务端照着执行,改内存的玩家就能瞬移、穿墙、日行千里。移动校验要做的,就是在把请求交给 Mover 之前,先证明「这个落点在物理上可能是从当前位置走过来的」。
本节写三道闸,依次是序号单调、速度上限、视线不穿墙。三条都过,才允许落点生效;任何一条不过,就带原因码拒掉。全部是 L1,go test ./internal/move/ 直接跑。
三道闸:先把顺序想清楚
三道闸不是并列的,是有先后的,顺序错了会浪费计算甚至误判。
第一道是序号单调。每条 MoveReq 带一个自增序号,服务器只接受严格大于上次的序号。这一条最便宜(比一次整数比较),却能拦掉重放攻击——攻击者录下一条合法移动反复重放,序号不动就立刻露馅。它也能顺手处理乱序:UDP 或弱网下旧包晚到,序号回退直接丢弃,不会让角色被一条迟到的包拽回原地。
第二道是速度上限。从上次接受的落点到本次落点,距离除以时间必须不超过该实体的最大速度(留一点容差给网络抖动)。这一条拦加速外挂:无论客户端怎么编坐标,单位时间内能走多远是硬约束。
第三道是视线不穿墙。这是三道里唯一需要几何计算的。速度合法不代表路径合法——两堵墙夹角的两侧可能只隔几个逻辑单位,速度校验会放行,但角色明明过不去。用 DDA 走格检查起终点连线是否全程可行走,才能拦住这类「贴着墙角闪过去」的穿墙。
把三道闸写成一张表
validate.go 把这三道闸落成两个函数:ValidateSpeed 做纯速度校验,LineOfSight 做 DDA 走格,MoveGuard.Check 把它们串起来并维护「上次接受的落点」。下面这段是 L1,go test ./internal/move/ 可以直接跑:
// file: internal/move/validate.go
// tier: L1
// verify: go test ./internal/move/
package move
import "math"
// RejectReason 是服务器拒绝一次移动的机器可读原因码。
// 客户端据此决定是「静默回拉」还是「弹提示」。
type RejectReason uint8
const (
Accept RejectReason = 0 // 通过
RejectSpeed RejectReason = 1 // 速度超限(加速外挂)
RejectWall RejectReason = 2 // 路径穿墙
RejectReplay RejectReason = 3 // 序号回退(重放 / 乱序)
RejectOutOfMap RejectReason = 4 // 越界
)
// SpeedTolerance 是允许的速度上限系数。网络抖动会让两次上报的 dt 略有出入,
// 留 15% 余量;超过即判为加速外挂。
const SpeedTolerance = 1.15
// ValidateSpeed 检查从 from 到 to 在 dt 秒内是否物理可能。
func ValidateSpeed(from, to Vec2, dt, maxSpeed float64) RejectReason {
if dt <= 0 {
return RejectSpeed
}
allow := maxSpeed * SpeedTolerance * dt
if Distance(from, to) > allow+1e-9 {
return RejectSpeed
}
return Accept
}
// LineOfSight 用 DDA 走格判断 a→b 之间是否全程可行走。
// 这是防穿墙的核心:客户端可以谎报一个「瞬移穿墙」的落点,
// 但服务器只要发现起终点连线穿过不可走格,就直接拒绝。
func LineOfSight(g *Grid, a, b Vec2) bool {
ax, ay := CellOf(a)
bx, by := CellOf(b)
if !g.Walkable(ax, ay) || !g.Walkable(bx, by) {
return false
}
x, y := ax, ay
dx := b.X - a.X
dy := b.Y - a.Y
stepX, stepY := 1, 1
if dx < 0 {
stepX = -1
}
if dy < 0 {
stepY = -1
}
// 到下一条格线的参数增量与每跨一格的时间增量。
tMaxX, tDeltaX := math.Inf(1), math.Inf(1)
if dx != 0 {
edge := float64(x+1) * GridSize
if stepX < 0 {
edge = float64(x) * GridSize
}
tMaxX = (edge - a.X) / dx
tDeltaX = GridSize / math.Abs(dx)
}
tMaxY, tDeltaY := math.Inf(1), math.Inf(1)
if dy != 0 {
edge := float64(y+1) * GridSize
if stepY < 0 {
edge = float64(y) * GridSize
}
tMaxY = (edge - a.Y) / dy
tDeltaY = GridSize / math.Abs(dy)
}
for i := 0; i < g.W*2+g.H*2+2; i++ { // 步数上限,防浮点死循环
if x == bx && y == by {
return true
}
if tMaxX < tMaxY {
x += stepX
tMaxX += tDeltaX
} else {
y += stepY
tMaxY += tDeltaY
}
if !g.Walkable(x, y) {
return false
}
}
return true
}
// MoveGuard 逐实体记录上一次接受的移动,用于拦截重放、超速与穿墙。
type MoveGuard struct {
LastPos Vec2
LastSeq uint32
MaxSpeed float64
}
// NewMoveGuard 创建守卫,pos 为当前权威位置,maxSpeed 为该实体的速度上限。
func NewMoveGuard(pos Vec2, maxSpeed float64) *MoveGuard {
return &MoveGuard{LastPos: pos, MaxSpeed: maxSpeed}
}
// Check 依次做三道校验:序号单调 → 速度上限 → 视线不穿墙。
// 任一步失败都返回对应原因码,且**不更新 LastPos**——被拒的移动
// 绝不能污染下一次校验的基准,否则一次伪造就能把后续合法移动一起带偏。
func (g *MoveGuard) Check(seq uint32, to Vec2, dt float64, grid *Grid) RejectReason {
if seq <= g.LastSeq {
return RejectReplay
}
if r := ValidateSpeed(g.LastPos, to, dt, g.MaxSpeed); r != Accept {
return r
}
if grid != nil {
if cx, cy := CellOf(to); !grid.In(cx, cy) {
return RejectOutOfMap
}
if !LineOfSight(grid, g.LastPos, to) {
return RejectWall
}
}
g.LastSeq = seq
g.LastPos = to
return Accept
}
LineOfSight 用的是 Amanatides–Woo 的 DDA 走格:从起点格出发,用 tMaxX/tMaxY 记录「还要多久跨到下一条竖线/横线」,每次挑更近的那条跨过去,逐个检查经过的格子。tDeltaX = GridSize / |dx| 是每跨一格的固定步长,所以整个过程是 O(走过的格数),而不是按像素采样——采样步长太粗会漏掉薄墙,太细又慢。循环里那个 g.W*2+g.H*2+2 的步数上限是安全阀:浮点误差理论上可能让 tMaxX == tMaxY 反复成立,有了上限最坏也只是返回 true(放行),绝不会卡死服务端。
Check 里最关键的一行注释是「被拒的移动不更新 LastPos」。如果错误地更新了,攻击者只要故意发一条必被拒的移动,就能把服务器记录的基准位置拖到他想要的地方,下一次校验就形同虚设。只有被接受的移动才推进基准,这是整段代码里最容易被写反、也最致命的一处。
常见绕过手法与对策
把三类攻击和它们的拦截闸对起来,能看出为什么三道闸缺一不可——少任何一道,都会留下一个明确的作弊口子。
| 攻击手法 | 客户端表现 | 被哪道闸拦下 |
|---|---|---|
| 重放合法移动 | 序号不动、位置反复跳回同一点 | 序号单调 |
| 加速外挂 | 单位时间位移超过速度上限 | 速度上限 |
| 瞬移穿墙 | 落点直接落在墙的另一侧 | 视线检测 |
| 贴角闪穿 | 起终点很近,但中间隔着一堵墙 | 视线检测 |
| 越界落点 | 坐标落到地图边界之外 | 越界检查 |
注意「贴角闪穿」这一行:它和「瞬移穿墙」看着像,其实不同——它的位移很小,速度校验会放行,只有视线检测能发现连线穿过了墙体。这也是为什么速度校验再便宜也不能替代视线检测:两者拦的根本不是同一类东西。反过来,视线检测也替代不了速度校验:一条绕远路、每步都合法的「慢速瞬移」,视线是查不出来的,得靠位移上限。三道闸是互补的,不是冗余的。
拒绝之后:回拉,而不是踢下线
拦下来只是第一步,还得决定怎么处置。常见做法有三种:静默回拉、弹提示、踢下线。绝大多数「差一点」的移动其实是网络抖动或客户端预测偏差,正确反应是把服务器权威位置回给客户端,让它把自己拉回来,而不是动辄断线。真正需要断线的是高频、持续、明显恶意的模式。
下面是服务端处理一条 CmdMoveReq 后的应答逻辑(片段,落到 CmdMoveResp 0x0202):
// snippet: 服务器处理 MoveReq 后的应答(0x0202)
// 载荷:seq(4,BE) | reason(1) | x(4,BE) | y(4,BE)
func (h *Handler) onMoveReq(p *Player, seq uint32, goal Vec2, dt float64) {
guard := h.guards[p.ID]
if r := guard.Check(seq, goal, dt, h.grid); r != Accept {
// 拒绝:把权威位置回给客户端,让它自行回拉,不轻易断线。
h.send(p, CmdMoveResp, encodeMoveResp(seq, r, guard.LastPos))
return
}
h.movers[p.ID].Goal = goal
h.send(p, CmdMoveResp, encodeMoveResp(seq, Accept, goal))
}
原因码 RejectReplay / RejectSpeed / RejectWall 回给客户端后,客户端可以据此做不同的反馈:速度超限通常是自己预测跑太快,静默回拉即可;连续多次 RejectWall 则值得上报风控。把「拒绝原因」显式建模成一个枚举,比只回一个布尔值有用得多——后者到了排查线上问题时什么都告诉不了你。
用测试把三种攻击钉死
校验代码最容易「看着对、其实放行」,所以必须用测试把每种攻击都跑一遍。下面是 validate_test.go 里两段测试的摘录,属于同一个已跑通的 L1 包:
// snippet: internal/move/validate_test.go(摘录)
func TestLineOfSightBlockedByWall(t *testing.T) {
g := wallGrid() // 一整堵竖墙在 x=5
// 墙同侧:可见。
if !LineOfSight(g, CellCenter(3, 0), CellCenter(4, 4)) {
t.Fatal("同侧应可见")
}
// 跨墙:不可见,正是「穿墙」要拦的情形。
if LineOfSight(g, CellCenter(4, 0), CellCenter(6, 0)) {
t.Fatal("跨墙应不可见")
}
}
func TestMoveGuardRejectsReplayAndWall(t *testing.T) {
g := wallGrid()
guard := NewMoveGuard(CellCenter(4, 0), 100)
if r := guard.Check(1, CellCenter(4, 1), 1.0, g); r != Accept {
t.Fatalf("首次合法移动 = %v,期望 Accept", r)
}
// 同一个序号再来一次:重放。
if r := guard.Check(1, CellCenter(4, 2), 1.0, g); r != RejectReplay {
t.Fatalf("重放 = %v,期望 RejectReplay", r)
}
// 序号前进,但落点在墙另一侧:穿墙。
if r := guard.Check(2, CellCenter(6, 1), 1.0, g); r != RejectWall {
t.Fatalf("穿墙 = %v,期望 RejectWall", r)
}
// 被拒的移动不得污染基准位置。
if guard.LastPos != CellCenter(4, 1) {
t.Fatalf("被拒后基准被污染: %+v", guard.LastPos)
}
}
最后一条断言是整组测试里最重要的一条:它不检查「拒绝了没有」,而检查「拒绝之后基准有没有被改」。前面说过,被拒的移动若污染了 LastPos,攻击者就能靠故意发送非法移动来拖拽基准位置——那是最隐蔽、也最容易被漏测的一类漏洞。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go test ./internal/move/
ok aegis/internal/move 0.293s (14 个 test 全绿,含 4 个校验相关)
$ go test ./internal/move/ -bench BenchmarkLineOfSight -benchtime=200x -run '^$'
BenchmarkLineOfSight-10 200 15.62 ns/op 0 B/op 0 allocs/op
一次 DDA 视线检测约 16 纳秒、零分配,比一次速度校验还便宜——也就是说三道闸加起来对每个 MoveReq 的开销可以忽略不计,完全没有理由为了省 CPU 而跳过校验。测试覆盖了三种攻击:序号回退判 RejectReplay、跨墙落点判 RejectWall、以及「被拒后基准位置不被污染」。以上数字是同机相对量级,绝对数值不可移植。
移动与寻路到此闭环:位置由服务器算(5.1)、路怎么走由服务器规划(5.2)、落点可不可信由服务器校验(5.3)。下一章进入战斗管线——当「移动」这个基础动作可信之后,技能释放、Buff 与结算才有意义。
阅读导航:上一节:5.2 A* 与导航网格 · 下一节:6.1 指令投递与执行队列 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。