跳转至

客户端威胁模型

改金币为什么能成功

玩家把金币从 1200 改成 999999,界面显示正常。如果服务器接收客户端上报的余额,这次修改会直接写进账号;如果服务器自己保存余额,下一次同步就会把客户端数值覆盖掉。

这只能说明金币没有完全交给客户端。伤害、掉落、冷却和领奖请求仍然可能由客户端决定。继续查下去,常见的情况是购买时客户端传价格,战斗时客户端传伤害,结算时客户端传分数。表面上有服务器,关键结果还是客户端说了算。

所以先不要问“应该加什么反作弊”。先逐个确认:这个结果由谁计算,客户端能改到哪一步,服务器有没有能力重新算一遍。

游戏形态决定权威边界

不同游戏需要的服务器权威不一样。把所有逻辑都搬到服务器会拖慢开发,也会增加延迟;把战斗全部留在客户端,再靠检测兜底,通常已经太晚。

游戏形态 必须由服务器决定 客户端可以做什么 只靠检测会怎样
单机离线 一般没有服务器结果,存档损坏和误改更常见 本地模拟、存档、设置 内核级反作弊成本通常高于损失
联机 PvE 背包、货币、掉落、交易、共享世界状态 移动预测、动画、特效、部分低风险伤害 改掉落和重复领奖仍然能破坏经济
竞技 PvP 命中、伤害、位置、弹药、冷却、比赛结果 输入采集、预测、插值、表现 客户端先算出 9999 伤害,事后检测只能追责
异步排行榜 分数来源、提交次数、成绩校验 本地试玩、录像、延迟上传 只上传一个分数,无法区分修改和正常成绩
WebGL / 小游戏 购买、奖励、分数、账号状态 展示、输入、轻量预测 JS 和请求对玩家基本可见,混淆不改变结果

判断方法很直接:如果玩家在本地把某个值改掉,服务器能否在不信任这个值的情况下重新得出结果。不能,就说明这个结果不该由客户端单独提交。

输入和结果要分开

客户端适合发送“发生了什么输入”,服务器负责决定“产生了什么结果”。

玩家按下攻击键
  → 客户端发送输入序号、时间、朝向、按键状态
  → 服务器检查角色状态、冷却、弹药、位置和射线
  → 服务器计算命中、伤害和消耗
  → 广播结果,客户端校正表现

输入本身也可能被伪造,服务器仍要检查数值范围和发生条件,但不需要相信客户端算出的伤害。这个划分比“客户端只做表现”更实际:客户端需要预测,否则操作会有明显延迟,只是预测结果不能成为最终事实。

协议里最好把输入、结果和表现分开。输入带 sequence、客户端 tick 和持续时间;结果带服务器 tick 和权威状态;表现层只消费结果。这样出问题时能判断是输入丢了、服务器拒绝了,还是客户端预测回滚了。

攻击者通常从哪里进入

攻击不一定从内存开始。先看攻击成本和收益,往往会发现协议和发布流程更容易下手。

阶段 常见做法 能拿到什么
静态检查 解包、找字符串、看资源路径和接口字段 逻辑入口、配置格式、接口地址
动态调试 附加调试器、下断点、看日志 参数、分支条件、密钥出现的位置
修改发布物 改配置、改脚本、重签名、替换热更文件 启动检查、默认值、可执行代码
注入与 Hook DLL、.so、dylib、Frida、LSPosed 函数返回值、运行时对象、协议明文
内存修改 数值搜索、AOB 扫描、指针链 血量、金币、冷却、移速
协议重放 抓包、改参数、重复请求 重复领奖、伪造操作、绕过冷却

注入和内存修改看起来更“高级”,但重复请求只需要抓到一个合法包。购买、领奖、升级、抽卡这类有持久化结果的接口,首先要防重放。

这些攻击面对应的存储、网络和平台交互检查,可以对照 OWASP MASVS 的分类逐项核对。1

领奖接口为什么必须先做幂等

假设领奖接口只检查“任务是否完成”,没有记录这次奖励是否已经发过。玩家抓到请求后连续发送十次,服务器每次都能看到任务完成,就会发十次奖励。加频率限制只能减慢发送速度,不能解决重复到账。

更合理的流程是:

  1. 客户端只提交任务 ID 和请求 ID,不提交奖励内容。
  2. 服务器确认任务状态、完成时间和服务端记录。
  3. 服务器生成或使用已有的 claim_key,并把它和账号绑定。
  4. 在同一事务里写入领取记录、发放奖励,再把记录标记为完成。
  5. 重复请求返回已有结果,不再次发放。

claim_key 不能由客户端自由填写。可以用服务端订单号、平台交易号,或者服务端生成后返回给客户端的领取凭证。客户端自己生成的 UUID 可以被换掉,重新发一次就绕过了。

数据库要有唯一约束,而不是只靠代码里的 if:

CREATE UNIQUE INDEX ux_reward_claim
ON reward_claims(account_id, claim_key);

第一次请求插入成功,第二次请求会因为唯一索引失败。此时不要返回“系统错误”,而是读取已有记录,返回相同的发放结果。这样才能区分“已经领过”和“这次没领到”。

如果发奖要和日志、邮件、第三方支付对账,事务边界要提前想清楚。至少保证奖励记录只有一个来源,不要先加金币再写领取记录;进程在中间退出时,重试会重复发放。

购买流程同样如此。平台收据必须由服务器向平台验证,商品内容从服务器商品表读取。客户端传来的价格、数量、币种和商品名称只能用于日志。

移动校验:拒绝异常,不要偷偷夹回去

位置校验不是把坐标夹到地图边界就结束。修改器可以持续发送小步瞬移,也可以在合法范围内加速。

服务器收到移动输入后,至少检查:

  • 输入序号是否重复或倒退。
  • elapsed 是否为正数,是否超过最大允许间隔。
  • 坐标和方向是否包含 NaN、Infinity。
  • 单位时间内的位移是否超过角色最大速度。
  • 角色是否处于禁止移动、眩晕或传送状态。
  • 连续输入的数量是否超过心跳能产生的合理值。
public MoveResult ValidateMove(
    MoveInput input,
    PlayerState player,
    float serverTime)
{
    if (input.Sequence <= player.LastMoveSequence)
        return MoveResult.Rejected("stale_sequence");

    if (!float.IsFinite(input.Elapsed) ||
        input.Elapsed <= 0 ||
        input.Elapsed > 0.5f)
    {
        return MoveResult.Rejected("invalid_elapsed");
    }

    if (!IsFinite(input.Position) ||
        !IsFinite(input.Direction))
    {
        return MoveResult.Rejected("non_finite_input");
    }

    var allowed =
        player.MaxSpeed * input.Elapsed * 1.1f + 0.05f;

    if (Vector3.Distance(player.Position, input.Position) > allowed)
        return MoveResult.Correction(player.Position);

    return MoveResult.Accepted(input.Position);
}

这里的 1.1f 和 0.05f 是给网络抖动和浮点误差留的容差,不是固定值,要按角色速度、输入间隔和碰撞体大小实测。容差太大,连续小步就能加速;容差太小,延迟高或掉帧的玩家会被反复拉回。

异常时返回权威位置让客户端校正,比静默修改坐标更好排查。静默夹取会让客户端和服务器的轨迹慢慢分开,最后表现为“我明明进了门,服务器说我在门外”。

服务器还要限制输入频率。只检查单次位移,玩家仍然可以用更高的发送频率把速度叠上去。输入序号、服务器时间和单位时间内的移动预算要一起看。

射击校验:服务器重算命中

客户端可以发“朝向哪里开火”,不能发“我打中了谁,造成多少伤害”。

public ShotResult ResolveShot(
    ShotInput input,
    PlayerState player,
    WorldState world,
    float serverTime)
{
    if (!player.IsAlive ||
        !world.TryGetWeapon(player.WeaponId, out var weapon))
    {
        return ShotResult.Rejected("invalid_state");
    }

    if (serverTime < player.NextFireTime)
        return ShotResult.Rejected("fire_rate");

    if (!float.IsFinite(input.Direction.x) ||
        !float.IsFinite(input.Direction.y) ||
        !float.IsFinite(input.Direction.z) ||
        input.Direction.sqrMagnitude < 0.99f ||
        input.Direction.sqrMagnitude > 1.01f)
    {
        return ShotResult.Rejected("invalid_direction");
    }

    var hit = world.Trace(
        player.EyePosition,
        input.Direction,
        weapon.Range);

    player.NextFireTime = serverTime + weapon.FireInterval;
    return world.ApplyDamage(player, hit, weapon.Damage);
}

这段代码只覆盖最基本的状态检查。实际还要处理弹药扣除、换弹中断、武器切换、射击起点是否穿墙以及延迟补偿。延迟补偿不能直接相信客户端上报的旧位置;服务器要保存自己的历史快照,并在有限窗口内查找。

射速检查必须放在服务器时间上。客户端可以伪造本地 tick 或把多个射击输入打包在同一帧发送,服务器按到达时间逐个处理时就会限制射速。

投射物同理。服务器可以重新模拟弹道,也可以只校验发射起点、方向、速度和目标状态,但最终伤害不能在客户端算完再上报。

本地存档能保护什么

单机游戏可以让玩家改存档。增加校验、压缩和加密,主要能减少误改、损坏和简单复制,不能阻止有权限的玩家修改存档。

如果单机成绩要上传排行榜,或者存档要跨设备同步,存档就必须经过服务器校验。可选做法包括:

  • 服务器按固定规则重算分数,客户端只上传必要输入。
  • 上传带随机种子的录像或操作摘要,服务器复算关键节点。
  • 只允许服务器生成的高价值物品进入账号仓库。
  • 对可疑成绩要求更多数据,而不是直接接受。

对本地存档做 HMAC 仍然需要把密钥放在客户端。玩家可以找到密钥并重新签名,所以它只能提高最低门槛。

各平台能依赖什么

平台能力适合判断“这是不是我发布的安装包”,不适合证明“运行过程中没有被注入”。两者要分开。

平台 可以依赖 不能单独依赖
Windows 安装和更新时的 Authenticode、MSIX 或商店签名 运行时模块列表、调试器标志、驱动检测;管理员可以绕过,覆盖层和杀毒软件也会误报
Android 安装包签名证书、Google Play Integrity 的服务端判定 Root 路径、模拟器型号、包名列表;定制系统、隐藏工具和虚拟多开会让这些信号失真23
iOS 系统代码签名、App Attest 的服务端判定 越狱文件、固定设备 ID、get-task-allow 字符串;越狱环境可以隐藏或伪造4
WebGL / 小游戏 服务端会话、购买和结果校验 JS 混淆、前端存储、客户端计时器;代码和请求基本都在玩家手里
主机 平台认证、补丁签名、平台方检测结果 自研内核驱动和跨平台 Hook 检测;通常也不符合平台审核要求

Play Integrity 和 App Attest 返回的是平台签名的证明,必须由服务器验证,不能由客户端解析后说“通过”。它们也有设备、地区和账号条件,失败不一定等于作弊。具体接入要以平台文档和目标系统版本为准。24

Windows 的 Authenticode 能说明文件由谁签发,但如果玩家在进程启动后改内存,签名文件本身仍然是合法的。运行时检测能补充信号,不能把签名当成运行时完整性证明。5

防护顺序

1. 先确定权威结果

按前面的游戏形态列出所有能改变账号、经济和比赛结果的接口。逐个确认服务器能否重算,客户端上报的字段是否只是输入。

2. 补齐幂等、重放和频率限制

购买、领奖、升级、抽卡、交易、排行榜提交都要有唯一请求标识和服务器记录。只有限流没有幂等,仍然会重复到账。

3. 再做发布物完整性

安装包、热更清单、代码和资源分别校验。热更清单要签名,文件要按清单校验哈希,服务器还要拒绝已撤销的版本。具体做法见热更新包与资源验签。

4. 记录运行时信号,但不要单信号封号

调试器、未知模块、可执行内存和 Root/越狱都能作为信号。它们的共同问题是误报和绕过都很多。

信号 常见误报来源 适合的动作
未知模块 输入法、录屏、直播、覆盖层、杀毒软件 记录模块路径和签名,提高风险等级
调试器附加 开发包、性能分析、崩溃收集 区分开发/发布构建,限制交易或排行榜
Root / 越狱 定制系统、测试设备、企业环境 结合设备历史和账号行为复核
代码段变化 JIT、热更、重定位、保护壳 按构建和平台记录基线,不要用固定哈希

风险日志至少带 buildId、平台、系统版本、会话 ID、采样时间和信号来源。只有“riskScore=62”无法复盘,也无法判断是某个版本误报还是账号真的异常。

5. 先限制影响,再决定封禁

高风险时可以暂时关闭交易、排行榜提交和匹配,要求重新登录或进入人工审核。直接闪退会让客服没有排查入口,也会把误报玩家挡在游戏外。

封禁规则不要完整下发到客户端。客户端只需要知道当前操作被拒绝,不需要知道具体命中了哪条规则。

上线前检查

  • 客户端发送伤害值时,服务器是否完全忽略?
  • 重复发送同一个领奖请求,是否只产生一条发放记录?
  • 玩家瞬移、持续加速和超高频输入,服务器是否拒绝并下发权威位置?
  • 射击方向包含 NaN、长度异常或射速超限时,是否会在进入射线检测前被拒绝?
  • 单机存档上传排行榜时,服务器是否能复算或验证成绩?
  • 热更清单被替换、回滚或撤销后,客户端是否会拒绝加载?
  • 运行时检测出现误报时,是否有恢复账号和关闭限制的流程?
  • 日志能否还原一次请求对应的账号、版本、服务器 tick 和处理结果?

客户端安全不是把客户端变成可信环境。更实际的目标是:普通修改不能让服务器接受错误结果,高级修改需要同时改客户端、协议和服务端状态,检测结果只用于缩短影响时间。