《MMO 服务器开发》15.1 权威边界与信任模型

外挂的根子不在客户端有漏洞,而在服务端信了不该信的话。本节把权威边界画清楚:哪些字段客户端只能提议、哪些必须由服务端裁决;再用一个真跑的信任契约表证明——客户端一旦发来服务端自有字段就是违规。最后说明为什么信任模型必须落到字段级,以及「把一切搬上服务端」并不是正确答案。

15.1 权威边界与信任模型

第 14 章讲完了延迟补偿与高延迟公平性,但整套裁决有一个前提:服务端相信自己在裁决的东西。反外挂不是「在客户端装一个检测模块」,而是「服务端从一开始就不把客户端的断言当成事实」。一个玩家把客户端改成 3 倍移速,如果服务端只是照单全收地广播新坐标,那么传输层加密、心跳、延迟补偿都救不了他——他根本没撒谎,他只是「报告了一个服务端没质疑的位置」。

所以这一节不写检测规则,先写边界:一条连接上,哪些话客户端可以说,哪些话只有服务端能说。边界画错了,后面两节的校验规则、信号采集、限速都会建在流沙上。

本节的目标是给 aegis 一个可执行的信任契约表(trust contract)。它不是文档,是代码:每条命令的每个字段都标注信任等级,服务端在反序列化之后、业务处理之前,机械地检查「客户端有没有发来它不该发的字段」。这样边界就不依赖开发者的记忆。

与站内实践文章的分工

站内 game/server/practice/ 专题里有几篇与权威、反作弊直接相关的架构文。它们负责讲「方案怎么选、边界怎么划、线上怎么踩坑」,本节负责「把边界写成一张能跑的表」。分工如下:

站内实践文章它负责的方案层本节负责的实现层
游戏服务器如何做权威移动校验移动校验放在哪一层、与预测/回滚如何协作只定义「提议 vs 裁决」的字段级信任契约
在线游戏后端反作弊信号设计反作弊信号有哪些、如何分级处置只提供信号得以成立的前提:字段可信
游戏服务器权威状态架构设计权威状态整体架构与一致性取舍只落地「谁有权写状态」这一条最小规则

这三篇是「为什么这么划」,本节是「划完怎么用代码守住」。

权威边界:客户端提议,服务端裁决

把一条连接上流动的信息分成两类:

  • 提议(proposal):客户端说「我想做某事」。比如「我要移动到 (x,y,z)」「我要用技能 1001」「我要买道具 A」。提议是意图,不是事实;服务端可以拒绝、可以改、可以忽略。
  • 裁决(adjudication):服务端说「发生了什么」。位置、血量、冷却到期时间、金币余额、掉落结果。这些只能由服务端写,客户端连「建议值」都不该出现在协议里。

边界画在哪,取决于两个问题:

  1. 它会不会影响别人? 位置影响别人(看错我在哪就会误判命中),血量影响别人,经济影响全服。影响别人的,必须服务端裁决。
  2. 它会不会影响数值或经济? 掉落、经验、金币、排名——只要能被折算成稀缺资源,就必须服务端裁决。

两个都是「否」的,可以留在客户端:镜头旋转、表情动作、UI 开关、本地音效。它们不影响任何人,也不产生资源,服务端没必要为之花 CPU。

客户端可提议          服务端必须裁决
───────────────       ──────────────────
目的地 to             最终位置、朝向
技能 id              冷却到期时间、伤害数值
物品 id              背包内容、金币余额
攻击目标              命中判定、暴击结果
聊天文本              过滤结果、发送频率

注意最后一行:聊天文本是提议,但「发送频率」是裁决。玩家可以提议任意内容,但「一秒钟能发几条」由服务端定——这一条留到 15.3 的令牌桶。

信任模型:可校验 ≠ 可采信

边界有了,接下来是信任模型。核心一句话:客户端的输入一律不可信,可以校验,但不可采信。「校验」和「采信」是两回事:

  • 校验(validate):检查客户端说的值是否在允许范围内,通过就接受。
  • 采信(trust):直接拿客户端的值当权威值用,不检查。

最危险的不是「没校验」,而是「把不该采信的字段当成了权威」。典型事故:客户端发来 {to, speed, hp},服务端用 speed 去算移动合法性——而 speed 本该由服务端从角色属性算出来。攻击者把 speed 改成 9999,校验「通过」了,因为它压根没被校验,直接被采信。

所以信任模型必须落到字段级别。下面这段是 L1,go test 可以直接跑:

// file: internal/authority/boundary.go
// tier: L1
// verify: go test ./internal/authority/ -run 'TestTrust'

package authority

import (
	"fmt"
	"sort"
)

// Trust classifies every field a client can put on the wire. The whole point of
// making it a type is that the classification stops being a thing a developer
// has to remember: it lives next to the command definition and the adjudicator
// reads it mechanically.
type Trust uint8

const (
	// Untrusted is a field the server never believes on its own. It is read,
	// used as a hint, and then recomputed from server state.
	Untrusted Trust = iota
	// Advisory is a field the server may accept, but only after the value has
	// passed a server-side validator. "Client says it moved here" is advisory.
	Advisory
	// Derived is a server-owned field. The client never sends it, and a frame
	// that carries one is a protocol violation regardless of its value.
	Derived
)

// String renders a Trust for logs.
func (t Trust) String() string {
	switch t {
	case Untrusted:
		return "untrusted"
	case Advisory:
		return "advisory"
	case Derived:
		return "derived"
	default:
		return "unknown"
	}
}

// FieldSpec is the trust contract for one field of one command.
type FieldSpec struct {
	Name  string
	Trust Trust
}

// CommandSpec is the trust contract for a single command kind. Registering the
// contract is how the boundary gets drawn: whatever is not listed is not
// accepted, and whatever is Derived may not arrive from a client.
type CommandSpec struct {
	Kind   string
	Fields []FieldSpec
}

// Violation is one breach of the trust contract found in an inbound message.
type Violation struct {
	Command string
	Field   string
	Reason  string
}

// Error implements error so a Violation can be returned directly.
func (v Violation) Error() string {
	return fmt.Sprintf("command %s field %s: %s", v.Command, v.Field, v.Reason)
}

// Registry holds the trust contract for every command the server accepts. It is
// deliberately small: the boundary is a table, not a code path.
type Registry struct {
	specs map[string]CommandSpec
}

// NewRegistry returns an empty trust registry.
func NewRegistry() *Registry {
	return &Registry{specs: make(map[string]CommandSpec)}
}

// Register installs a command's trust contract. Duplicate command kinds and
// duplicate field names are rejected so the table cannot silently drift.
func (r *Registry) Register(spec CommandSpec) error {
	if spec.Kind == "" {
		return fmt.Errorf("authority: empty command kind")
	}
	if _, dup := r.specs[spec.Kind]; dup {
		return fmt.Errorf("authority: duplicate command kind %q", spec.Kind)
	}
	seen := make(map[string]bool, len(spec.Fields))
	for _, f := range spec.Fields {
		if seen[f.Name] {
			return fmt.Errorf("authority: duplicate field %q in %q", f.Name, spec.Kind)
		}
		seen[f.Name] = true
	}
	r.specs[spec.Kind] = spec
	return nil
}

// Check reports every field the client must not have sent. The rule is narrow
// and absolute: Advisory and Untrusted fields are allowed through to the
// validator, but a Derived field arriving from a client is a violation no
// matter what value it carries. Output is sorted so tests and logs are stable.
func (r *Registry) Check(kind string, sent []string) []Violation {
	spec, ok := r.specs[kind]
	if !ok {
		return []Violation{{Command: kind, Reason: "unknown command"}}
	}
	class := make(map[string]Trust, len(spec.Fields))
	for _, f := range spec.Fields {
		class[f.Name] = f.Trust
	}
	var out []Violation
	for _, name := range sent {
		t, known := class[name]
		switch {
		case !known:
			out = append(out, Violation{Command: kind, Field: name, Reason: "field not in contract"})
		case t == Derived:
			out = append(out, Violation{Command: kind, Field: name, Reason: "client sent a server-derived field"})
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Field != out[j].Field {
			return out[i].Field < out[j].Field
		}
		return out[i].Reason < out[j].Reason
	})
	return out
}

三个信任等级对应三种处置:

  • Untrusted(不可信):读进来当提示,随后用服务端状态重算。elapsed_ms 属于此类——客户端说「过了 100ms」,服务端不信,用自己的 tick 间隔。
  • Advisory(可采信但须先校验):可以接受,但必须先过校验器。to(目的地)属于此类。
  • Derived(服务端派生):客户端永远不该发。speed、hp 属于此类。

Registry.Check 的规则很窄也很绝对:Advisory 和 Untrusted 放行给校验器,Derived 一出现就是违规,不管它带的是什么值。这个设计的好处是「零假阳性」——诚实的客户端永远不会发 Derived 字段,所以任何一次命中都是真信号,可以直接进 15.3 的嫌疑分。

测试断言了这一点:

// snippet: internal/authority/boundary_test.go(节选)

// 诚实客户端只发它被允许发的字段,不该被误报。
if v := r.Check("scene.move", []string{"to", "elapsed_ms"}); len(v) != 0 {
	t.Fatalf("honest client flagged: %v", v)
}

// 篡改客户端注入服务端自有字段,两个都必须被抓到。
got := r.Check("scene.move", []string{"to", "speed", "hp"})
if len(got) != 2 {
	t.Fatalf("got %d violations, want 2: %v", len(got), got)
}

为什么「全搬服务端」是错的方向

画完边界,一个自然的反应是「那我把所有东西都搬到服务端,不就没边界了」。这是最容易走的弯路,值得单独说清楚它错在哪。

第一,成本和延迟不划算。 镜头旋转、本地预测的位移、UI 状态,如果每个都走一次服务端裁决,一帧就是几十条请求;一个场景几百人,光这些无意义的往返就能把网关压垮。真正需要裁决的是「影响别人的那部分」,不是「所有部分」。

第二,也是更关键的:全搬服务端并不能消灭外挂,只会把外挂推向更底层。 把位置搬上服务端后,攻击者不会放弃,他会去攻击「输入」——伪造大量合法但无意义的移动来干扰 AOI,或者攻击「时序」——卡在冷却判定的边界上疯狂触发。服务器权威不是「外挂消失」,而是「外挂能影响的范围被压到服务端愿意为之付出代价的那一档」。

第三,边界本身是有弹性的。 竞技向的 MOBA 会把命中判定全放服务端,休闲向的钓鱼小游戏可以把拉杆动画全放客户端。边界画在哪取决于玩法、成本和作弊的收益,不是一个非黑即白的教条。

一句话:权威边界是「把有限的裁决预算花在影响别人和影响数值的地方」,不是「把一切搬上服务端」。

本节小结

  • 权威边界回答「谁有权写状态」:客户端提议,服务端裁决。
  • 边界按两条标准划:是否影响别人、是否影响数值或经济。
  • 信任模型落到字段级:Untrusted 重算、Advisory 先校验、Derived 一出现即违规。
  • 信任契约表是代码不是文档,Registry.Check 让「客户端发来服务端自有字段」变成可机械检测的违规。
  • 「全搬服务端」不是答案——它贵、慢,且把外挂推向输入与时序这些更隐蔽的面。

下一节把这个边界用在最频繁、也最容易被滥用的两条路径上:移动和战斗。

阅读导航:上一节:目录 · 下一节:15.2 移动与战斗合法性校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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