Three Hold’em Security Checks: Reading Cards and Tampering with Settlement
Local debugger experiments read unrevealed cards and test a deck swap against real server settlement. Includes source, commands and records.
Local results: with process-debugging permission, we read the exact community cards before reveal and another player’s hidden hole cards. Swapping two card IDs in the dealing process caused server settlement to reject the result. Here are the operations, actual cards, checks and reproduction materials.
Prerequisite: server access, and memory reads without an API
This experiment ran on our local macOS machine; it did not log in to or read Tencent Cloud production servers. Reproducing the same debugger method against a deployed dealing service requires the ability to execute code on the machine hosting the dealing process, plus operating-system permission to debug or access that process’s memory. A Tencent Cloud console login, game back-office login, or even SSH access alone does not necessarily grant those permissions. This experiment does not demonstrate that an ordinary player can obtain them remotely or read a production deck.
curl is an HTTP client: it sends requests to server handlers, whose code performs operations and may update business state in memory. It does not itself read or write arbitrary server process memory. If a prior curl request changed state, the corresponding server handler performed that change.
Here, LLDB read and wrote the deck through operating-system debugging facilities. That path needs no application memory-access API. Temporary observation logs also exposed the deck buffer address, making it easy to locate. The experiment still used normal signed HTTP dealing calls; “no memory API” does not mean “no HTTP endpoints.”
Environment and permissions
The experiment ran locally on macOS on November 1, 2026, using LLDB, the real default dealing service, betting engine and ActiveHand::finish() settlement function. The local account had operating-system permission to attach a debugger to the experimental process. A back-office login does not grant that permission. The experiment examines control of the dealing process; ordinary player or spectator access is a separate prerequisite in the bounty.
We ran three separate heads-up hands: a normal settlement control, a pre-reveal read, and a memory swap. Each player started with 200 chips, blinds were 1/2, and players called or checked to showdown. No database or wallet was connected. Two temporary observation statements reported the deck buffer address and the tentative settlement result before validation. Settlement checks were unchanged.
The tested source baseline was e3212dc8516c3482b405f9998ff9ad96be2dbde3. The cited pf2.rs and sealed_dealer.rs match this public version byte for byte.
Why does an encrypted deck remain readable in memory?
In default mode, the dealing service creates the encrypted deck and also retains the private key and secret shuffle mapping. LockedDeck.key holds the key. final_card_ids() uses the order determined by player randomness to return the actual card IDs. The service stores them in SealedHand.deck: Vec<u8> and draws hole and community cards from it.
Each ID is one byte, from 0 to 51. In card_id.rs, integer division id / 4 plus 2 gives the rank; id % 4 selects spades, hearts, diamonds or clubs in that order. ID 24 is 8♠ and ID 33 is 10♥.
LLDB uses operating-system debugging facilities to stop a process, read or write an address, and resume execution. The application does not need a memory-access API. This implementation retains plaintext card IDs, so those bytes directly reveal the cards.
01: Can the exact community cards be known before reveal?
All five were read in advance and matched the subsequent reveals. Hand ID: 56905c86-406e-4f08-92ac-61e4223ae1fe.
- Start an independent dealing service and create a committed heads-up hand. Hold the experiment before calling any board-advancement operation.
- Take the process PID and
SealedHand.deckbuffer address from this hand’s observation log. Attach LLDB and read 52 bytes. Use the PID and address printed by your own run.
lldb -p <owned local process PID>
(lldb) memory read --format u --size 1 --count 52 <deck address>
(lldb) detach
- Save the memory data and predicted cards before releasing the experiment’s wait and allowing dealing to continue.
- Use the real
ActiveHand::advance()path for flop, turn and river, then compare all five cards with the server result.
For n dealt seats, the board occupies deck[2n..2n+5]. Here n=2, so positions 4–8 yielded [24, 33, 28, 5, 30].
| Position | Read before reveal | Actual server reveal |
|---|---|---|
| Flop 1 | 8♠ (24) | 8♠ (24) |
| Flop 2 | 10♥ (33) | 10♥ (33) |
| Flop 3 | 9♠ (28) | 9♠ (28) |
| Turn | 3♥ (5) | 3♥ (5) |
| River | 9♦ (30) | 9♦ (30) |
02: Can another player’s unshown hole cards be obtained?
Both cards were read before showdown and matched the subsequent showdown. This used the same memory read as above, with no request to another player’s card-access endpoint.
- Find the player’s zero-based position
pin the frozen seat order. - Read
deck[p]anddeck[n+p]. In heads-up play, seat 0 uses positions 0 and 2; seat 1 uses positions 1 and 3. - Save both players’ predicted hole cards, continue the betting flow, and compare them with the server’s showdown result.
| Seat | IDs read in advance | Cards | Actual showdown |
|---|---|---|---|
| 0 (player 1) | 36, 22 | J♠, 7♦ | Both match |
| 1 (player 2) | 6, 21 | 3♦, 7♥ | Both match |
This exercises access to the real dealing process. The normal card endpoint still binds a ticket to one seat and returns that seat’s cards. Operating-system debugging permission gives direct access to the process memory behind the endpoint.
03: Will the server settle using altered hole cards?
This attempt to swap card IDs in memory was rejected. The swap hand was 248a71f6-557b-4b7d-8fd9-55f26b46f558, separate from the read-only hand above.
- Complete a normal control hand through betting, showdown and
ActiveHand::finish(), confirming an accepted settlement result. - Create a new committed hand and hold the flow after creation. Its first two IDs were
[33, 40], the first hole card of seats 0 and 1 respectively. - Use LLDB to swap those bytes to
[40, 33], exchanging 10♥ and Q♠. Read all 52 bytes back and confirm the remaining 50 are unchanged.
# These values belong to the recorded hand. Use the actual two IDs from a fresh run.
(lldb) memory write --format u --size 1 <deck address> 40 33
(lldb) memory read --format u --size 1 --count 52 <deck address>
(lldb) detach
- Continue the real service. Cards reach the engine through the existing signed HTTP path; players call or check to showdown.
- Call the real settlement function and record its return. The engine computes a tentative result, but the settlement check returns an error and rejects it.
BASELINE_ACCEPTED
MEMORY_SWAP [33, 40] -> [40, 33]
TAMPER_REJECTED
reason=sealed recovery commitment mismatch
The rejection occurs on the server. ActiveHand::finish() calls validate_final_deck(), which recomputes a SHA-256 commitment over the hand ID, internal seed, nonce and all 52 cards. The swap preserves a complete deck, but its hash differs from the commitment saved at creation, so the function returns an error.
Later checks compare showdown cards with their deck positions and reconstruct the committed order from the master seed. This attempt stopped at the earlier commitment check. The experiment changed only two deck bytes and left validation logic intact; no wallet writes were run.
What happens when a player verifies the hand?
A separate fresh hand was checked using the real pf2::verify() and client compareWithEvidence(). Original cards and evidence passed fully. Swapped cards with the original proof failed the local comparison with card_mismatch. Changing the claimed cards inside the proof failed with decryption proof is invalid at position 0. Without local records, the client returned the weaker no_record result, not a full pass.
Reading or leaking cards leaves the deck unchanged, so the dealing proof can remain valid. Swapping cards breaks consistency with the commitment or recorded cards. That explains the observed successful reads and rejected settlement.
Public source and reproduction code
Public repository (pinned revision). This standalone example uses only the public mental-poker library. It creates its own deck, swaps two IDs in a local vector, and checks that the original proof still proves the original cards while a proof claiming changed cards is rejected. It does not reproduce the full server settlement or LLDB experiment above. No private checkout, server login, database or credentials are required.
Install Rust/Cargo, save Cargo.toml and verify_swap.rs in the same new directory, then run cargo run. The first run fetches public dependencies; card values vary each 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);
}