8.3 客户端防作弊与数据校验

前两节把存档存好、迁好了,这一节问一个更尖锐的问题:存档被玩家改了怎么办。给出客户端能做的四类校验——数值上限、时间间隔下界、增量单调性、离线收益封顶,再用 S 层脚本模拟八种作弊模式,看清本地校验能拦住哪些、漏掉哪些,以及为什么权威必须放服务端。

站内已有一篇小游戏开发综述 ,那篇是平台能力综述,把防作弊当成一句「重要数据放服务端」带过;本章给的是可跑、可断言的存储抽象层与迁移器,这一节则给出可量化的校验层。另有《游戏服务端编程实践》 那 43 篇讲的是服务端权威逻辑与协议,本章讲的是客户端侧:本地存储降级、存档迁移,以及哪些校验必须在客户端做、哪些必须交给服务端。分工的结论必须说在前面——客户端校验为什么不能替代服务端权威:客户端没有可信基准,攻击者能同时伪造所有字段让它们互相自洽,所以客户端校验只提高作弊成本,权威必须放服务端。这一节会用一段可跑的仿真把这个数字算出来。

8.1 把存档存进抽象层,8.2 让存档能升级,但它们都默认了一件事:存档里的数字是诚实的。而在浏览器里,localStorage 是明文的、可直接编辑的;在宿主里,存档文件也能被工具翻出来改。玩家把 gold 从 5000 改成 5000000,游戏凭什么拒绝?

客户端能做什么,不能做什么

先把边界画清楚,否则后面全是空话。

客户端能做的是一致性检查:一次提交里的几个字段之间必须互相自洽。时间只能前进不能回拨;gold 只能涨不能跌;一段时间内涨的金币不能超过这段时间的产出上限;离线收益不能超过封顶。这些检查的输入是同一份存档里的字段,不需要任何外部基准,所以在客户端就能做,而且成本极低。

客户端做不到的是来源可信性:它无法判断「这份存档是不是玩家手改的」。因为攻击者可以同时改所有字段——把时间前推、把金币增量调到刚好等于产出上限、把等级和离线时长都留在合法区间——改完之后所有字段互相自洽,一致性检查全部通过。客户端手里只有这一份存档,没有任何「独立于玩家控制的记录」可以拿来对照。

服务端能做客户端做不到的那一半:它手里有玩家上一次提交时的存档,那份记录在服务器上,玩家改不了。所以服务端可以判断「这次提交相对上次提交是不是合理的」。这就是「权威」的含义——不是「服务端校验更严格」,而是「服务端有一个客户端伪造不了的基准」。

四类本地校验

下面这段是 P 层内核代码,把客户端能做的四类校验落成函数:数值上限、时间间隔下界、增量单调性、离线收益封顶。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "客户端校验的作弊模式对照。全整数:时间用毫秒整数、增量用整数金币,无浮点。scenarios 是在 baseline 上叠加的「一次提交」,kind 只是分类标签。",
  "check": { "goldMax": 999999, "levelMax": 60, "minDeltaMs": 1000, "goldPerMinute": 1200, "offlineCapMs": 28800000, "tolerancePct": 20 },
  "baseline": { "gold": 5000, "level": 12, "energy": 40, "lastSeenMs": 1700000000000, "offlineClaimedMs": 0 },
  "scenarios": [
    { "id": "legit_play", "kind": "正常", "deltaMs": 120000, "goldDelta": 2400, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 0 },
    { "id": "clock_rollback", "kind": "改时间", "deltaMs": -60000, "goldDelta": 0, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 0 },
    { "id": "clock_jump_fast", "kind": "改时间", "deltaMs": 200, "goldDelta": 0, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 0 },
    { "id": "gold_regression", "kind": "改存档", "deltaMs": 60000, "goldDelta": -3000, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 0 },
    { "id": "gold_ceiling", "kind": "改存档", "deltaMs": 60000, "goldDelta": 2000000, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 0 },
    { "id": "gold_inflation", "kind": "改存档", "deltaMs": 60000, "goldDelta": 500000, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 0 },
    { "id": "offline_over_cap", "kind": "改存档", "deltaMs": 60000, "goldDelta": 1000, "levelDelta": 0, "energy": 40, "offlineClaimedMs": 999999999 },
    { "id": "crafted_consistent", "kind": "改存档", "deltaMs": 600000, "goldDelta": 12000, "levelDelta": 1, "energy": 50, "offlineClaimedMs": 600000 },
    { "id": "replay_old_save", "kind": "重放请求", "deltaMs": 300000, "goldDelta": 6000, "levelDelta": 0, "energy": 45, "offlineClaimedMs": 0 }
  ]
}
// file: core/src/e08/anticheat.ts
// tier: P
// verify: node --test 'core/test/e08_*.test.ts'
//
// 客户端可做的存档校验:数值上限、时间间隔下界、增量单调性、离线收益封顶。
// 定位必须说清:这些校验只提高作弊成本,不构成权威——权威必须放服务端。
// 原因见 core/test/e08_anticheat.test.ts 里的「自洽伪造」用例:
// 客户端没有可信基准,攻击者可以同时伪造所有字段使它们互相自洽。
// 全整数、无浮点、无 Math.random()、只用可擦除语法(禁 enum / namespace)。

export interface SaveState {
  gold: number;
  level: number;
  energy: number;
  lastSeenMs: number;
  offlineClaimedMs: number;
}

export interface CheckCfg {
  readonly goldMax: number;
  readonly levelMax: number;
  readonly minDeltaMs: number;
  readonly goldPerMinute: number;
  readonly offlineCapMs: number;
  readonly tolerancePct: number;
}

export interface Verdict {
  ok: boolean;
  reasons: string[];
}

/** 单次提交校验:返回所有不通过的原因(而非首个),便于日志聚合。 */
export function validate(prev: SaveState, next: SaveState, cfg: CheckCfg): Verdict {
  const reasons: string[] = [];
  const dt = next.lastSeenMs - prev.lastSeenMs;

  // 时间:不允许回拨;两次提交之间不得短于下界
  if (dt < 0) reasons.push("clock_rollback");
  if (dt >= 0 && dt < cfg.minDeltaMs) reasons.push("too_fast");

  // 数值上限
  if (next.gold > cfg.goldMax) reasons.push("gold_ceiling");
  if (next.level > cfg.levelMax) reasons.push("level_ceiling");
  if (next.energy < 0 || next.energy > 100) reasons.push("energy_range");

  // 单调性:gold / level 只能涨不能跌
  if (next.gold < prev.gold) reasons.push("gold_regression");
  if (next.level < prev.level) reasons.push("level_regression");

  // 增量上界:dt 内最多能赚多少,留 tolerancePct 的抖动余量(整数地板除)
  if (dt > 0) {
    const allowed = Math.floor((cfg.goldPerMinute * dt) / 60000);
    const cap = Math.floor((allowed * (100 + cfg.tolerancePct)) / 100);
    if (next.gold - prev.gold > cap) reasons.push("gold_inflation");
  }

  // 离线收益封顶
  if (next.offlineClaimedMs < 0) reasons.push("offline_negative");
  if (next.offlineClaimedMs > cfg.offlineCapMs) reasons.push("offline_over_cap");

  return { ok: reasons.length === 0, reasons };
}

几个设计点值得说明。第一,validate 返回原因数组而不是布尔。 一次伪造往往同时违反多条规则(改大金币既越过上限、又越过增量上界),把原因全收集起来,服务端才能拿它做风控聚类——「连续三次触发 gold_inflation」比「一次校验失败」有信息量得多。第二,增量上界用整数地板除。 allowed = goldPerMinute × dt ÷ 60000,cap = allowed × (100 + tolerancePct) ÷ 100,全程整数。tolerancePct 是给网络抖动和真实波动留的余量,它不是「宽容作弊」,而是避免把正常玩家的正常波动误判成作弊。第三,离线收益单独封顶。 放置类游戏最容易出的漏洞就是「改设备时间跳过等待」,offlineCapMs 直接把可领取的离线时长钉死,无论玩家怎么改时间,能领的离线收益都有上限。

P 层测试:连「拦不住」都要测

下面这段是 P 层测试的关键用例(完整 5 个用例见 core/test/e08_anticheat.test.ts)。前四个验证「该拦的拦住」,最后一个验证「拦不住的那类确实拦不住」——把已知的失败也写成断言,是这一节最重要的工程习惯。

// snippet: 关键用例(完整文件见 core/test/e08_anticheat.test.ts)
test("自洽伪造:所有字段互相自洽时,本地校验全部通过(所以权威必须在服务端)", () => {
  // 攻击者把时间前推 10 分钟,gold 增量正好等于速率上限,level / energy / 离线时长都在界内。
  const dt = 600000; // 10 分钟
  const allowed = Math.floor((CFG.goldPerMinute * dt) / 60000); // 12000
  const next = { gold: BASE.gold + allowed, level: BASE.level + 1, energy: 50, lastSeenMs: at(dt), offlineClaimedMs: dt };
  const v = validate(BASE, next, CFG);
  assert.equal(v.ok, true); // 本地全过——这就是它拦不住的原因
  assert.deepEqual(v.reasons, []);
});

跑全部 5 个用例,全过:

ℹ tests 5
ℹ pass 5
ℹ fail 0

最后那个用例断言 v.ok === true——它期望校验通过。这不是 bug,而是把「客户端校验的边界」钉进测试里:任何一天有人觉得「加了本地校验就安全了」,跑一遍测试就会看到这条明确写着「本地全过」。测试不只保护正确行为,也保护已知的局限。

S 层:八种作弊,本地拦住六个

下面这段是 S 层脚本,把八种作弊模式(改时间 / 改存档 / 重放请求)逐个叠加到 baseline 上,跑一遍与 anticheat.ts 等价的校验,统计「拦住 / 漏掉」。全整数,图表用纯 Python SVG。

#!/usr/bin/env python3
# file: sim/e08_anticheat.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e08_anticheat.py --config config/ --out out/
"""客户端校验能拦住多少作弊:逐模式统计。

把每个作弊场景叠加到 baseline 上,跑一遍与 core/src/e08/anticheat.ts 等价的校验,
统计「拦住 / 漏掉」并按模式汇总。结论:本地校验拦得住「字段互相不一致」的改,
拦不住「所有字段互相自洽」的伪造——所以权威必须放服务端。全整数,纯 Python SVG。
产出:out/e08_anticheat.json + out/e08_anticheat_scenarios.csv + out/e08_anticheat.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "e08_anticheat.json"), encoding="utf-8") as f:
        return json.load(f)


def validate(prev, nxt, cfg):
    """与 TS 侧 validate 逐位等价的校验,返回不通过原因列表。"""
    reasons = []
    dt = nxt["lastSeenMs"] - prev["lastSeenMs"]
    if dt < 0:
        reasons.append("clock_rollback")
    if dt >= 0 and dt < cfg["minDeltaMs"]:
        reasons.append("too_fast")
    if nxt["gold"] > cfg["goldMax"]:
        reasons.append("gold_ceiling")
    if nxt["level"] > cfg["levelMax"]:
        reasons.append("level_ceiling")
    if nxt["energy"] < 0 or nxt["energy"] > 100:
        reasons.append("energy_range")
    if nxt["gold"] < prev["gold"]:
        reasons.append("gold_regression")
    if nxt["level"] < prev["level"]:
        reasons.append("level_regression")
    if dt > 0:
        allowed = (cfg["goldPerMinute"] * dt) // 60000
        if nxt["gold"] - prev["gold"] > (allowed * (100 + cfg["tolerancePct"])) // 100:
            reasons.append("gold_inflation")
    if nxt["offlineClaimedMs"] < 0:
        reasons.append("offline_negative")
    if nxt["offlineClaimedMs"] > cfg["offlineCapMs"]:
        reasons.append("offline_over_cap")
    return reasons


def draw_svg(path, rows):
    """纯 Python SVG:每场景一根条,拦住的涂蓝、漏掉的涂红。"""
    W, ROW, PAD, BAR = 720, 34, 190, 300
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(78 + ROW * len(rows)), "viewBox": f"0 0 {W} {78 + ROW * len(rows)}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(78 + ROW * len(rows)), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        "各作弊场景:本地校验拦住(蓝)/ 漏掉(红)"
    for i, r in enumerate(rows):
        y, caught = 42 + i * ROW, bool(r["reasons"])
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = r["id"]
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "height": "18", "rx": "4",
                                    "width": str(BAR if caught else (BAR * 35) // 100),
                                    "fill": "#2563eb" if caught else "#dc2626"})
        ET.SubElement(svg, "text", {"x": str(PAD + BAR + 8), "y": str(y + 20), "font-size": "11",
                                    "fill": "#6b7280"}).text = ("拦住:" + ",".join(r["reasons"])) if caught else "漏掉"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = load(args.config)
    check, base = cfg["check"], cfg["baseline"]

    rows = []
    for s in cfg["scenarios"]:
        nxt = {"gold": base["gold"] + s["goldDelta"], "level": base["level"] + s["levelDelta"],
               "energy": s["energy"], "lastSeenMs": base["lastSeenMs"] + s["deltaMs"],
               "offlineClaimedMs": s["offlineClaimedMs"]}
        rows.append({"id": s["id"], "kind": s["kind"], "deltaMs": s["deltaMs"], "goldDelta": s["goldDelta"],
                     "reasons": validate(base, nxt, check)})

    cheats = [r for r in rows if r["kind"] != "正常"]
    caught = [r for r in cheats if r["reasons"]]
    missed = [r for r in cheats if not r["reasons"]]
    false_positive = [r["id"] for r in rows if r["kind"] == "正常" and r["reasons"]]
    report = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "cheatTotal": len(cheats),
              "caught": len(caught), "missed": len(missed), "caughtPct": (len(caught) * 100) // len(cheats),
              "falsePositive": false_positive, "missedIds": [r["id"] for r in missed], "scenarios": rows}
    os.makedirs(args.out, exist_ok=True)
    report["svg"] = os.path.join(args.out, "e08_anticheat.svg")
    report["svgBytes"] = draw_svg(report["svg"], rows)
    with open(os.path.join(args.out, "e08_anticheat.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "e08_anticheat_scenarios.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "kind", "deltaMs", "goldDelta", "caught", "reasons"])
        for r in rows:
            w.writerow([r["id"], r["kind"], r["deltaMs"], r["goldDelta"], bool(r["reasons"]), "|".join(r["reasons"])])
    print(json.dumps({k: report[k] for k in ("cheatTotal", "caught", "missed", "caughtPct", "missedIds", "falsePositive")},
                     ensure_ascii=False))
    return 0 if not false_positive else 1


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出是这样(同机相对量级,绝对数值不可移植):

{"cheatTotal": 8, "caught": 6, "missed": 2, "caughtPct": 75, "missedIds": ["crafted_consistent", "replay_old_save"], "falsePositive": []}

逐场景明细 out/e08_anticheat_scenarios.csv 更值得看:

id,kind,deltaMs,goldDelta,caught,reasons
legit_play,正常,120000,2400,False,
clock_rollback,改时间,-60000,0,True,clock_rollback
clock_jump_fast,改时间,200,0,True,too_fast
gold_regression,改存档,60000,-3000,True,gold_regression
gold_ceiling,改存档,60000,2000000,True,gold_ceiling|gold_inflation
gold_inflation,改存档,60000,500000,True,gold_inflation
offline_over_cap,改存档,60000,1000,True,offline_over_cap
crafted_consistent,改存档,600000,12000,False,
replay_old_save,重放请求,300000,6000,False,

数字读出三件事。第一,八种作弊本地拦住六个(caughtPct 75)。 按模式拆:改时间 2/2 全拦(回拨报 clock_rollback、间隔过短报 too_fast);改存档 4/5(倒退、越上限、巨量增量、离线越顶都被抓);重放请求 0/1(一条没拦住)。falsePositive 是空数组——正常一局没有被误伤,这说明阈值定得不紧。

第二,gold_ceiling 一行同时触发两条规则。 改到 2000000 既越过 goldMax(gold_ceiling),又远超过那一分钟的产出上限(gold_inflation)。原因数组把两条都记下来,正是「原因全收集」的价值——朴素实现只报第一条,风控就少了一个信号。

第三,漏掉的两条恰好是「内部自洽」的伪造。 crafted_consistent 把时间前推 10 分钟、金币增量正好等于速率上限(12000,恰好在 14400 的容忍上限内)、等级与离线时长都在界内——本地校验一路通过。replay_old_save 更隐蔽:它是一份「时间在推进、增量也合理」的重放提交,本地没有「上一次提交」之外的任何基准,根本无从判断它是重放。这两条不是「阈值没调好」,而是「客户端拿不到判断它们所需的信息」。

权威必须在服务端

把上面的话收成一条纪律:客户端校验只提高作弊成本,不能替代服务端权威。

理由是结构性的,不是实现问题。客户端校验的输入是「玩家控制的那份存档」,攻击者能改它,也就能让它的字段互相自洽。客户端手里没有一个「独立于玩家控制的基准」,所以它判断不了来源可信性,只能判断内部一致性。而服务端手里有上一次提交时的存档——那份记录在服务器上、玩家改不了——它才能回答「这次提交相对上次是不是合理的」。这不是「服务端校验更严格」,而是「服务端有一个客户端伪造不了的基准」。

落到《合成小岛》的具体做法上,就是把校验分成两层:客户端做廉价的、能立刻反馈的一致性检查(改本地存档能立刻被本地拦下,给玩家即时反馈,也过滤掉最粗糙的改法);服务端做权威结算——金币、等级、付费、排行这些「值钱」的数据,最终以服务端的记录为准。客户端校验负责让作弊「不划算」,服务端权威负责让作弊「不可能」。第 10 章的平台能力抽象层会把「哪些数据走服务端」这件事落到具体接口上。

最后一句提醒:离线收益封顶这类规则,客户端和服务端必须用同一份参数。如果客户端封 8 小时、服务端封 24 小时,就会出现「客户端算出来能领、服务端拒绝」的诡异体验。8.1 的「一份 config 三种消费」在这里同样适用——封顶值只该有一个来源。

阅读导航:上一节:存档结构与版本迁移 · 下一节:对象池设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制