Skip to content

Security: jinmiaoluo/tower-defense

Security

docs/SECURITY.md

排行榜安全设计

本文档描述塔防游戏排行榜的安全实现策略。

威胁模型

核心前提:客户端不可信

游戏逻辑在客户端执行以保证流畅体验,但客户端环境完全不可信:

  • 客户端代码可被逆向、修改
  • 网络请求可被拦截、篡改
  • 系统时间可被修改
  • 内存数据可被读取、修改

攻击向量

数据伪造

  • 伪造击杀数量(声称击杀 100 只,实际 10 只)
  • 伪造金钱收益(声称获得 1000 金币,实际 100 金币)
  • 伪造得分(直接修改 scoreGained)
  • 伪造伤害数据(声称造成 10000 伤害,实际 100 伤害)

逻辑绕过

  • 跳过建筑成本扣除(免费建造)
  • 修改建筑属性(无限射程、瞬间攻击)
  • 修改怪物属性(1 血怪物、静止不动)

重放攻击

  • 重复提交同一波次数据
  • 使用他人的有效数据包

时间操控

  • 修改系统时间伪造游戏时长
  • 暂停游戏期间修改状态

多层验证架构

采用分层验证策略,每层有不同的检测目标和计算成本。

Level 1:基础验证

目标:拦截明显的数据不一致。

验证内容

  • 击杀数量一致性:sum(killedByType) == killed
  • 击杀上限:killedByType[type] <= wave_config[type].count
  • 数量守恒:killed + passed + remaining == spawned
  • 金钱收益:moneyGained == sum(killedByType[type] * monster[type].money)
  • 生成数量:spawned <= total_monsters

特点:计算简单、响应快、覆盖明显作弊。

Level 2:伤害验证

目标:检测伤害数据的合理性。

验证内容

  • 伤害下限:totalDamageDealt >= totalLifeDestroyed
  • DPS 容量验证:totalDamageDealt <= max_dps * waveDurationFrames * 1.1

DPS 容量计算

max_dps = sum(building.damage / building.attack_interval for building in validation_buildings)

注意:使用的是 validation_buildings(波次期间存在过的建筑),而非波次结束时的 submitted_buildings。因为攻击可能发生在建筑被出售之前,验证需要考虑所有参与过攻击的建筑。

10% 容差用于处理浮点精度和边界情况。

攻击事件验证

  • 伤害总和:sum(attacks.damage) == totalDamageDealt
  • 帧号时序:攻击帧号必须递增
  • 怪物 ID 验证:必须是服务端下发的 UUID
  • 射程验证:originalTargetPosition 在建筑射程内
  • 伤害值范围:1 <= damage <= building_damage_at_level
  • 累计伤害验证:被击杀怪物的累计伤害 >= 生命值
  • 路径方向验证:怪物整体移动方向应朝向出口(仅记录日志,不阻断)

射程验证说明

验证的是发射时目标位置(originalTargetPosition),而非命中时位置。这允许合法的"误伤"场景(子弹命中路径上的其他怪物)。

路径验证说明

路径验证检查怪物是否整体朝出口方向移动。由于以下原因,此验证仅记录日志而不阻断请求

  • 怪物有 10% 概率随机重新寻路
  • 玩家可以移除路障导致怪物掉头走新路径
  • 这些都是合法的游戏行为,但会导致怪物暂时远离出口
  • 应对:依赖 DPS 容量验证(绕路会增加怪物存活时间,受 DPS 上限约束)

Level 3:确定性寻路验证(未实现)

目标:消除怪物寻路的随机性,使服务端能精确重建怪物路径。

TODO 计划

  • 种子随机数生成器
    • 服务端在 firstWave/nextWave 中下发随机种子
    • 前端使用种子初始化 PRNG(如 xorshift128+)
    • 10% 重新寻路判定改用 PRNG 而非 Math.random()
    • 服务端可用相同种子重现随机序列
  • 确定性寻路验证
    • 服务端根据种子重建怪物的寻路决策序列
    • 验证客户端提供的怪物位置是否符合预期
    • 检测位置伪造和路径操控

预期收益

  • 服务端可独立计算怪物在任意帧的位置
  • 射程验证不再依赖客户端提供的位置
  • 可检测怪物位置伪造

Level 4:统计分析

目标:检测历史数据异常。

检测项

  • 击杀率突变:历史 < 50%,当前 > 95%
  • 效率突变:当前波和历史都有花费时,当前效率 > 历史效率 * 3

注意:效率检测仅在当前波次和历史波次都有建筑花费时触发。跳过检测的情况:

  • 当前无花费:玩家在前期集中投资后不再建造是正常策略
  • 历史无花费:无法计算有意义的历史效率基准

特点:仅记录日志,不阻断请求。用于事后分析和人工审核。

得分验证

得分基于攻击事件计算,而非客户端声称的值:

expected_score = sum(floor(sqrt(attack.damage)) for attack in attacks)

服务端独立计算得分,与客户端提交值比对。

入榜判定策略

判定流程

提交 /sessions/end
    +-- 验证 sessionId 有效性
    |       - 会话不存在或过期时返回错误
    +-- 如有 lastWave,执行波次验证
    |       - Level 1 -> Level 2
    |       - 任一失败则拒绝入榜
    +-- 检查最终得分
    |       - score == 0: 拒绝入榜(至少需击杀一只怪物)
    +-- 执行 Level 4 统计分析
    |       - 记录异常日志,不阻断
    +-- 创建 LeaderboardEntry
    +-- 返回排名信息

得分来源

最终得分从 WaveRecord 累加,而非客户端声称:

final_score = sum(wave_record.score_gained for wave_record in session.wave_records)

每波的 score_gained 在波次验证通过后由服务端存储,不依赖客户端累计值。

零分处理

0 分不能上榜:

  • 防止刷空记录占位
  • 确保排行榜有实际游戏内容
  • 最低要求:完成至少一次有效攻击

防重放与防刷榜

会话机制

会话唯一性

  • 每次 POST /sessions 创建新的 UUID
  • sessionId 不可预测,无法猜测他人会话

会话生命周期

  • 创建:POST /sessions
  • 更新:POST /sessions/wave(波次提交)
  • 结束:POST /sessions/end(成功后删除 session)或 24 小时过期清理

防重复提交

提交成功后立即删除 session,确保同一 sessionId 只能成功提交一次:

with transaction.atomic():
    entry = LeaderboardEntry.objects.create(...)
    session.delete()
  • 第一次提交:查询 session 成功,创建排行榜记录,删除 session
  • 第二次提交:查询 session 失败(DoesNotExist),返回 404
  • 事务保证:创建记录和删除 session 原子执行,不会出现中间状态

会话状态追踪

class GameSession:
    wave_count: int      # 已完成波次数
    next_wave: JSON      # 下一波配置(含怪物 UUID)

波次连续性

波次号验证

  • 必须连续提交:wave 1 -> wave 2 -> wave 3
  • 不能跳波:不能从 wave 1 直接提交 wave 3
  • 不能重复:wave 2 提交后不能再次提交 wave 2

实现方式

if request.wave_number != session.wave_count + 1:
    return error("波次不连续")

怪物 UUID 机制

UUID 生成

  • 每波怪物由服务端生成唯一 UUID
  • UUID 在 nextWave 中下发给客户端
  • 客户端必须使用这些 UUID 记录攻击

验证作用

  • 防止客户端凭空创造怪物
  • 确保攻击记录对应真实的服务端怪物
  • 支持累计伤害验证(追踪单个怪物受到的总伤害)

验证逻辑

for attack in attacks:
    if attack.monsterId not in wave_monster_ids:
        return error("怪物 ID 不是服务端下发的 UUID")

帧号替代时间戳

设计理由

  • 系统时间可被篡改
  • 帧号只在游戏运行时递增
  • 暂停时帧号不增长

验证作用

  • DPS 容量验证基于帧号计算实际游戏时长
  • 攻击间隔验证基于帧号差
  • 防止通过修改时间绕过验证

服务端波次生成

波次 11+ 使用随机生成算法:

  • 组大小:随机 1-3
  • 怪物类型:随机 0-8

验证作用

  • 服务端生成完整怪物配置(含唯一 ID)并下发给客户端
  • 验证时通过怪物 ID 精确匹配
  • 防止客户端伪造怪物或篡改属性

局限性与应对

已知局限

Level 3 未实现

确定性寻路验证未实现。

  • 原因:需要移除或改造 10% 随机重新寻路机制
  • 影响:射程验证依赖客户端提供的怪物位置
  • 应对:Level 2 的攻击事件验证提供部分覆盖

路径验证仅记录日志

路径验证检查怪物是否整体朝出口方向移动,但不阻断请求,仅记录日志

  • 原因:怪物有 10% 概率随机重新寻路,且玩家可以移除路障导致怪物掉头
  • 影响:无法检测怪物绕远路的操控
  • 应对:依赖 DPS 容量验证(绕路会增加存活时间,受 DPS 上限约束)

统计分析不阻断

Level 4 只记录日志,不拒绝请求。

  • 原因:避免误杀正常玩家的突破性表现
  • 影响:需要人工审核异常记录
  • 应对:建立审核机制,必要时手动删除可疑成绩

薄弱点

子弹碰撞检测

客户端负责碰撞检测,服务端无法验证子弹是否真的命中。

  • 风险:客户端可伪造命中(子弹未到达即判定命中)
  • 缓解:DPS 容量验证限制了总伤害上限

怪物位置可信度

攻击记录中的怪物位置由客户端提供。

  • 风险:客户端可伪造位置使射程验证通过
  • 缓解:路径方向验证提供部分约束

精确时序

攻击间隔验证依赖客户端帧号。

  • 风险:客户端可调整帧号间隔
  • 缓解:DPS 容量验证作为总量约束

设计权衡

安全性 vs 体验

选择客户端执行以保证流畅体验,接受无法完全防止作弊。

开发成本 vs 防护深度

跳过 Level 3 确定性寻路验证,用 Level 1 + 2 + 4 覆盖大部分场景。

防护 vs 误杀

Level 4 不阻断请求,避免误伤正常玩家。

后续增强方向

  • 实现 Level 3 确定性寻路验证
  • 引入机器学习异常检测
  • 客户端完整性校验(代码签名)
  • 建立人工审核工作流

相关文档

There aren’t any published security advisories