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 paper
  • my=paper → reveal scissors
  • my=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.}