BluffKing 三项安全挑战:源码如何防守,玩家有没有可能做到?
逐项检查提前预测公共牌、取得他人底牌与修改手牌影响结算的实现,说明可行性、局后复算边界,以及离线篡改验证的实际结果。
提前知道公共牌、看到对手底牌、把自己的手牌改成赢家——这三件事,分别考验随机数、信息隔离和服务端结算。BluffKing 本期安全悬赏为每项经复现确认的挑战提供人民币 10,000 元奖励。
本文按这三个问题检查默认德州扑克密封发牌链路,依据 2026 年 10 月 26 日源码快照 78f381f46c8970f8478c903ed1f6e54ea8a71f0c,并运行仓库自带的离线验证程序。这里没有完成线上攻击复现,也没有确认线上运行二进制与该快照一致。结论不推广到全部游戏、锦标赛或高级加密模式。
01 提前预测公共牌
玩家能否在服务器正式翻牌前,提前知道这一手将出现的准确公共牌,包括花色和点数?
源代码怎么做的?
默认流程先由独立发牌进程生成 32 字节的秘密随机种子 server_seed,来源是操作系统随机数 OsRng。开局公布的是带域分离的 SHA-256 承诺值,种子本身留在服务端。
支持该协议的玩家客户端也可以贡献随机数,先交承诺,再公开自己的种子。最终洗牌种子由服务端种子、按座位排序的已接收玩家种子和本手 hand_id 共同派生。客户端超时可能没有贡献,因此不能假定每手都混入了玩家随机数。
洗牌使用完整的 256 位种子初始化 ChaCha20Rng,再执行 Fisher–Yates;交换位置通过拒绝采样选择,避免直接取模产生偏差。牌序确定后,公共牌由游戏循环根据当前阶段向发牌进程请求,客户端消息只带已经揭示的公共牌。对应源码见文末 [1]–[4]。
是否有可能做到?
在秘密种子没有泄露、操作系统熵源和密码算法正常的前提下,本次检查没有从这条实现链路找到普通玩家提前算出准确公共牌的可行方法。知道洗牌算法、手牌编号、自己的随机数或此前牌局结果,仍然缺少本手的秘密输入。胜率预测也不能给出确定的花色和点数。
理论上猜中当然可能,但一次猜中不能证明掌握了未公开信息;挑战证据需要把预测绑定到具体手牌,并证明提交发生在正式揭牌之前。
如果秘密种子泄露、主机被控制或出现新的越权漏洞,结论会变化。发牌进程本身持有明文牌堆,所以进程隔离不能被解释成运营方在任何情况下都无法知道牌。种子承诺也不单独排除运营方选择性中止牌局等行为。
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♠ | 拒绝,底牌不一致 |
| 替换服务端种子,保留原承诺 | 拒绝,承诺不一致 |
| 删除全部底牌和公共牌 | 拒绝,没有可比对牌 |
这组结果表明离线验证器能识别这些记录篡改。它没有经过线上下注和账户结算,不能拿来声称第三项挑战已经完成服务端实测。示例使用固定种子,也不能证明线上随机数质量。自行核验真实牌局的方法见如何独立验证一手牌。
对应源码
路径以本次审查仓库为准;已公开的对应文件链接固定到公开源码快照,未附链接的条目保留服务端审查定位。
server/src/pf_dealing.rs—os_random_32 / seed_commit / derive_deck_seedengine/src/rng.rs—PokerRng::from_seed_bytesengine/src/deck.rs—Deck::new / rng_rangeserver/src/sealed_dealer.rs—SealedHand / sync_tickets / deliver_holes / ActiveHand::finishserver/src/protocol/translation.rs—translate_for / snapshot_for_user_with_equities_fromserver/src/sealed_dealer/authorization.rs—guard / signserver/src/session/fair_dealing.rs—pf_emit_revealserver/src/handlers/hands_fairness.rs—get_hand_fairnessserver/src/protocol/client_commands.rs—ClientCmd::Actionserver/src/ws/socket.rs—sender_user_idserver/src/session/action_wait.rs—sender_valid / decision_epochengine/src/game/pots.rs—finish_blindmental-poker/src/bin—pf_demo_hand / pf_verify