德州三项安全验证:提前读牌、查看底牌和改牌结算
本地实测:持有进程调试权限时,公共牌和底牌可提前读出;交换牌序后,真实服务端结算拒绝。附源码、命令和原始记录。
本地实测结果:有进程调试权限时,能在翻牌前读出准确公共牌,也能读出另一位玩家的底牌;只交换发牌进程里的牌序,服务端结算会拒绝。下面列出三项操作、实际牌面、拒绝原因和复现材料。
前提:需要服务器权限吗?为什么没有内存 API 也能读牌?
本次实验在本机 macOS 上完成,没有登录或读取腾讯云生产服务器。若要在部署中的发牌服务上复现同样的调试方法,需要能在发牌进程所在机器上执行程序,并拥有操作系统授予的目标进程调试/内存访问权限。仅能登录腾讯云控制台、游戏后台,甚至仅能 SSH 登录机器,都不一定具备这些权限。本文没有证明普通玩家可以远程获得这些权限或读取生产牌组。
curl 是 HTTP 客户端:它把请求发给服务端,由接口处理代码执行操作,可能改变内存中的业务状态;它本身不能直接读写服务端任意内存。此前若通过 curl 改变了某项状态,真正执行修改的是对应接口的服务端代码。
这次读写牌组使用的是 LLDB 调试器。它通过操作系统提供的调试机制访问目标进程,无需应用提供读写内存 API;本次还临时增加观察日志,直接给出了牌组缓冲区地址,便于定位。实验仍使用正常的签名 HTTP 发牌调用,“没有内存 API”不等于“没有 HTTP 接口”。
实验环境与权限
测试于 2026 年 11 月 1 日在本机 macOS 上完成,使用 LLDB、真实的默认发牌服务、下注引擎和 ActiveHand::finish() 结算函数。账号获得了操作系统允许附加到该实验进程的调试权限;普通后台登录不会授予这项权限。这里复现的是控制发牌进程后的能力,悬赏中的普通玩家/观战者权限是另一项前提。
实验运行三手独立的双人牌局:正常结算对照、翻牌前读取、内存换牌。每人起始 200 筹码,大小盲 1/2,随后跟注或过牌到摊牌。没有连接数据库或写钱包。为了准确定位内存,临时加入两条观察日志:牌组缓冲区地址,以及校验前算出的候选结算结果;原有结算检查保持原样。
实验源码基线为 e3212dc8516c3482b405f9998ff9ad96be2dbde3。本文引用的 pf2.rs 和 sealed_dealer.rs 已与这个公开版本逐字节比对一致。
先解释:牌组加密了,为什么还能从内存读牌?
默认模式由发牌服务生成加密牌组,也由它持有解密私钥和秘密洗牌对应关系。源码中的 LockedDeck.key 保存私钥,final_card_ids() 根据玩家随机数决定的顺序,返回整副牌的真实编号。发牌服务把这些编号保存到 SealedHand.deck: Vec<u8>,之后从中取出底牌和公共牌。
一个编号只占一个字节,范围是 0~51。按 card_id.rs,点数为 编号 / 4 的整数部分加 2,花色由 编号 % 4 决定:0 黑桃、1 红桃、2 方块、3 梅花。例如 24 是黑桃 8,33 是红桃 10。
LLDB 通过操作系统的进程调试机制暂停目标、读取或写入指定地址,再让进程继续。这个入口由操作系统控制,应用无需提供读写内存 API。本次程序本身保留了明文牌序,因此读到编号后即可换算牌面。
01:正式翻牌前,能知道准确公共牌吗?
本次读出了五张,而且全部与随后翻出的牌一致。手牌编号:56905c86-406e-4f08-92ac-61e4223ae1fe。
- 启动独立发牌服务,创建已承诺牌组的双人牌局。在调用任何公共牌推进操作前暂停实验流程。
- 从本手的观察日志取得进程 PID 和
SealedHand.deck缓冲区地址。附加 LLDB,读取 52 个字节。下面的 PID 和地址必须使用本次运行实际输出的值。
lldb -p <本机实验进程 PID>
(lldb) memory read --format u --size 1 --count 52 <牌组地址>
(lldb) detach
- 把读到的数据和预测牌面先保存,再解除实验流程的等待,让服务端继续发牌。
- 调用真实的
ActiveHand::advance()依次推进翻牌、转牌、河牌,最后与服务端结果中的五张公共牌逐张比较。
源码规定:有 n 个座位时,五张公共牌位于 deck[2n..2n+5]。本手 n=2,所以读取下标 4~8,得到 [24, 33, 28, 5, 30]。
| 位置 | 翻牌前读到 | 服务端实际揭示 |
|---|---|---|
| 翻牌 1 | 8♠(24) | 8♠(24) |
| 翻牌 2 | 10♥(33) | 10♥(33) |
| 翻牌 3 | 9♠(28) | 9♠(28) |
| 转牌 | 3♥(5) | 3♥(5) |
| 河牌 | 9♦(30) | 9♦(30) |
02:能拿到另一位玩家的未公开底牌吗?
本次在摊牌前读到了,两张都与随后摊牌一致。使用上面同一手的内存读取结果,没有再调用别人的取牌接口。
- 按冻结后的座位顺序确定玩家位置
p,从 0 开始。 - 按真实发牌规则取
deck[p]和deck[n+p]。双人局中,座位 0 对应下标 0、2;座位 1 对应下标 1、3。 - 先保存两人的底牌预测,再继续下注流程,最后与服务端的摊牌结果比较。
| 座位 | 提前读到的编号 | 牌面 | 实际摊牌 |
|---|---|---|---|
| 0(玩家 1) | 36、22 | J♠、7♦ | 两张一致 |
| 1(玩家 2) | 6、21 | 3♦、7♥ | 两张一致 |
这里验证的是读取真实发牌进程的能力。正常取牌接口仍会用票据确定座位,只返回对应座位的牌;操作系统调试权限允许直接读取接口背后的进程内存。
03:改自己的手牌,能让服务端按改过的牌结算吗?
这次交换牌序内存的尝试被服务端拒绝。换牌手牌编号:248a71f6-557b-4b7d-8fd9-55f26b46f558,与前面的读牌手牌不同。
- 先跑正常对照手牌,完整经过下注、摊牌和
ActiveHand::finish(),确认能够返回有效结算。 - 创建新的已承诺牌组,在开局后暂停流程。这手的前两个编号是
[33, 40],分别是座位 0 和座位 1 的第一张底牌。 - LLDB 直接把这两个字节交换为
[40, 33],将红桃 10 与黑桃 Q 对调。读取全部 52 字节回查,确认其余 50 个字节不变。
# 以下数值属于本文记录的这手牌;重新运行时用实际读到的两个编号。
(lldb) memory write --format u --size 1 <牌组地址> 40 33
(lldb) memory read --format u --size 1 --count 52 <牌组地址>
(lldb) detach
- 让实际发牌服务继续运行。牌面通过原来的签名 HTTP 路径进入引擎,继续跟注或过牌直到摊牌。
- 调用真实结算函数,记录其返回值。程序能算出候选胜负,但结算检查返回错误,结果没有被接受。
BASELINE_ACCEPTED
MEMORY_SWAP [33, 40] -> [40, 33]
TAMPER_REJECTED
reason=sealed recovery commitment mismatch
拒绝发生在服务端。ActiveHand::finish() 调用 validate_final_deck(),重新计算包含手牌编号、内部种子、nonce 和全部 52 张牌的 SHA-256 承诺。交换两张牌后,仍然是一副完整牌,但哈希与开局保存的承诺不同,函数立即返回错误。
该函数后面还会检查摊牌结果是否来自对应牌组位置,并从主种子重建已承诺牌序。本次在前一道承诺检查就被拒绝。实验只修改牌组中的两个字节,保持校验逻辑原样;没有运行钱包写入。
玩家点验证时,会发生什么?
另生成一手牌,使用真实 pf2::verify() 和客户端 compareWithEvidence() 补测:原牌与原证明完整通过;换牌后仍返回原证明,本机底牌记录与证明不一致,返回 card_mismatch;把证明中的牌面也改掉,返回 decryption proof is invalid at position 0。缺少本机记录时,返回的是较弱的 no_record,不会给出完整通过。
读取或泄露牌面没有改变牌组,所以发牌证明仍可以有效;交换牌面则会破坏与承诺或现场记录的一致性。这就是本次“读牌成功、换牌结算被拒绝”的技术原因。
公开源码与复现代码
公开仓库(固定版本)。下面的独立示例只依赖公开的 mental-poker 库:创建自己的牌组、交换本地向量中的两个编号,验证原证明仍证明原牌,而声称换牌后的证明会被拒绝。它不复现上文完整的服务端结算或 LLDB 读内存实验,也不需要私有仓库、服务器登录、数据库或密钥。
安装 Rust/Cargo,把 Cargo.toml 与 verify_swap.rs 保存到同一个新目录,再执行 cargo run。首次运行需要联网下载公开依赖,每次牌面不同。
[package]
name = "public-card-proof-demo"
version = "0.1.0"
edition = "2021"
[workspace]
[[bin]]
name = "public-card-proof-demo"
path = "verify_swap.rs"
[dependencies]
mental-poker = { git = "https://github.com/CisaSettle/bluffking.git", rev = "be969f6493a36cebc0190db5803b1f405cb8a640" }
uuid = { version = "1", features = ["v4"] }
rand_core = { version = "0.6", features = ["getrandom"] }
use mental_poker::pf2::{self, LockedDeck, Pf2Error};
use rand_core::{OsRng, RngCore};
use std::{collections::BTreeMap};
fn seed() -> [u8; 32] { let mut s=[0;32]; OsRng.fill_bytes(&mut s); s }
fn main() {
let locked=LockedDeck::new(&seed(), uuid::Uuid::new_v4());
let clients=BTreeMap::from([(0u8,seed()),(1u8,seed())]);
let mut memory_deck=locked.final_card_ids(&clients);
let bundle=locked.bundle(&clients,&[0,1],&[],&[0,1,2,3],&mut OsRng).unwrap();
pf2::verify(&bundle).expect("original proof must pass");
println!("ORIGINAL_PROOF=PASS first_two={:?}", &memory_deck[..2]);
memory_deck.swap(0,1); // Change only our own local vector.
println!("MEMORY_AFTER_SWAP={:?}", &memory_deck[..2]);
// Returning the original proof still proves the original cards, not the changed ones.
let original_verified=pf2::verify(&bundle).unwrap();
assert_ne!(original_verified.cards[&0],memory_deck[0]);
println!("UNCHANGED_PROOF=PASS but proven_position_0={} observed_position_0={} MISMATCH",original_verified.cards[&0],memory_deck[0]);
// Claiming the modified cards with the original openings is rejected cryptographically.
let mut forged=bundle.clone();
for opening in &mut forged.openings { opening.card=memory_deck[opening.position]; }
let error=pf2::verify(&forged).expect_err("changed card claim must fail");
assert!(matches!(error,Pf2Error::Opening(0)));
println!("CHANGED_CARD_PROOF=FAIL error={}",error);
}