《MMO 服务器开发》5.3 移动校验与防穿墙

权威移动的前两节假设落点可信,这一节把不可信也纳入设计。本节从零写 internal/move 的三道闸:序号单调拦重放、速度上限拦加速外挂、DDA 走格视线检测拦穿墙;被拒的移动绝不更新基准位置,并把原因码随 0x0202 回给客户端让它自行回拉,配 go test 逐条验收。

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 指令投递与执行队列 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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