从代码层面浅析海岛奇兵 “814事件” Keycard 刷取 Bug
yuhao7370
2026年08月20日 16:52
收录于文集
共2篇
海岛奇兵BoomBeach

本文仅用于技术研究、漏洞复盘与软件工程讨论,不提供可用脚本,也不建议尝试任何可能影响游戏公平性、账号安全或服务器数据的操作。

一些名词

Keycard 在客户端内部的资源名为 DraftPick,主要用于小队长招募。

COE(Cycle of Evil,邪恶事件循环)中的恐怖博士、克隆岛、战争工厂等事件,到达一定阶段之前,未通过的阶段可以奖励 Keycard。同时,客户端还存在一套历史 Keycard 补发机制

问题出在:客户端判断谁有资格补发的状态,和最终把奖励写给谁的对象,并没有始终绑定在一起。

实际上客户端至少存在两套相关标记。这里暂时只讨论针对老玩家的 bug 触发链路。

1. Avatar commodity 0x1A2

正常完成当前邪恶事件第 7 阶段时,客户端会检查 Avatar 上的通用状态:

代码块
PlainText
自动换行
复制代码
commodity[0x1A2] < baseStage + 7
复制成功

它用于判断当前阶段的 DraftPick 是否已经结算。

2. COE event record 的 hct

历史补发走的是另一套逻辑。每个 COE 事件记录会从 JSON 中读取:

代码块
JSON
自动换行
复制代码
"coe": {
  "ed": [
    {
      "i": 41000006,
      "p": 15,
      "d": false,
      "hct": 0
    }
  ]
}
复制成功

对应到内存结构:

代码块
PlainText
自动换行
复制代码
record + 0x0C = p
record + 0x10 = hct
复制成功

其中:

  • p:该邪恶事件已经推进到的进度;

  • hct:历史 Keycard 已经补发到的位置;

  • i:事件数据 ID;

  • d:事件状态标记。

历史补发函数关注的是 p 与 hct 之间是否存在缺口,而不是先检查自家账号的 0x1A2。

其简化逻辑类似:

代码块
PlainText
自动换行
复制代码
lastStage = p + eventBase - 1

if hct < lastStage:
    for stage in hct + 1 ... lastStage:
        total += DraftPickReward(stage)
复制成功

因此,即使当前玩家早已领取过自己的补发,只要客户端此刻读取的是另一个玩家较旧的 hct,历史补发检查仍然可能返回正数。

那么,长期离线玩家为什么成为关键

客户端在创建一条 COE event record 时,会把 hct 默认初始化为 0;只有 JSON 中确实包含 hct 时才覆盖它。

这意味着:

  • 较新、持续上线的账号,通常已经完成数据迁移,hct 会跟随进度推进;

  • 长期离线账号可能保留旧快照,出现 p 很高但 hct=0 的状态。

对游戏采样结果如下:

这些 ID 依次对应 Hammerman、Dr. T Tropical/Volcano、Gearheart、Imitation Game 及另一组 Dr. T 事件。 (哈莫曼,小博士大博士,工厂,克隆岛,小博士大博士)

长期离线玩家的 7 条记录全部为:

代码块
PlainText
自动换行
复制代码
p > 0
hct = 0
复制成功

活跃玩家的 7 条记录则全部满足:

代码块
PlainText
自动换行
复制代码
hct = p + 6
复制成功

这正是“历史进度已经完成迁移”的形态。

“找长期离线玩家”可以让 hct 满足 hct = 0 的条件。

0x2BE:被延迟执行的一次性补发命令

当玩家点击邪恶事件时,客户端会先检查是否存在历史 Keycard 可补发。

如果计算结果大于 0,客户端不会立刻发奖,而是创建一个内部类型为 0x2BE 的逻辑命令,放入 LogicCommandManager 队列。

关键代码可以简化成:

代码块
PlainText
自动换行
复制代码
amount = CalculateMissedCoeDraftPickRange(currentLevel)

if amount > 0:
    enqueue(CommandType 0x2BE)
复制成功

这个命令非常特殊:它没有保存账号 ID、Avatar ID、补发区间、奖励数量或唯一领取流水号。它真正执行时,会再次从“当前 Level”中读取状态并重新计算奖励。

更关键的是,它的状态许可为:

代码块
PlainText
自动换行
复制代码
Home  = 允许执行
Visit = 禁止执行
Attack = 禁止执行
复制成功

这解释了为什么 bug 对操作速度非常敏感,稍慢一点就会卡失败。

旧客户端中,从他人资料点击“眼睛”后,画面可能已经退回海域,但逻辑状态仍短暂保留在 Visit。

此时点击邪恶事件:

  1. 客户端读取的是被访问玩家的 COE record;

  2. 长期离线玩家的 hct=0 让补发预检查返回正数;

  3. 0x2BE 命令被成功放入队列;

  4. 但 Visit 状态不允许它执行。

快速点击指南针后,状态切换到 Home,命令获得执行许可。与此同时,奖励接收 Avatar 已经切换为自己的 Primary Avatar,而 COE manager 在旧客户端中仍可能保留被访问玩家的数据。

于是形成了最关键的混合状态:

代码块
PlainText
自动换行
复制代码
奖励接收者 = 自己
补发资格   = 长期离线玩家的 p/hct
复制成功

如果返回慢了,命令会在 Visit 状态中先到达执行器。客户端中存在一条非常直接的错误文本:

代码块
PlainText
自动换行
复制代码
Execute command failed! Command is not allowed in visit state.
复制成功

这与玩家看到的错误高度吻合。

奖励为什么会落到自己的账号

GetOwningPlayerAvatar(level) 会根据 GameMode 选择不同 Avatar:

代码块
PlainText
自动换行
复制代码
Home  -> Primary Avatar
Visit -> Secondary Avatar
复制成功

历史补发执行时的逻辑大致为:

代码块
PlainText
自动换行
复制代码
selfAvatar = GetOwningPlayerAvatar(level)
coeManager = level->coeManager

record = coeManager.findRecord(currentEvent)
amount = calculate(record.p, record.hct)

selfAvatar.commodity[0x1A2] = lastStage
record.hct = max(record.hct, lastStage)
selfAvatar.DraftPick += amount
复制成功

这里出现了一个典型的软件工程问题:

  • selfAvatar 是根据执行时的 GameMode 动态选择;

  • coeManager 属于 Level 上另一个独立状态对象;

  • 命令没有把二者绑定为同一个不可变玩家上下文。

这本质上是一个 TOCTOU(检查时与使用时不一致)+ 跨上下文状态污染 问题。

为什么能重复领取

一次补发执行后,客户端会推进当前内存中那条 foreign record 的 hct。

但“查看其他玩家”本质上应该是只读快照。这次本地修改不会替被查看玩家永久更新服务器数据。

当玩家再次访问同一个长期离线账号时,服务器仍然会发回原来的:

代码块
PlainText
自动换行
复制代码
p > 0
hct = 0
复制成功

于是同一批历史奖励又会被判断为“尚未领取”。

只要旧客户端还能重复制造混合上下文,就可以重复触发同一批一次性补发。


如何看待“814事件”

从玩家视角看,这是一场突然出现的“福利狂欢”;从运营视角看,它会直接破坏抽卡资源、排行榜和账号价值;从技术视角看,它则是一个非常典型的状态管理事故。对于这坨陈年屎山,海岛奇兵的开发者又将如何应对?