Skip to content

Client-visible rng_seed in filtered game state lets a participant predict hidden randomness and reconstruct hidden library order #5368

Description

@ntindle

Summary

filter_state_for_viewer redacts hidden zones from the GameState delivered to each player, but it does not strip the deterministic RNG seed. Every filtered snapshot sent to a client (or P2P guest) still contains rng_seed. Because all engine randomness is drawn from a ChaCha20 stream seeded solely by that value, a participant who knows the seed can reproduce the game's entire random stream — future coin flips, die rolls, random discards/targets, and shuffles — and can potentially reconstruct hidden library order that the redaction is meant to protect.

Affected code

  • crates/engine/src/types/game_state.rsrng_seed: u64 is a serialized field. The live rng: ChaCha20Rng stream is #[serde(skip)], but the seed that fully determines it is not.
  • crates/engine/src/game/visibility.rsfilter_state_for_viewer(state, viewer) clones and redacts hands, libraries, face-down identities, choice payloads, and deck pools, but leaves rng_seed intact on the returned state.
  • Delivery paths that ship the filtered state to untrusted clients:
    • Server-authoritative: crates/phase-server/src/main.rs broadcasts filter_state_for_player(&raw_state, pid) as a StateUpdate to each seat.
    • P2P host-authoritative: the host's getViewerSnapshot(pid)engine-wasm get_viewer_snapshot_js runs filter_state_for_viewer and sends the result to each guest.

Impact

All engine randomness draws deterministically from state.rng, a ChaCha20 keystream seeded only by rng_seed. A participant who knows the seed and can infer how many RNG values have already been consumed can:

  • Predict every future randomized outcome (coin flips, die rolls, random targets/discards, shuffles).
  • Replay the initial library shuffle to reconstruct the ordering of hidden libraries — defeating the confidentiality that visibility.rs otherwise enforces for library contents and order.

This affects both multiplayer modes. It is an information-disclosure / competitive-integrity issue: an otherwise-legal but modified client can obtain hidden information and foresee randomness — i.e. cheat. Hidden-zone confidentiality is a security property the engine deliberately implements; exposing the seed undermines it.

Attack complexity / caveats

Exploitation requires reconstructing the exact number of prior RNG consumptions to align the keystream position (the stream offset itself is not serialized). This is non-trivial but tractable for a determined attacker who can observe the action/event history. No integrity or availability impact, and the attacker must be a participant in the game — which is why this is rated moderate rather than high.

Suggested remediation

  • Strip rng_seed from client-facing state in filter_state_for_viewer (e.g. zero it for all viewers) so it is never serialized to any client or guest.
  • Keep the seed strictly server/host-internal. Persistence/resume paths already re-seed on restore, so removing it from client snapshots should be low-risk.
  • Optionally, derive the gameplay keystream from a value that is never client-derivable, so even a leaked persisted seed cannot reproduce in-game randomness.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions