Post

BlackhatMEA2025 Whack a Scratch Writeup

Writeup

GPT-5 to the rescue

File-Output

TLDR:

The service hides a 252-bit key as twelve 21-bit chunks on the diagonals of two triangular matrices, he “SCRATCH” menu leaks only linear combinations of those internals, so a purely passive algebraic recovery is under-determined, But the WHACK menu is an oracle that lets you increment any matrix entry and tells you when you’ve pushed a given diagonal all the way to p − 1

Counting how many accepted whacks it took to reach the first BROKE reveals the starting diagonal value exactly. Do that for the 6 diagonals of each matrix (12 values total), pack them into the 252-bit key, and “Open Cash Box” returns the flag bytes.

Writeup

The exploitable oracle: WHACK

The WHACK menu lets you pick matrix index i and a coordinate (r,c) and it increments that entry modulo p. After each accepted whack the server prints: you whacked the ticket machine

If an increment would roll a diagonal entry to an invalid/sentinel value (effectively p−1 on the diagonal slot), the service yells

YOU BROKE MY TICKET MACHINE?!

and rotates/reset-repairs.

Key observation for a diagonal position d: Let x be the unknown current value of the diagonal (an integer [0,p-1] Each WHACK on(d,d) does x←(x+1) mod p. The first time the server refuses (prints BROKE) is when the pre-increment value reached p−1. If we counted S accepted whacks before the first BROKE, then x+S≡p−1(modp)⟹x≡(p−1)−S≡p−(S+1)(modp).

File-Output

So one pass of whacks on a single diagonal reveals its true starting value x.

Do that for all diagonals of S0 and S1 and you have the exact twelve 21-bit chunks of the masked key.

Script:

For each triangular matrix i ∈ {0,1} and each diagonal index d ∈ {0..5}: Repeatedly send WHACKs to (i, d, d). Count how many “you whacked…” appear before the very first “YOU BROKE…”.

Recover the chunk with: alt text

After all 12 chunks are recovered, pack them into a 252-bit integer: chunk 0 as the least-significant 21 bits, …, chunk 11 as the most-significant 21 bits. Emit as 32-byte big-endian hex.

Use Open Cash Box with that 32-byte key. The server prints “You retrieve N credits.” N is the flag as a big integer. Convert N to bytes → BHFlagY{…}.

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/usr/bin/env python3
from pwn import *
import sys, time, os

MODULUS = (1 << 21) - 9
MSG_HIT = b'*you whacked the ticket machine*'
MSG_BREAK = b'YOU BROKE MY TICKET MACHINE?!'
LONGEST = max(len(MSG_HIT), len(MSG_BREAK))

BATCH_SIZE   = 3000
BUF_SIZE     = 1 << 16
WAIT_TIME    = 0.6
QUIET_LIMIT  = 2

context.log_level = 'info'   # change to 'debug' to see all traffic


def init_connection(host=None, port=None, credits=-1_000_000_000, local=False):
    if local:
        conn = process(["python3", "whack-a-scratch.py"])  # run server locally
    else:
        conn = remote(host, port)

    conn.recvuntil(b'|  > ')
    conn.sendline(b's')
    conn.recvuntil(b'(int) ')
    conn.sendline(str(credits).encode())
    conn.recvuntil(b'|  > ')
    return conn


def spam_hits(conn, idx, depth, repeat):
    packet = b'w\n%d,%d,%d\n' % (idx, depth, depth)
    conn.send(packet * repeat)


def process_buffer(buf, start_score):
    pos = 0
    score = start_score
    total_len = len(buf)
    while True:
        hit_at = buf.find(MSG_HIT, pos)
        break_at = buf.find(MSG_BREAK, pos)
        if hit_at == -1 and break_at == -1:
            leftover = buf[-(LONGEST-1):] if total_len >= (LONGEST-1) else buf
            return score, False, leftover
        if break_at != -1 and (hit_at == -1 or break_at < hit_at):
            leftover = buf[break_at + len(MSG_BREAK):]
            leftover = leftover[-(LONGEST-1):] if len(leftover) >= (LONGEST-1) else leftover
            return score, True, leftover
        score += 1
        pos = hit_at + len(MSG_HIT)


def clear_backlog(conn):
    idle = 0
    buffer_tail = b''
    while idle < QUIET_LIMIT:
        data = conn.recv(timeout=WAIT_TIME, numb=BUF_SIZE)
        if not data:
            idle += 1
        else:
            idle = 0
            buffer_tail = (buffer_tail + data)[-(LONGEST-1):]


def recover_entry_with_retry(host, port, conn, idx, depth, local=False):
    while True:
        try:
            score = 0
            carry = b''
            while True:
                spam_hits(conn, idx, depth, BATCH_SIZE)
                done = 0
                while done < BATCH_SIZE:
                    chunk = conn.recv(timeout=0.5, numb=BUF_SIZE)
                    if not chunk:
                        break
                    combined = carry + chunk
                    prev_score = score
                    score, broken, carry = process_buffer(combined, score)
                    done += (score - prev_score)
                    if broken:
                        value = (MODULUS - (score + 1)) % MODULUS
                        clear_backlog(conn)
                        return value, conn
        except EOFError:
            try:
                conn.close()
            except Exception:
                pass
            conn = init_connection(host, port, local=local)


def build_key(parts):
    result = 0
    for pos in range(11, -1, -1):
        result = (result << 21) | (parts[pos] & ((1 << 21) - 1))
    return result.to_bytes(32, 'big').hex()


def unlock_box(conn, key_hex):
    conn.sendline(b'o')
    conn.recvuntil(b'(hex) ')
    conn.sendline(key_hex.encode())
    reply = conn.recvline(timeout=2) or b''
    message = reply.decode(errors='ignore').strip()
    print(message)
    if 'You retrieve' in message:
        try:
            num = int(message.split('retrieve', 1)[1].split('credits', 1)[0].replace('.', '').strip())
            flag_bytes = num.to_bytes((num.bit_length() + 7) // 8, 'big')
            try:
                print(f'FLAG: {flag_bytes.decode()}')
            except:
                print(f'FLAG bytes: {flag_bytes!r}')
        except Exception:
            pass


# ---------------------------------------------
# Main entry
# ---------------------------------------------
def main():
    # If no args -> run local server automatically
    if len(sys.argv) == 1:
        log.info("Running against local process (whack-a-scratch.py)")
        host, port, local = None, None, True
    elif len(sys.argv) == 3:
        host, port, local = sys.argv[1], int(sys.argv[2]), False
    else:
        print(f'Usage: {sys.argv[0]} [<host> <port>]')
        sys.exit(1)

    conn = init_connection(host, port, local=local)

    recovered = [0] * 12
    for depth in range(6):
        val, conn = recover_entry_with_retry(host, port, conn, 0, depth, local=local)
        recovered[depth] = val
    
    for depth in range(6):
        val, conn = recover_entry_with_retry(host, port, conn, 1, depth, local=local)
        recovered[6 + depth] = val

    key_str = build_key(recovered)
    print(f'Key = {key_str}')

    try:
        unlock_box(conn, key_str)
        conn.sendline(b'q')
    except EOFError:
        conn = init_connection(host, port, local=local)
        unlock_box(conn, key_str)
        conn.sendline(b'q')


if __name__ == '__main__':
    main()

This post is licensed under CC BY 4.0 by the author.