一次误封,把“反作弊”从功能变成了架构问题
《裂隙战场》上线第二个月做过一次封禁,依据是客户端完整性校验:检测到调试器或内存修改就直接封号。名单里出现了一名主播,他的直播推流软件恰好会注入图形层,触发了同一条规则。申诉花了三天,舆论已经发酵完。
同一个季度,《群岛纪元》出现了一次资源复制。玩家利用断线重连的时间窗,让同一笔建造扣款执行了两次。客户端完整性校验完全看不见这个问题,因为客户端没有改任何代码,它只是重复发了请求。
《泡泡工坊》的情况更直接:团队只有两名后端,没有安全专职,也不可能维护一套客户端加固。它的反作弊目标不是“抓住高手”,而是“让批量脚本的成本高于收益”。
三个产品、三种失败,说明反作弊不是一个可以插在某一层的模块。它是一组分布在链路各处的检查点,每个检查点只能回答一部分问题。
五层检查点
客户端完整性 ──┐
网关频率/协议 ──┼──> 评分与归因 ──> 阈值分级 ──> 人工复核 / 封禁
战斗服规则 ────┤ │
经济审计 ──────┤ └──> 证据链(只追加、可校验)
离线复核 ──────┘
| 层 | 能发现什么 | 主要误伤 | 成本与延迟 | 绕过成本 |
|---|---|---|---|---|
| 客户端完整性 | 改包、注入、调试器、模拟器特征 | 直播/辅助/无障碍工具 | 客户端体积与兼容性,服务端低 | 中,改特征即可 |
| 网关频率与协议 | 洪水、畸形包、不可能的时间间隔 | NAT 重绑定、重连后的突发 | 很低,线速可做 | 低,限速即可绕过 |
| 战斗服规则 | 超速、超射程、冷却不符、伤害异常 | 延迟补偿、边界碰撞、合法连招 | 中,跟随 tick | 高,要贴合规则 |
| 经济审计 | 复制、异常产出、交易闭环 | 客服补发、活动配置错误 | 低,可离线 | 高,要改账 |
| 离线复核与人工 | 跨天模式、团伙关联、申诉证据 | 人工判断偏差 | 高,按人天 | 最高,但不可规模化 |
这张表最重要的不是“哪层更强”,而是“每层都能被绕过”。客户端可以被改特征,网关可以被限速到正常范围,战斗服规则可以被慢慢逼近阈值,经济账可以被拆成多笔小额。没有任何单层能“杜绝作弊”;架构的目标是让攻击成本持续上升,同时让误伤可解释、可申诉、可撤销。
为什么单层封禁一定会出事
单层封禁有两个结构性问题。
第一,单层证据的误伤率不可控。客户端完整性会命中辅助工具,网关频率会命中重连风暴,战斗服规则会命中延迟补偿的边界。把这些信号直接映射成封禁,等于把该层所有的假阳性都变成用户损失。
第二,单层证据无法区分“异常”和“恶意”。一个玩家在高铁上玩游戏,RTT 抖动、重连、输入突发会同时触发网关和战斗服规则;他的行为统计与脚本几乎一样。只有跨层、跨时间的证据才能把两者分开。
所以策略要分成两级:评分回答“有多可疑”,归因回答“证据来自几层”,封禁回答“是否满足复核条件”。 单层可以触发观察和人工复核,但不能直接封禁。
证据链:封禁决定必须能被复查
申诉处理时,运营需要的不是“模型给出 0.93 分”,而是“在什么时间、因为哪条规则、命中了什么数据、当时的追踪号是什么”。因此每条命中都要落成一条不可静默修改的证据记录。
| 字段 | 作用 |
|---|---|
Seq | 全局递增,防止插入和删除 |
PlayerID / Rule / Layer | 归因,支撑分层统计 |
Score / Detail | 本次命中的权重与可读说明 |
At / TraceID | 关联日志与回放 |
PrevHash / Hash | 哈希链,检测事后篡改 |
哈希链不提供“不可删除”的保证,那需要外部存储或 WORM 介质;它提供的是“删除或修改会被发现”。对内部审计和申诉来说,这已经足够,而且实现成本远低于引入一套专门的审计基础设施。
评分、分级与双层确认
评分引擎只做三件事:累加权重、按层归因、根据策略给出分级。它不包含任何检测模型,检测规则由各层自己产生 Finding,引擎不关心规则细节。
策略里有四个阈值:观察、复核、封禁,以及封禁所需的最小层数。前三个回答“分数到哪”,第四个回答“证据够不够硬”。
一个常见的误区是把所有层的分数直接相加。网关一次洪水可能产生几百分,战斗服一次异常只有十分;直接相加会让网关单层轻松越过封禁线,正好绕过了双层确认。正确做法是同时看总分和层分布:总分决定是否进入视野,层数决定能否升级处置。
另一条边界是衰减。三周前的一次网关异常不应该和今天的战斗服异常等权。生产上应给证据设置时间窗,或在评分时按时间衰减;本节为了保持可测试性,把衰减留给调用方,只在接口上区分“记录”和“评估”。
代码:anticheat 包
下面的实现包含五个部分:层枚举、命中记录、只追加的证据链、分层评分与评估、链校验。它不实现任何具体检测规则,因为那属于各层的领域逻辑。
// file: internal/anticheat/engine.go
// tier: L1
// verify: go test ./internal/anticheat/
package anticheat
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"time"
)
// Layer names where a signal was produced. The order is the escalation order,
// not a priority: a cheap gateway rule runs long before an expensive replay
// review, and a ban should never rest on one layer alone.
type Layer string
const (
LayerClient Layer = "client"
LayerGateway Layer = "gateway"
LayerBattle Layer = "battle"
LayerEconomy Layer = "economy"
LayerReview Layer = "review"
)
// Finding is one rule hit. Score is a policy weight, not a probability.
type Finding struct {
Rule string
Layer Layer
Score int
Detail string
}
// Evidence is an append-only record. PrevHash and Hash make the trail
// tamper-evident without requiring a database trigger.
type Evidence struct {
Seq int
PlayerID string
Rule string
Layer Layer
Score int
Detail string
At time.Time
TraceID string
PrevHash string
Hash string
}
type Verdict string
const (
VerdictPass Verdict = "pass"
VerdictWatch Verdict = "watch"
VerdictReview Verdict = "review"
VerdictBan Verdict = "ban"
)
// Policy separates "how much evidence" from "how much corroboration". A ban
// requires both a score and findings from at least MinBanLayers layers.
type Policy struct {
Watch int
Review int
Ban int
MinBanLayers int
}
func DefaultPolicy() Policy {
return Policy{Watch: 20, Review: 60, Ban: 120, MinBanLayers: 2}
}
type Engine struct {
policy Policy
scores map[string]int
layers map[string]map[Layer]int
evidence []Evidence
}
func NewEngine(p Policy) *Engine {
if p.MinBanLayers < 1 {
p.MinBanLayers = 1
}
return &Engine{
policy: p,
scores: make(map[string]int),
layers: make(map[string]map[Layer]int),
}
}
// Record appends a finding and returns the sealed evidence record. Callers must
// keep the returned record; the in-memory chain is a convenience, not storage.
func (e *Engine) Record(playerID string, at time.Time, traceID string, f Finding) Evidence {
ev := Evidence{
Seq: len(e.evidence) + 1,
PlayerID: playerID,
Rule: f.Rule,
Layer: f.Layer,
Score: f.Score,
Detail: f.Detail,
At: at.UTC(),
TraceID: traceID,
}
if n := len(e.evidence); n > 0 {
ev.PrevHash = e.evidence[n-1].Hash
}
ev.Hash = hashEvidence(ev.PrevHash, ev)
e.evidence = append(e.evidence, ev)
e.scores[playerID] += f.Score
if e.layers[playerID] == nil {
e.layers[playerID] = make(map[Layer]int)
}
e.layers[playerID][f.Layer] += f.Score
return ev
}
type Assessment struct {
PlayerID string
Score int
Layers []Layer
Verdict Verdict
Note string
}
// Assess caps a single-layer score at review. This is the architecture rule
// that says "no single sensor may ban an account".
func (e *Engine) Assess(playerID string) Assessment {
layers := make([]Layer, 0, len(e.layers[playerID]))
for layer := range e.layers[playerID] {
layers = append(layers, layer)
}
sort.Slice(layers, func(i, j int) bool { return layers[i] < layers[j] })
verdict := e.verdict(e.scores[playerID])
note := ""
if verdict == VerdictBan && len(layers) < e.policy.MinBanLayers {
verdict = VerdictReview
note = "ban requires corroboration from at least two layers"
}
return Assessment{
PlayerID: playerID,
Score: e.scores[playerID],
Layers: layers,
Verdict: verdict,
Note: note,
}
}
func (e *Engine) verdict(score int) Verdict {
switch {
case score >= e.policy.Ban:
return VerdictBan
case score >= e.policy.Review:
return VerdictReview
case score >= e.policy.Watch:
return VerdictWatch
default:
return VerdictPass
}
}
func (e *Engine) Score(playerID string) int { return e.scores[playerID] }
func (e *Engine) LayerScore(playerID string, layer Layer) int {
return e.layers[playerID][layer]
}
func (e *Engine) Evidence() []Evidence {
return append([]Evidence(nil), e.evidence...)
}
// VerifyChain recomputes every hash and link. A modified score, detail or
// timestamp anywhere in the trail fails the whole chain.
func VerifyChain(evidence []Evidence) error {
prev := ""
for i, ev := range evidence {
if ev.Seq != i+1 {
return fmt.Errorf("anticheat: evidence %d has seq %d", i, ev.Seq)
}
if ev.PrevHash != prev {
return fmt.Errorf("anticheat: evidence %d prev hash mismatch", i)
}
if ev.Hash != hashEvidence(prev, ev) {
return fmt.Errorf("anticheat: evidence %d hash mismatch", i)
}
prev = ev.Hash
}
return nil
}
func hashEvidence(prev string, ev Evidence) string {
raw := fmt.Sprintf("%s|%s|%s|%s|%d|%d|%s|%s|%s",
prev, ev.PlayerID, ev.Rule, ev.Layer, ev.Score, ev.Seq,
ev.At.UTC().Format(time.RFC3339Nano), ev.TraceID, ev.Detail)
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
Record 接收时间与追踪号,而不是在内部取 time.Now()。这样回放、测试和离线补录都能得到确定的时间线;如果引擎自己取时间,同一批证据在不同机器上会算出不同的哈希。
Assess 先按分数得到分级,再检查层数。单层高分不会返回 ban,而是降级为 review 并附带说明。这个降级不是“宽大处理”,而是把决定权交回给能看到完整证据的人。
VerifyChain 从第一条开始重算。任何一条的分数、详情、时间或序号被改动,后续所有链接都会失配;只改最后一条也无法通过,因为它的 PrevHash 仍指向旧值。
测试覆盖了什么
lab 里的 internal/anticheat 有 5 个测试函数,其中 4 个是表驱动:
| 测试 | 覆盖的失败模式 |
|---|---|
TestVerdictThresholds | 四个分级在阈值边界上的取值 |
TestAssessmentTable | 干净、观察、单层高分、双层封禁四种组合 |
TestLayerAttribution | 同层累加、跨层独立、未命中层为 0 |
TestEvidenceChainDetectsTampering | 修改分数、详情、前哈希、序号均被发现 |
TestBanRequiresTwoLayers | 单层 200 分仍只能复核,补一层后才封禁 |
本机运行结果:
$ go test -count=1 -v ./internal/anticheat/
--- PASS: TestVerdictThresholds (0.00s)
--- PASS: TestBanRequiresTwoLayers (0.00s)
--- PASS: TestLayerAttribution (0.00s)
--- PASS: TestAssessmentTable (0.00s)
--- PASS: TestEvidenceChainDetectsTampering (0.00s)
PASS
ok gsarchlab/internal/anticheat 0.268s
失败模式与兜底
规则误伤集中爆发。 一次客户端更新可能让某类正常工具全部命中。上线前必须支持按规则灰度、按层关闭和快速回滚;只支持“全开或全关”的引擎会在事故时被迫整体下线。
分数通胀。 规则加得越多,总分越容易越线。需要定期回看各层的分数分布,给单层设置贡献上限,否则双层确认会被“每层都很低但加起来很高”绕过。
证据链断裂。 并发写入、重试、补录都可能打乱序号。写入端必须单线程追加,或在入库时统一分配序号;发现断链时应告警并停止自动处置,而不是跳过坏记录继续。
申诉无据。 只存最终分数,不存命中详情,运营无法解释封禁原因。证据记录必须在处置前落盘,而不是事后从日志里拼。
常见错误与边界
第一,把客户端完整性当作封禁依据。它适合做“进入对局的准入检查”和“观察信号”,不适合单独定罪,因为误伤面覆盖了大量正常软件。
第二,只做服务端规则,不做经济审计。规则能抓住“这一帧不对”,抓不住“每天多出 200 金币、连续 30 天”;后者只有账本视角能看见。
第三,把反作弊放进战斗服主循环。检测和评分应与战斗 tick 解耦,否则一次规则升级就会影响帧预算;战斗服只负责产生命中,评分异步消费。
第四,忽略《泡泡工坊》这类小团队的成本约束。五层全建不是默认答案;休闲产品可以先做网关限速加经济对账,把客户端加固延后到出现规模化脚本之后。
小结
反作弊的架构问题不是“用哪个检测算法”,而是“把信任放在哪一层、每层能证明什么、证据如何组合成决定”。客户端完整性、网关、战斗服、经济审计和人工复核各自只能回答一部分问题,任何单层都不足以封禁。
internal/anticheat 把评分、归因、分级、证据链做成一个与规则无关的引擎。它强制双层确认,强制证据只追加,强制每次评估都能追溯到具体命中;这些约束比任何单条规则都更难被绕过。
下一节进入经济系统:当“谁说了算”落到掉落、随机数和账本上,服务端权威如何做到可审计,而不是仅仅“不可预测”。
阅读导航:上一节:6.1 客户端预测与服务端裁决 · 下一节:6.3 经济系统与随机数的可信边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。