$cat space-cards.mdCryptoHackultet 2026
Space Cards
A card game reshuffles every round, but each round's deck is the same fixed permutation of the last one, so a handful of rounds is enough to learn it and predict the rest.
$man space-cards
What this one teaches:
- That re-shuffling with a deterministic PRNG that never re-seeds is not the same as re-shuffling randomly each time.
- Recovering an unknown permutation by comparing two consecutive, partially observed states.
- Building an adaptive strategy around an oracle, using what you've learned so far to decide what to ask next.
Task description
A card game on a spacecraft where almost nothing is random. Win 27 rounds with at most 5 mistakes.
Each round allows asking for the positions of up to 11 named cards, peeking at up to 11 deck positions, then guessing where a hidden card (“HQ”) sits and naming a random target card.
What’s actually driving the shuffle
The challenge binary is a self-extracting Python 3.12 application, compiled with Nuitka. Its deck construction uses Python’s standard random.shuffle() (Mersenne Twister) once, for an “intermediate deck,” and then applies a second shuffle using a custom LCG class, producing the deck actually used in play. Crucially, that LCG is never re-seeded between rounds; it just keeps advancing, 51 steps further for every round that passes.
Why that makes every round predictable
Since the intermediate deck is fixed once per session, and the LCG only ever advances forward (never resets), the transformation from one round’s final deck to the next round’s final deck is the same fixed permutation every single time within a session, call it P: whatever card sits at position i in round n will sit at position P[i] in round n + 1, P[P[i]] in round n + 2, and so on. P itself varies between different runs of the challenge (since it depends on the session’s random seed), but within one session it never changes.
That means once a single HQ position is known at some round k, its position in every later round is already determined, P applied however many times forward, with no further guessing needed, as long as P itself has been learned.
Learning P
Asking Q1 (card positions) for the same known cards across two consecutive rounds reveals, for each tracked card, exactly which position it moved to, directly giving entries of P. Separately, whenever a round’s attempt at guessing HQ’s position fails, the response reveals HQ’s true position for that round, which, compared against the previous round’s known HQ position, hands over another entry of P for free. Both sources accumulate into the same permutation table as the game goes on.
Playing the game
- Each round, ask Q1 about whichever cards from the previous round’s deck haven’t yet had their post-shuffle position confirmed, to keep filling in unknown entries of
P. - Predict HQ’s likely position using
Papplied to the last known HQ position, and include that predicted position in Q2 (the peek) to confirm or correct it. - Guess the predicted (or directly observed) HQ position.
- For the random target card, use whatever has already been learned about the current deck, built up from Q1, Q2, and
Papplied forward from the previous round’s known deck, to name it correctly whenever possible, falling back to an informed guess among the remaining unknown cards otherwise.
As more of P gets filled in across early rounds, later rounds become almost fully predictable, both for HQ’s position and for named cards. Across multiple attempts, this strategy reliably won 24 to 25 of the 27 rounds, comfortably inside the 5-mistake allowance.
The exploit
import socket, re
SUITS, RANKS = ['S', 'H', 'D', 'C'], ['2','3','4','5','6','7','8','9','10','J','Q','K','A']
ALL_CARDS = [f"{s}{r}" for s in SUITS for r in RANKS if not (s == 'H' and r == 'Q')] # HQ is special
s = socket.create_connection(('chal.live.hackultet.hr', 14085))
P = [None] * 52 # P[old_position] = new_position, learned across rounds
decks, hq_pos = {}, {}
correct = mistakes = 0
def update_P(prev_round, observed):
prev = decks.get(prev_round, [None] * 52)
for pos, card in observed.items():
if card == 'HQ':
continue
for old_pos in range(52):
if prev[old_pos] == card and old_pos != pos and P[old_pos] is None:
P[old_pos] = pos
for rnd in range(1, 28):
observed = {}
# Q1: ask about cards whose post-shuffle position we don't know yet
prev = decks.get(rnd - 1, [None] * 52)
q1 = [prev[i] for i in range(52) if prev[i] and prev[i] != 'HQ' and P[i] is None][:11]
send_cards(s, q1)
observed.update(parse_positions(recv_until(s, 'Indices > ')))
if rnd > 1:
update_P(rnd - 1, observed)
# Q2: peek at the predicted HQ position, plus whatever else is still unknown
predicted_hq = P[hq_pos[rnd - 1]] if rnd - 1 in hq_pos and P[hq_pos[rnd - 1]] is not None else None
q2 = ([predicted_hq] if predicted_hq is not None else []) + \
[i for i in range(52) if i not in observed][:11]
send_indices(s, q2)
observed.update(parse_cards(recv_until(s, 'HQ > ')))
# guess HQ, using the prediction when we have one
guess = predicted_hq if predicted_hq is not None else next(iter(observed), 0)
send_guess(s, guess)
result = recv_until(s, '> ')
if 'CORRECT' in result:
correct += 1
hq_pos[rnd] = guess
elif 'FAILURE' in result:
mistakes += 1
actual_hq = int(re.search(r'HQ was at (\d+)', result).group(1))
hq_pos[rnd] = actual_hq
update_P(rnd, {actual_hq: 'HQ', **observed})
decks[rnd] = [observed.get(i, prev[P.index(i)] if i in P else None) for i in range(52)]
Reconnecting and retrying the whole game a few times (the server occasionally resets state) eventually prints the flag once all 27 rounds pass with 5 or fewer mistakes.
Result
CTF2026[436239588719]
What made the deck predictable at all
Nothing about the individual shuffles was weak on its own; Mersenne Twister and a well-built LCG are both fine generators used correctly. The actual weakness was architectural: reusing one continuously advancing LCG across every round of a single session, rather than drawing a fresh, independent shuffle each time, turned “shuffle again” into “apply the same fixed transformation again,” and a fixed transformation, once observed even partially, stays known for the rest of the session.
$cat flag.txt reveal
CTF2026[436239588719]