San Diego CTF 2024 — Raccoon Run
The annual UC San Diego Raccoon Run is happening right now!! Apparently there's an underground gambling ring going on there. Maybe you can make it big?
We're given a Python server that looks like this:
Code (py):
1import json
2from time import time
3import tornado
4import tornado.websocket
5import tornado.ioloop
6import random
7import asyncio
8import os
9from datetime import timedelta
10
11import tornado.web
12import tornado.gen
13
14PORT = 8000
15
16NUM_RACCOONS = 8
17FINISH_LINE = 1000
18TARGET_BET = 1000
19STEP_TIME = 0.25 # in seconds
20BETTING_TIME = 15 # in seconds
21
22FLAG = os.environ["GZCTF_FLAG"]
23
24active_betters = {}
25connections = {}
26connection_count = 0
27game = None
28
29class RaccoonRun:
30 def __init__(self):
31 self.raccoons = [0] * 8
32 self.finishers = []
33 self.can_bet = True
34 self.bet_end = 0
35
36 def step(self):
37 self.can_bet = False
38 random_int = random.getrandbits(32)
39 for i in range(NUM_RACCOONS):
40 self.raccoons[i] += (random_int >> (i * 4)) % 16
41 for (i, x) in enumerate(self.raccoons):
42 if x >= FINISH_LINE and i not in self.finishers:
43 self.finishers.append(i)
44 return (self.raccoons, self.finishers)
45
46 def game_over(self):
47 return len(self.finishers) >= NUM_RACCOONS
48
49class Gambler:
50 def __init__(self, account=10):
51 self.account = account
52 self.guess = None
53 self.bet_amount = 0
54
55 def bet(self, guess, bet_amount):
56 if self.validate_bet(guess, bet_amount):
57 self.guess = guess
58 self.bet_amount = bet_amount
59 return True
60 else:
61 return False
62
63 def validate_bet(self, guess, bet_amount):
64 if (type(guess) is not list):
65 return False
66 if not all(type(x) is int for x in guess):
67 return False
68 if len(guess) != NUM_RACCOONS:
69 return False
70 if (type(bet_amount) is not int):
71 return False
72 if (bet_amount < 0 or bet_amount > self.account):
73 return False
74 return True
75
76 # updates amount of money in account after game is over and bet comes through
77 # and then return an boolean indicating whether you won/lost
78 def check_bet(self, game_instance):
79 if game_instance.finishers == self.guess:
80 self.account += self.bet_amount
81 return True
82 else:
83 self.account -= self.bet_amount
84 return False
85
86 def reset_bet(self):
87 self.guess = None
88 self.bet_amount = 0
89
90def get_race_information(id):
91 return json.dumps({"type": "race_information", "can_bet": "true" if game.can_bet else "false", "raccoons": game.raccoons, "finishers": game.finishers, "account": active_betters[id].account})
92
93class RRWebSocketHandler(tornado.websocket.WebSocketHandler):
94 def open(self):
95 global game
96 global active_betters
97 global connections
98 global connection_count
99
100 self.better_id = connection_count
101 active_betters[self.better_id] = Gambler()
102 connections[self.better_id] = self
103 connection_count += 1
104 self.write_message(get_race_information(self.better_id))
105 if game.can_bet:
106 self.write_message(json.dumps({"type":"betting-starts","until":game.bet_end}))
107
108 def on_message(self, message):
109 try:
110 data = json.loads(message)
111 if "type" not in data:
112 self.write_message(json.dumps({"type": "response", "value": "invalid WebSockets message"}))
113 elif (data["type"] == "bet"):
114 if (game.can_bet):
115 if active_betters[self.better_id].bet(data["order"], data["amount"]):
116 self.write_message(json.dumps({"type": "response", "value": "bet successfully placed!"}))
117 else:
118 self.write_message(json.dumps({"type": "response", "value": "bet is invalid, failed to be placed"}))
119 else:
120 self.write_message(json.dumps({"type": "response", "value": "bet cannot be placed after the race starts, failed to be placed"}))
121 elif (data["type"] == "buy_flag"):
122 if (active_betters[self.better_id].account > TARGET_BET):
123 self.write_message(json.dumps({"type": "flag", "value": FLAG}))
124 elif (data["type"] == "state"):
125 self.write_message(json.dumps({"type": "response", "value": "bet" if game.can_bet else "race"}))
126 elif (data["type"] == "account"):
127 self.write_message(json.dumps({"type": "response", "value": active_betters[self.better_id].account}))
128 else:
129 self.write_message(json.dumps({"type": "response", "value": "invalid WebSockets message"}))
130 except json.JSONDecodeError:
131 self.write_message(json.dumps({"type": "response", "value": "invalid WebSockets message"}))
132
133 def on_close(self):
134 del active_betters[self.better_id]
135 del connections[self.better_id]
136
137def game_loop():
138 global game
139 print("Raccoons", game.raccoons)
140 print("Finishers", game.finishers)
141 for (id, connection) in connections.items():
142 connection.write_message(get_race_information(id))
143 game.step()
144 if game.game_over():
145 print("Raccoons", game.raccoons)
146 print("Finishers", game.finishers)
147 for (id, connection) in connections.items():
148 connection.write_message(get_race_information(id))
149 connection.write_message(json.dumps({"type":"result", "value": game.finishers}))
150 for (id, x) in active_betters.items():
151 if x.guess != None:
152 win = x.check_bet(game)
153 connections[id].write_message(json.dumps({"type": "bet_status", "value": f"you {'won' if win else 'lost'} the bet, your account now has ${x.account}"}))
154 x.reset_bet()
155 else:
156 connections[id].write_message(json.dumps({"type": "bet_status", "value": f"you didn't place a bet, your account now has ${x.account}"}))
157 print("Every raccoon has finished the race.")
158 print(f"Starting new game! Leaving {BETTING_TIME} seconds for bets...")
159 game = RaccoonRun()
160 game.bet_end = time() + BETTING_TIME
161 for (id, connection) in connections.items():
162 connection.write_message(get_race_information(id))
163 connection.write_message(json.dumps({"type":"betting-starts","until":game.bet_end}))
164 tornado.ioloop.IOLoop.current().add_timeout(timedelta(seconds=BETTING_TIME), game_loop)
165 else:
166 tornado.ioloop.IOLoop.current().add_timeout(timedelta(seconds=STEP_TIME), game_loop)
167
168if __name__ == "__main__":
169 tornado.ioloop.IOLoop.configure("tornado.platform.asyncio.AsyncIOLoop")
170 io_loop = tornado.ioloop.IOLoop.current()
171 asyncio.set_event_loop(io_loop.asyncio_loop)
172 game = RaccoonRun()
173 print(f"Starting new game! Leaving {BETTING_TIME} seconds for bets...")
174 game.bet_end = time() + BETTING_TIME
175 tornado.ioloop.IOLoop.current().add_timeout(timedelta(seconds=BETTING_TIME), game_loop)
176 application = tornado.web.Application([
177 (r"/ws", RRWebSocketHandler),
178 (r"/(.*)", tornado.web.StaticFileHandler, {"path": "./static", "default_filename": "index.html"})
179 ])
180 application.listen(PORT)
181 io_loop.start()
At each step of the race, 4-bit chunks of a random 32-bit int are added to each raccoons position, continuing until all 8 raccoons cross the finish line (> 1000). While we can bet on the order of the raccoons to finish the race, we only win if our guess is exactly correct.
The main idea with this challenge is that because we're given the positions of the raccoons at each step, we can reconstruct the exact 32-bit integer used to reach that step. Feeding that integer into an RNG cracker, we can reverse engineer the RNG being used by the server to run the races, then simulate each race ahead of time to bet flawlessly and buy the flag.
Code (py):
1import numpy as np
2import websocket
3import json
4from randcrack import RandCrack
5
6
7WS_URL = "ws://127.0.0.1:64045/ws"
8TARGET_BET = 1000
9
10rc = RandCrack()
11raccoons = np.zeros(8, dtype=int)
12balance = 10
13
14start_processing = False
15stop_processing = False
16
17
18def on_message(ws, message):
19 global start_processing, stop_processing, balance, raccoons
20
21 print('Received message:', message, flush=True)
22 data = json.loads(message)
23
24 match data['type']:
25 # Reset race data
26 case 'bet_status':
27 raccoons = np.zeros(8, dtype=int)
28
29 case 'betting-starts':
30 start_processing = True
31 if not rc.state:
32 return
33
34 # If `rc.state` is populated, stop sampling random output
35 stop_processing = True
36
37 # If we have enough to buy the flag, do so
38 if balance > TARGET_BET:
39 ws.send(json.dumps({
40 "type": "buy_flag"
41 }))
42 return
43
44 # Otherwise, predict the winners and place a bet
45 r_state = [0] * 8
46 finishers = []
47 while len(finishers) != 8:
48 random_int = rc.predict_getrandbits(32)
49 for i in range(8):
50 r_state[i] += (random_int >> (i * 4)) % 16
51 if r_state[i] >= 1000 and i not in finishers:
52 finishers.append(i)
53
54 print(f"The bet is: {finishers}")
55 ws.send(json.dumps({
56 "type": "bet",
57 "order": finishers,
58 "amount": balance
59 }))
60 balance *= 2
61
62 case 'race_information':
63 # Ignore data if we've started the script in the middle of a race, or after we've reached a betting
64 # phase with populated `rc.state`.
65 if not start_processing or stop_processing:
66 return
67 if data['can_bet'] == "true":
68 return
69
70 # If we've populated `rc.state` before the end of a race, keep advancing the RNG.
71 if rc.state:
72 rc.predict_getrandbits(32)
73
74 new_states = np.array(data['raccoons'])
75 diffs = new_states - raccoons
76 raccoons = new_states
77
78 print(diffs, flush=True)
79 # print(''.join(reversed(list(map(lambda d: bin(d)[2:].zfill(4), diffs)))))
80
81 # Reverse random integer:
82 # self.raccoons[i] += (random_int >> (i * 4)) % 16
83 reconstructed = 0
84 for i, num in enumerate(diffs):
85 reconstructed += num << (i * 4)
86
87 # Mask signed integer back to unsigned for randcrack
88 reconstructed &= 0xffffffff
89
90 rc.submit(reconstructed)
91 print(f'{rc.counter}: {reconstructed}', flush=True)
92
93
94if __name__ == "__main__":
95 ws = websocket.WebSocketApp(WS_URL, on_message=on_message)
96 ws.run_forever()
After about ~5 games of sampling the RNG and 7 games of betting, we can purchase the flag:
Code (bash):
1PS C:\Users\kevin\Downloads> python3 .\solve.py
2Received message: {"type": "race_information", "can_bet": "true", "raccoons": [0, 0, 0, 0, 0, 0, 0, 0], "finishers": [], "account": 10}
3Received message: {"type": "betting-starts", "until": 1715572315.2874234}
4Received message: {"type": "race_information", "can_bet": "true", "raccoons": [0, 0, 0, 0, 0, 0, 0, 0], "finishers": [], "account": 10}
5Received message: {"type": "race_information", "can_bet": "false", "raccoons": [9, 0, 5, 15, 12, 2, 11, 7], "finishers": [], "account": 10}
6[ 9 0 5 15 12 2 11 7]
71: 2066543881
8Received message: {"type": "race_information", "can_bet": "false", "raccoons": [21, 14, 8, 18, 25, 14, 25, 10], "finishers": [], "account": 10}
9[12 14 3 3 13 12 14 3]
102: 1053635564
11Received message: {"type": "race_information", "can_bet": "false", "raccoons": [21, 15, 23, 31, 35, 24, 32, 25], "finishers": [], "account": 10}
12[ 0 1 15 13 10 10 7 15]
133: 4155170576
14Received message: {"type": "race_information", "can_bet": "false", "raccoons": [22, 22, 23, 46, 38, 30, 47, 25], "finishers": [], "account": 10}
15[ 1 7 0 15 3 6 15 0]
164: 258207857
17Received message: {"type": "race_information", "can_bet": "false", "raccoons": [37, 23, 23, 56, 42, 39, 50, 33], "finishers": [], "account": 10}
18[15 1 0 10 4 9 3 8]
195: 2207555615
20Received message: {"type": "race_information", "can_bet": "false", "raccoons": [50, 32, 30, 57, 53, 54, 53, 38], "finishers": [], "account": 10}
21[13 9 7 1 11 15 3 5]
226: 1408964509
23Received message: {"type": "race_information", "can_bet": "false", "raccoons": [51, 33, 31, 71, 60, 58, 67, 38], "finishers": [], "account": 10}
24[ 1 1 1 14 7 4 14 0]
257: 239591697
26Received message: {"type": "race_information", "can_bet": "false", "raccoons": [57, 35, 44, 84, 63, 72, 81, 45], "finishers": [], "account": 10}
27[ 6 2 13 13 3 14 14 7]
288: 2128862502
29Received message: {"type": "race_information", "can_bet": "false", "raccoons": [71, 37, 48, 85, 77, 72, 81, 55], "finishers": [], "account": 10}
30[14 2 4 1 14 0 0 10]
319: 2685277230
32Received message: {"type": "race_information", "can_bet": "false", "raccoons": [75, 47, 63, 94, 87, 82, 87, 63], "finishers": [], "account": 10}
33[ 4 10 15 9 10 10 6 8]
3410: 2259328932
35Received message: {"type": "race_information", "can_bet": "false", "raccoons": [84, 57, 64, 108, 102, 94, 93, 66], "finishers": [], "account": 10}
36[ 9 10 1 14 15 12 6 3]
3711: 919593385
38Received message: {"type": "race_information", "can_bet": "false", "raccoons": [91, 64, 67, 116, 106, 96, 106, 69], "finishers": [], "account": 10}
39[ 7 7 3 8 4 2 13 3]
4012: 1025803127
41Received message: {"type": "race_information", "can_bet": "false", "raccoons": [102, 71, 68, 129, 115, 101, 119, 76], "finishers": [], "account": 10}
42[11 7 1 13 9 5 13 7]
4313: 2103038331
44Received message: {"type": "race_information", "can_bet": "false", "raccoons": [111, 73, 80, 135, 123, 114, 130, 91], "finishers": [], "account": 10}
45[ 9 2 12 6 8 13 11 15]
4614: 4225264681
47Received message: {"type": "race_information", "can_bet": "false", "raccoons": [119, 84, 95, 143, 131, 119, 137, 104], "finishers": [], "account": 10}
48[ 8 11 15 8 8 5 7 13]
4915: 3612905400
50...
51Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1036, 1052, 1078, 1060, 1042, 975, 931, 1044], "finishers": [3, 2, 0, 1, 4, 7], "account": 640}
52Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1048, 1057, 1082, 1070, 1043, 981, 944, 1045], "finishers": [3, 2, 0, 1, 4, 7], "account": 640}
53Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1055, 1066, 1095, 1080, 1056, 984, 954, 1057], "finishers": [3, 2, 0, 1, 4, 7], "account": 640}
54Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1057, 1071, 1103, 1080, 1059, 984, 955, 1060], "finishers": [3, 2, 0, 1, 4, 7], "account": 640}
55Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1063, 1073, 1111, 1094, 1061, 999, 955, 1061], "finishers": [3, 2, 0, 1, 4, 7], "account": 640}
56Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1075, 1078, 1120, 1095, 1070, 1003, 967, 1066], "finishers": [3, 2, 0, 1, 4, 7, 5], "account": 640}
57Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1087, 1087, 1121, 1098, 1083, 1003, 967, 1077], "finishers": [3, 2, 0, 1, 4, 7, 5], "account": 640}
58Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1098, 1092, 1136, 1111, 1091, 1006, 973, 1091], "finishers": [3, 2, 0, 1, 4, 7, 5], "account": 640}
59Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1111, 1101, 1140, 1122, 1104, 1014, 983, 1102], "finishers": [3, 2, 0, 1, 4, 7, 5], "account": 640}
60Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1115, 1116, 1152, 1136, 1117, 1026, 993, 1107], "finishers": [3, 2, 0, 1, 4, 7, 5], "account": 640}
61Received message: {"type": "race_information", "can_bet": "false", "raccoons": [1129, 1117, 1163, 1143, 1117, 1029, 1005, 1108], "finishers": [3, 2, 0, 1, 4, 7, 5, 6], "account": 640}
62Received message: {"type": "result", "value": [3, 2, 0, 1, 4, 7, 5, 6]}
63Received message: {"type": "bet_status", "value": "you won the bet, your account now has $1280"}
64Received message: {"type": "race_information", "can_bet": "true", "raccoons": [0, 0, 0, 0, 0, 0, 0, 0], "finishers": [], "account": 1280}
65Received message: {"type": "betting-starts", "until": 1715572927.6933398}
66Received message: {"type": "flag", "value": "SDCTF{m3rs3nn3_tw15t3r_15_5cuff3d_b300818768b0}"}