Challenge: COMpetition · CTF: GPN CTF 2026 · Category: Crypto · Difficulty: Easy
Summary#
A 100-round commit-reveal rock-paper-scissors where you commit your move before the
opponent reveals theirs. The commitment scheme sha256(r1 + message + r2) lets the player
choose both r1 and r2, making it non-binding: a single hash opens to any word that
is a substring of the committed preimage — so you can always reveal the winning move.
Solution#
Step 1: Spot the non-binding commitment#
The server verifies a move with:
1
| return commitment == sha256(r1 + message + r2).digest()
|
The player supplies r1, message, and r2. Since both r1 and r2 are attacker-chosen,
any commitment whose preimage contains a word can be opened to that word by sliding the
split point. Commit to a preimage containing all three words — rock, paper, scissors —
and after seeing the opponent’s move, reveal whichever one beats it:
my=rock → reveal papermy=paper → reveal scissorsmy=scissors → reveal rock
Two practical notes:
- A per-round
nonce keeps each commitment distinct (dodging the already_seen consistency check). - The server parses the proof with
.split(), which drops a trailing empty token, so an empty
r2 breaks unpacking — pad the preimage with a trailing nonce so r2 is never empty. - Python’s
ssl was rejected by the endpoint (SSLEOFError), so the script drives ncat --ssl.
Step 2: Win all 100 rounds#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| import subprocess
from hashlib import sha256
HOST = "cured-tofu-beside-sauteed-thyme-fnpt.gpn24.ctf.kitctf.de"
PORT = "443"
NUM_ROUNDS = 100
WIN = {"rock": "paper", "paper": "scissors", "scissors": "rock"}
p = subprocess.Popen(["ncat", "--ssl", HOST, PORT],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, bufsize=0)
buf = b""
def recv_until(tok: bytes) -> bytes:
global buf
while tok not in buf:
d = p.stdout.read(1)
if not d:
raise EOFError(buf)
buf += d
idx = buf.index(tok) + len(tok)
out, buf = buf[:idx], buf[idx:]
return out
def send_line(x: str):
p.stdin.write(x.encode() + b"\n"); p.stdin.flush()
recv_until(b"play a game...")
for i in range(NUM_ROUNDS):
nonce = f"{i:08d}".encode()
preimage = nonce + b"rockpaperscissors" + nonce # all three words, r1/r2 never empty
com = sha256(preimage).digest()
recv_until(b"Commitment (hex): ")
send_line(com.hex())
line = recv_until(b".").decode() # "I choose X."
my_choice = line.split("I choose ")[1].split(".")[0].strip()
your_choice = WIN[my_choice]
recv_until(b"What did you choose? ")
send_line(your_choice)
word = your_choice.encode()
pos = preimage.index(word)
r1, r2 = preimage[:pos], preimage[pos + len(word):]
recv_until(b"Proof (hex): ")
send_line(f"{r1.hex()} {r2.hex()}")
print((buf + p.stdout.read()).decode(errors="replace"))
|
Output (final round):
1
| How can that be? Well, a deal is a deal. Here is your flag: GPNCTF{W4I7, 1t'5 NOT jUS7 lUCK? NEv3R Has 8EEN.}
|
Flag#
1
| GPNCTF{W4I7, 1t'5 NOT jUS7 lUCK? NEv3R Has 8EEN.}
|