客户端威胁模型¶
改金币为什么能成功¶
玩家把金币从 1200 改成 999999,界面显示正常。如果服务器接收客户端上报的余额,这次修改会直接写进账号;如果服务器自己保存余额,下一次同步就会把客户端数值覆盖掉。
这只能说明金币没有完全交给客户端。伤害、掉落、冷却和领奖请求仍然可能由客户端决定。继续查下去,常见的情况是购买时客户端传价格,战斗时客户端传伤害,结算时客户端传分数。表面上有服务器,关键结果还是客户端说了算。
所以先不要问“应该加什么反作弊”。先逐个确认:这个结果由谁计算,客户端能改到哪一步,服务器有没有能力重新算一遍。
游戏形态决定权威边界¶
不同游戏需要的服务器权威不一样。把所有逻辑都搬到服务器会拖慢开发,也会增加延迟;把战斗全部留在客户端,再靠检测兜底,通常已经太晚。
| 游戏形态 | 必须由服务器决定 | 客户端可以做什么 | 只靠检测会怎样 |
|---|---|---|---|
| 单机离线 | 一般没有服务器结果,存档损坏和误改更常见 | 本地模拟、存档、设置 | 内核级反作弊成本通常高于损失 |
| 联机 PvE | 背包、货币、掉落、交易、共享世界状态 | 移动预测、动画、特效、部分低风险伤害 | 改掉落和重复领奖仍然能破坏经济 |
| 竞技 PvP | 命中、伤害、位置、弹药、冷却、比赛结果 | 输入采集、预测、插值、表现 | 客户端先算出 9999 伤害,事后检测只能追责 |
| 异步排行榜 | 分数来源、提交次数、成绩校验 | 本地试玩、录像、延迟上传 | 只上传一个分数,无法区分修改和正常成绩 |
| WebGL / 小游戏 | 购买、奖励、分数、账号状态 | 展示、输入、轻量预测 | JS 和请求对玩家基本可见,混淆不改变结果 |
判断方法很直接:如果玩家在本地把某个值改掉,服务器能否在不信任这个值的情况下重新得出结果。不能,就说明这个结果不该由客户端单独提交。
输入和结果要分开¶
客户端适合发送“发生了什么输入”,服务器负责决定“产生了什么结果”。
输入本身也可能被伪造,服务器仍要检查数值范围和发生条件,但不需要相信客户端算出的伤害。这个划分比“客户端只做表现”更实际:客户端需要预测,否则操作会有明显延迟,只是预测结果不能成为最终事实。
协议里最好把输入、结果和表现分开。输入带 sequence、客户端 tick 和持续时间;结果带服务器 tick 和权威状态;表现层只消费结果。这样出问题时能判断是输入丢了、服务器拒绝了,还是客户端预测回滚了。
攻击者通常从哪里进入¶
攻击不一定从内存开始。先看攻击成本和收益,往往会发现协议和发布流程更容易下手。
| 阶段 | 常见做法 | 能拿到什么 |
|---|---|---|
| 静态检查 | 解包、找字符串、看资源路径和接口字段 | 逻辑入口、配置格式、接口地址 |
| 动态调试 | 附加调试器、下断点、看日志 | 参数、分支条件、密钥出现的位置 |
| 修改发布物 | 改配置、改脚本、重签名、替换热更文件 | 启动检查、默认值、可执行代码 |
| 注入与 Hook | DLL、.so、dylib、Frida、LSPosed | 函数返回值、运行时对象、协议明文 |
| 内存修改 | 数值搜索、AOB 扫描、指针链 | 血量、金币、冷却、移速 |
| 协议重放 | 抓包、改参数、重复请求 | 重复领奖、伪造操作、绕过冷却 |
注入和内存修改看起来更“高级”,但重复请求只需要抓到一个合法包。购买、领奖、升级、抽卡这类有持久化结果的接口,首先要防重放。
这些攻击面对应的存储、网络和平台交互检查,可以对照 OWASP MASVS 的分类逐项核对。1
领奖接口为什么必须先做幂等¶
假设领奖接口只检查“任务是否完成”,没有记录这次奖励是否已经发过。玩家抓到请求后连续发送十次,服务器每次都能看到任务完成,就会发十次奖励。加频率限制只能减慢发送速度,不能解决重复到账。
更合理的流程是:
- 客户端只提交任务 ID 和请求 ID,不提交奖励内容。
- 服务器确认任务状态、完成时间和服务端记录。
- 服务器生成或使用已有的
claim_key,并把它和账号绑定。 - 在同一事务里写入领取记录、发放奖励,再把记录标记为完成。
- 重复请求返回已有结果,不再次发放。
claim_key 不能由客户端自由填写。可以用服务端订单号、平台交易号,或者服务端生成后返回给客户端的领取凭证。客户端自己生成的 UUID 可以被换掉,重新发一次就绕过了。
数据库要有唯一约束,而不是只靠代码里的 if:
第一次请求插入成功,第二次请求会因为唯一索引失败。此时不要返回“系统错误”,而是读取已有记录,返回相同的发放结果。这样才能区分“已经领过”和“这次没领到”。
如果发奖要和日志、邮件、第三方支付对账,事务边界要提前想清楚。至少保证奖励记录只有一个来源,不要先加金币再写领取记录;进程在中间退出时,重试会重复发放。
购买流程同样如此。平台收据必须由服务器向平台验证,商品内容从服务器商品表读取。客户端传来的价格、数量、币种和商品名称只能用于日志。
移动校验:拒绝异常,不要偷偷夹回去¶
位置校验不是把坐标夹到地图边界就结束。修改器可以持续发送小步瞬移,也可以在合法范围内加速。
服务器收到移动输入后,至少检查:
- 输入序号是否重复或倒退。
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 和处理结果?
客户端安全不是把客户端变成可信环境。更实际的目标是:普通修改不能让服务器接受错误结果,高级修改需要同时改客户端、协议和服务端状态,检测结果只用于缩短影响时间。