最近在看一套游戏结算链路时,我碰到了一种情况:发奖逻辑确实在服务器,但输入奖励计算的events仍由客户端提供。客户端说敌人死了、关卡过了,服务器再按这个结果发金币,本质上还是相信了客户端。
数据上报难防的,不是把金币从500改成50000,而是攻击者让一局战斗按"修改后的规则"正常打完。
第一层:先挡住改包和重放
最直接的攻击是抓取结算请求,修改数值后重新提交。这里需要签名、时间戳、nonce和幂等校验,但事件本身也要能定位:
text
battleId | seq | clientTick | action | targetId
battleId由服务器在开局时下发,seq检查重复和乱序,服务器另外记录recvTime。已经结算的battleId再次提交时,应返回原有结果,而不是重复发奖。
这层解决的是"请求有没有被直接篡改",并不能证明客户端生成的数据就是真的。签名函数和密钥都在客户端时,攻击者仍可能复用原有逻辑生成一条格式完全正确的请求。
第二层:别让客户端上报裁决结果
服务端重算奖励之前,先要回答一个问题:events从哪里来?
如果事件内容是"造成10000点伤害""敌人已经死亡",服务器只是把客户端给出的结论重新汇总了一遍。更合理的做法,是让客户端尽量只报操作意图:释放哪个技能、选择哪个目标。
csharp
foreach (var e in events.OrderBy(x => x.Seq))
ApplyIntent(ctx, e.Action, e.TargetId);
Grant(playerId, CalculateReward(ctx.VerifiedResult));
命中、伤害、冷却和奖励,由服务器根据角色属性、关卡配置、随机种子和战斗前快照推导。项目暂时做不到完整的服务端战斗,也应先收回货币、稀有道具、首通奖励和排行榜积分这些高价值结果。
第三层:战斗真实发生,不代表过程正常
更狡猾的方式,是不改结算包。
玩家正常开始、正常结束一局,但内存里的攻击力被调高,变速齿轮加快了本地时间,或者无敌逻辑让角色没有掉血。最终生成的事件流可能前后自洽,接口签名也完全正确,因为这局战斗确实发生过,只是执行了错误规则。
这时需要检查过程约束。比如一个冷却8秒的技能,10秒内出现5次,可以按技能分组检查:
csharp
return times[^1] - times[0] + jitterMs
< (times.Length - 1) * cooldownMs;
时间最好使用服务端接收时间,或者经过心跳校准的客户端单调时钟。冷却值必须从服务端状态读取,同时考虑减CD、技能充能和冷却重置,否则误报会比外挂先到。
沿着同一思路,还可以检查能量、血量、库存和事件顺序。变速通常表现为clientTick与服务器时间持续漂移;无敌则要对照敌方攻击、碰撞和角色血量变化。
这里有个必须承认的边界:如果敌方行为和血量变化全部由客户端裁决,服务器又没有状态快照,那么仅靠结算包无法证明玩家是否开过无敌。
第四层:不是所有对局都需要完整重演
完整重演有成本,但成本不一定高到无法接受。
按battleId、seq、clientTick、action、targetId和checkpointHash计算,一条关键事件的原始字段约34字节。每局80条,大约2.7KB。协议头和压缩率还要用真实流量测试,但这个量级至少可以作为容量评估的起点。
实践里可以分三档:
- 所有对局执行幂等、序号、值域和轻量状态校验;
- 普通对局按5%抽样做完整重演;
- 命中高风险规则的对局100%重演。
风险也别只做成一个布尔值。比如冷却冲突+40、持续时钟漂移+25、关键状态不一致+60。达到60分时延迟发奖并重演,达到100分时冻结结算并进入复核。
这些数字是便于讨论的示例,不是可以跨项目复制的标准。真正上线后,需要根据玩法、网络状况和误报率不断调整。
再往后,问题已经不是数据真假
脚本和宏可能完全不伪造数据。技能释放合法、冷却正常、伤害也由服务器计算,只是操作的人不是真人。
单看一局,服务端很难找到硬冲突,只能把观察周期拉长,检查操作间隔、路径重复、连续在线时长和多账号行为模板。走到这里,系统判断的已经不是"这条数据对不对",而是"这组行为像不像真人"。
所以,游戏数据上报可以按三条线拆开:
| 要解决的问题 | 对应手段 |
|---|---|
| 请求有没有被改或重放 | 签名、nonce、battleId、幂等校验 |
| 战斗过程是否符合规则 | 服务端状态、过程约束、抽样重演 |
| 合法操作是否来自真人 | 客户端环境信号、跨局行为检测 |
服务端校验的价值,不是让客户端变得可信,而是让客户端即使被控制,也不能靠几个字段决定奖励、排行和稀有资产。