← 返回博客

·

BluffKing 三项安全挑战:源码如何防守,玩家有没有可能做到?

逐项检查提前预测公共牌、取得他人底牌与修改手牌影响结算的实现,说明可行性、局后复算边界,以及离线篡改验证的实际结果。

提前知道公共牌、看到对手底牌、把自己的手牌改成赢家——这三件事,分别考验随机数、信息隔离和服务端结算。BluffKing 本期安全悬赏为每项经复现确认的挑战提供人民币 10,000 元奖励。

本文按这三个问题检查默认德州扑克密封发牌链路,依据 2026 年 10 月 26 日源码快照 78f381f46c8970f8478c903ed1f6e54ea8a71f0c,并运行仓库自带的离线验证程序。这里没有完成线上攻击复现,也没有确认线上运行二进制与该快照一致。结论不推广到全部游戏、锦标赛或高级加密模式。

01 提前预测公共牌

玩家能否在服务器正式翻牌前,提前知道这一手将出现的准确公共牌,包括花色和点数?

源代码怎么做的?

默认流程先由独立发牌进程生成 32 字节的秘密随机种子 server_seed,来源是操作系统随机数 OsRng。开局公布的是带域分离的 SHA-256 承诺值,种子本身留在服务端。

支持该协议的玩家客户端也可以贡献随机数,先交承诺,再公开自己的种子。最终洗牌种子由服务端种子、按座位排序的已接收玩家种子和本手 hand_id 共同派生。客户端超时可能没有贡献,因此不能假定每手都混入了玩家随机数。

洗牌使用完整的 256 位种子初始化 ChaCha20Rng,再执行 Fisher–Yates;交换位置通过拒绝采样选择,避免直接取模产生偏差。牌序确定后,公共牌由游戏循环根据当前阶段向发牌进程请求,客户端消息只带已经揭示的公共牌。对应源码见文末 [1]–[4]。

是否有可能做到?

在秘密种子没有泄露、操作系统熵源和密码算法正常的前提下,本次检查没有从这条实现链路找到普通玩家提前算出准确公共牌的可行方法。知道洗牌算法、手牌编号、自己的随机数或此前牌局结果,仍然缺少本手的秘密输入。胜率预测也不能给出确定的花色和点数。

理论上猜中当然可能,但一次猜中不能证明掌握了未公开信息;挑战证据需要把预测绑定到具体手牌,并证明提交发生在正式揭牌之前。

如果秘密种子泄露、主机被控制或出现新的越权漏洞,结论会变化。发牌进程本身持有明文牌堆,所以进程隔离不能被解释成运营方在任何情况下都无法知道牌。种子承诺也不单独排除运营方选择性中止牌局等行为。

02 取得他人底牌

玩家或观战者能否获得其他玩家尚未公开的底牌?

源代码怎么做的?

第一层是按接收者过滤消息。translate_for 处理底牌事件时,只对牌主保留牌值,其他座位得到空值;观战者不接收这条底牌事件。重连快照同样只保留本人可见的底牌。安全边界在服务端发出的数据中,而不只在前端的牌背图片上。[5]

第二层是密封发牌的取牌凭证。发牌进程为每个座位生成 32 字节随机票据。游戏服务只把对应票据发给本手实际获发牌、且仍持有原座位的玩家;后来入座的人不能因为占了同一个座位就继承上一位玩家的取牌权限。

浏览器用票据向发牌连接领取该座位底牌。发牌端按票据查找座位,不接受玩家随意指定一个座位就返回其底牌。内部取牌、翻牌和结算接口另有令牌与游戏进程签名校验。[4]、[6]

是否有可能做到?

在局中,仅修改自己的页面、查看自己收到的消息或反复重连,本次检查没有从上述路径找到读取其他玩家隐藏底牌的方法。不过票据属于持有即可领取底牌的凭证:偷到别人的有效票据、控制其设备或让对方主动共享底牌,都可能让攻击者获得信息。这些条件必须明确,不能与普通客户端具备透视能力混为一谈。

这里还有一个必须公开说明的边界:默认模式在牌局结束后,会向本手实际获发牌的参与者揭示种子。拿到种子和发牌座位顺序,就能复算整副牌,包括别人弃掉、没有在界面亮出的底牌,以及剩余牌堆。公平性导出接口也要求本手参与者身份,单纯观战或后来加入同一桌不因此获得种子。[7]、[8]

因此,如果问题指“牌局仍在进行时读取对手隐藏底牌”,本次检查没有发现可行入口;如果问题也包括“结束后复原界面没有亮出的底牌”,默认承诺—揭示机制确实允许本手参与者做到。这是源码中的隐私边界,是否符合悬赏判定需要明确时间范围,不能自行宣称已获得奖励。

默认密封发牌与高级加密发牌的信任边界不同,详见德扑发牌公平与隐私。

03 修改自己的手牌并影响服务端结算

能否让服务端接受修改后的手牌,并据此计算胜负和结算?仅修改浏览器中的显示不算。

源代码怎么做的?

普通下注消息提交的是弃牌、过牌、跟注、加注或全下,以及金额和当前决策编号,并没有让玩家指定自己底牌的字段。即使恶意客户端额外带上所谓的新底牌,正常动作处理路径也不会用它替换引擎中的手牌。[9]

身份也由服务端决定。WebSocket 从认证连接注入 sender_user_id;客户端传入的同名字段不会被用于反序列化身份。会话逻辑再检查发送者是否拥有当前行动座位,以及 decision_epoch 是否仍是当前决策,防止越位操作和旧动作重放。[10]、[11]

默认密封发牌需要摊牌时,游戏服务从发牌进程取得参赛者底牌,再交给引擎比牌。finish_blind() 要求牌局已经结束;存在争夺底池但缺少必要摊牌时返回错误,不会凭空补牌。服务端随后校验最终牌堆承诺,并核对用于结算的公共牌和摊牌是否与牌堆对应位置一致。[4]、[12]

是否有可能做到?

只修改浏览器显示或普通动作请求,本次检查没有从这条路径找到让服务器按伪造底牌结算的方法。要做到这一点,需要进一步找到能改写权威手牌状态、绕过可信发牌来源或破坏结算校验的漏洞;拥有服务端或主机控制权也会改变威胁前提。当前检查不能证明所有接口和部署配置都不存在此类问题。

离线篡改验证,实际得到什么结果?

我们运行仓库自带的 pf_demo_hand 和 pf_verify,对同一份确定性示例牌局做了四种修改:[13]

输入离线验证结果
原始示例,核对 3 个座位和 5 张公共牌通过,退出码 0
把第一张公共牌 J♦ 改成 A♠拒绝,公共牌不一致
把 0 号座位的 K♣ 改成 A♠拒绝,底牌不一致
替换服务端种子,保留原承诺拒绝,承诺不一致
删除全部底牌和公共牌拒绝,没有可比对牌

这组结果表明离线验证器能识别这些记录篡改。它没有经过线上下注和账户结算,不能拿来声称第三项挑战已经完成服务端实测。示例使用固定种子,也不能证明线上随机数质量。自行核验真实牌局的方法见如何独立验证一手牌。

对应源码

路径以本次审查仓库为准;已公开的对应文件链接固定到公开源码快照,未附链接的条目保留服务端审查定位。

  1. server/src/pf_dealing.rs — os_random_32 / seed_commit / derive_deck_seed
  2. engine/src/rng.rs — PokerRng::from_seed_bytes
  3. engine/src/deck.rs — Deck::new / rng_range
  4. server/src/sealed_dealer.rs — SealedHand / sync_tickets / deliver_holes / ActiveHand::finish
  5. server/src/protocol/translation.rs — translate_for / snapshot_for_user_with_equities_from
  6. server/src/sealed_dealer/authorization.rs — guard / sign
  7. server/src/session/fair_dealing.rs — pf_emit_reveal
  8. server/src/handlers/hands_fairness.rs — get_hand_fairness
  9. server/src/protocol/client_commands.rs — ClientCmd::Action
  10. server/src/ws/socket.rs — sender_user_id
  11. server/src/session/action_wait.rs — sender_valid / decision_epoch
  12. engine/src/game/pots.rs — finish_blind
  13. mental-poker/src/bin — pf_demo_hand / pf_verify