BlackhatMEA2025 Whack a Scratch Writeup
Writeup
GPT-5 to the rescue
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).
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…”.
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()