Skip to content

Instantly share code, notes, and snippets.

@ky28059
Last active May 13, 2024 04:34
Show Gist options
  • Save ky28059/e9d0957313af0d38811e6e845dab7a41 to your computer and use it in GitHub Desktop.
Save ky28059/e9d0957313af0d38811e6e845dab7a41 to your computer and use it in GitHub Desktop.

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:

import json
from time import time
import tornado
import tornado.websocket
import tornado.ioloop
import random
import asyncio
import os
from datetime import timedelta

import tornado.web
import tornado.gen

PORT = 8000

NUM_RACCOONS = 8
FINISH_LINE = 1000
TARGET_BET = 1000
STEP_TIME = 0.25 # in seconds
BETTING_TIME = 15 # in seconds

FLAG = os.environ["GZCTF_FLAG"]

active_betters = {}
connections = {}
connection_count = 0
game = None

class RaccoonRun:
    def __init__(self):
        self.raccoons = [0] * 8
        self.finishers = []
        self.can_bet = True
        self.bet_end = 0

    def step(self):
        self.can_bet = False
        random_int = random.getrandbits(32)
        for i in range(NUM_RACCOONS):
            self.raccoons[i] += (random_int >> (i * 4)) % 16
        for (i, x) in enumerate(self.raccoons):
            if x >= FINISH_LINE and i not in self.finishers:
                self.finishers.append(i)
        return (self.raccoons, self.finishers)
    
    def game_over(self):
        return len(self.finishers) >= NUM_RACCOONS
    
class Gambler:
    def __init__(self, account=10):
        self.account = account
        self.guess = None
        self.bet_amount = 0
    
    def bet(self, guess, bet_amount):
        if self.validate_bet(guess, bet_amount):
            self.guess = guess
            self.bet_amount = bet_amount
            return True
        else:
            return False
    
    def validate_bet(self, guess, bet_amount):
        if (type(guess) is not list):
            return False
        if not all(type(x) is int for x in guess):
            return False
        if len(guess) != NUM_RACCOONS:
            return False
        if (type(bet_amount) is not int):
            return False
        if (bet_amount < 0 or bet_amount > self.account):
            return False
        return True

    # updates amount of money in account after game is over and bet comes through
    # and then return an boolean indicating whether you won/lost
    def check_bet(self, game_instance):
        if game_instance.finishers == self.guess:
            self.account += self.bet_amount
            return True
        else:
            self.account -= self.bet_amount
            return False
    
    def reset_bet(self):
        self.guess = None
        self.bet_amount = 0

def get_race_information(id):
    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})

class RRWebSocketHandler(tornado.websocket.WebSocketHandler):
    def open(self):
        global game
        global active_betters
        global connections
        global connection_count
        
        self.better_id = connection_count
        active_betters[self.better_id] = Gambler()
        connections[self.better_id] = self
        connection_count += 1
        self.write_message(get_race_information(self.better_id))
        if game.can_bet:
            self.write_message(json.dumps({"type":"betting-starts","until":game.bet_end}))
    
    def on_message(self, message):
        try:
            data = json.loads(message)
            if "type" not in data:
                self.write_message(json.dumps({"type": "response", "value": "invalid WebSockets message"}))
            elif (data["type"] == "bet"):
                if (game.can_bet):
                    if active_betters[self.better_id].bet(data["order"], data["amount"]):
                        self.write_message(json.dumps({"type": "response", "value": "bet successfully placed!"}))
                    else:
                        self.write_message(json.dumps({"type": "response", "value": "bet is invalid, failed to be placed"}))
                else:
                    self.write_message(json.dumps({"type": "response", "value": "bet cannot be placed after the race starts, failed to be placed"}))
            elif (data["type"] == "buy_flag"):
                if (active_betters[self.better_id].account > TARGET_BET):
                    self.write_message(json.dumps({"type": "flag", "value": FLAG}))
            elif (data["type"] == "state"):
                self.write_message(json.dumps({"type": "response", "value": "bet" if game.can_bet else "race"}))
            elif (data["type"] == "account"):
                self.write_message(json.dumps({"type": "response", "value": active_betters[self.better_id].account}))
            else:
                self.write_message(json.dumps({"type": "response", "value": "invalid WebSockets message"}))
        except json.JSONDecodeError:
            self.write_message(json.dumps({"type": "response", "value": "invalid WebSockets message"}))

    def on_close(self):
        del active_betters[self.better_id]
        del connections[self.better_id]

def game_loop():
    global game
    print("Raccoons", game.raccoons)
    print("Finishers", game.finishers)
    for (id, connection) in connections.items():
        connection.write_message(get_race_information(id))
    game.step()
    if game.game_over():
        print("Raccoons", game.raccoons)
        print("Finishers", game.finishers)
        for (id, connection) in connections.items():
            connection.write_message(get_race_information(id))
            connection.write_message(json.dumps({"type":"result", "value": game.finishers}))
        for (id, x) in active_betters.items():
            if x.guess != None:
                win = x.check_bet(game)
                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}"}))
                x.reset_bet()
            else:
                connections[id].write_message(json.dumps({"type": "bet_status", "value": f"you didn't place a bet, your account now has ${x.account}"}))
        print("Every raccoon has finished the race.")
        print(f"Starting new game! Leaving {BETTING_TIME} seconds for bets...")
        game = RaccoonRun()
        game.bet_end = time() + BETTING_TIME
        for (id, connection) in connections.items():
            connection.write_message(get_race_information(id))
            connection.write_message(json.dumps({"type":"betting-starts","until":game.bet_end}))
        tornado.ioloop.IOLoop.current().add_timeout(timedelta(seconds=BETTING_TIME), game_loop)
    else:
        tornado.ioloop.IOLoop.current().add_timeout(timedelta(seconds=STEP_TIME), game_loop)

if __name__ == "__main__":
    tornado.ioloop.IOLoop.configure("tornado.platform.asyncio.AsyncIOLoop")
    io_loop = tornado.ioloop.IOLoop.current()
    asyncio.set_event_loop(io_loop.asyncio_loop)
    game = RaccoonRun()
    print(f"Starting new game! Leaving {BETTING_TIME} seconds for bets...")
    game.bet_end = time() + BETTING_TIME
    tornado.ioloop.IOLoop.current().add_timeout(timedelta(seconds=BETTING_TIME), game_loop)
    application = tornado.web.Application([
        (r"/ws", RRWebSocketHandler),
        (r"/(.*)", tornado.web.StaticFileHandler, {"path": "./static", "default_filename": "index.html"})
    ])
    application.listen(PORT)
    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.

import numpy as np
import websocket
import json
from randcrack import RandCrack


WS_URL = "ws://127.0.0.1:64045/ws"
TARGET_BET = 1000

rc = RandCrack()
raccoons = np.zeros(8, dtype=int)
balance = 10

start_processing = False
stop_processing = False


def on_message(ws, message):
    global start_processing, stop_processing, balance, raccoons

    print('Received message:', message, flush=True)
    data = json.loads(message)

    match data['type']:
        # Reset race data
        case 'bet_status':
            raccoons = np.zeros(8, dtype=int)

        case 'betting-starts':
            start_processing = True
            if not rc.state:
                return

            # If `rc.state` is populated, stop sampling random output
            stop_processing = True

            # If we have enough to buy the flag, do so
            if balance > TARGET_BET:
                ws.send(json.dumps({
                    "type": "buy_flag"
                }))
                return

            # Otherwise, predict the winners and place a bet
            r_state = [0] * 8
            finishers = []
            while len(finishers) != 8:
                random_int = rc.predict_getrandbits(32)
                for i in range(8):
                    r_state[i] += (random_int >> (i * 4)) % 16
                    if r_state[i] >= 1000 and i not in finishers:
                        finishers.append(i)

            print(f"The bet is: {finishers}")
            ws.send(json.dumps({
                "type": "bet",
                "order": finishers,
                "amount": balance
            }))
            balance *= 2

        case 'race_information':
            # Ignore data if we've started the script in the middle of a race, or after we've reached a betting
            # phase with populated `rc.state`.
            if not start_processing or stop_processing:
                return
            if data['can_bet'] == "true":
                return

            # If we've populated `rc.state` before the end of a race, keep advancing the RNG.
            if rc.state:
                rc.predict_getrandbits(32)

            new_states = np.array(data['raccoons'])
            diffs = new_states - raccoons
            raccoons = new_states

            print(diffs, flush=True)
            # print(''.join(reversed(list(map(lambda d: bin(d)[2:].zfill(4), diffs)))))

            # Reverse random integer:
            # self.raccoons[i] += (random_int >> (i * 4)) % 16
            reconstructed = 0
            for i, num in enumerate(diffs):
                reconstructed += num << (i * 4)

            # Mask signed integer back to unsigned for randcrack
            reconstructed &= 0xffffffff

            rc.submit(reconstructed)
            print(f'{rc.counter}: {reconstructed}', flush=True)


if __name__ == "__main__":
    ws = websocket.WebSocketApp(WS_URL, on_message=on_message)
    ws.run_forever()

After about ~5 games of sampling the RNG and 7 games of betting, we can purchase the flag:

PS C:\Users\kevin\Downloads> python3 .\solve.py
Received message: {"type": "race_information", "can_bet": "true", "raccoons": [0, 0, 0, 0, 0, 0, 0, 0], "finishers": [], "account": 10}
Received message: {"type": "betting-starts", "until": 1715572315.2874234}
Received message: {"type": "race_information", "can_bet": "true", "raccoons": [0, 0, 0, 0, 0, 0, 0, 0], "finishers": [], "account": 10}
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [9, 0, 5, 15, 12, 2, 11, 7], "finishers": [], "account": 10}
[ 9  0  5 15 12  2 11  7]
1: 2066543881
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [21, 14, 8, 18, 25, 14, 25, 10], "finishers": [], "account": 10}
[12 14  3  3 13 12 14  3]
2: 1053635564
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [21, 15, 23, 31, 35, 24, 32, 25], "finishers": [], "account": 10}
[ 0  1 15 13 10 10  7 15]
3: 4155170576
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [22, 22, 23, 46, 38, 30, 47, 25], "finishers": [], "account": 10}
[ 1  7  0 15  3  6 15  0]
4: 258207857
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [37, 23, 23, 56, 42, 39, 50, 33], "finishers": [], "account": 10}
[15  1  0 10  4  9  3  8]
5: 2207555615
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [50, 32, 30, 57, 53, 54, 53, 38], "finishers": [], "account": 10}
[13  9  7  1 11 15  3  5]
6: 1408964509
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [51, 33, 31, 71, 60, 58, 67, 38], "finishers": [], "account": 10}
[ 1  1  1 14  7  4 14  0]
7: 239591697
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [57, 35, 44, 84, 63, 72, 81, 45], "finishers": [], "account": 10}
[ 6  2 13 13  3 14 14  7]
8: 2128862502
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [71, 37, 48, 85, 77, 72, 81, 55], "finishers": [], "account": 10}
[14  2  4  1 14  0  0 10]
9: 2685277230
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [75, 47, 63, 94, 87, 82, 87, 63], "finishers": [], "account": 10}
[ 4 10 15  9 10 10  6  8]
10: 2259328932
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [84, 57, 64, 108, 102, 94, 93, 66], "finishers": [], "account": 10}
[ 9 10  1 14 15 12  6  3]
11: 919593385
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [91, 64, 67, 116, 106, 96, 106, 69], "finishers": [], "account": 10}
[ 7  7  3  8  4  2 13  3]
12: 1025803127
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [102, 71, 68, 129, 115, 101, 119, 76], "finishers": [], "account": 10}
[11  7  1 13  9  5 13  7]
13: 2103038331
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [111, 73, 80, 135, 123, 114, 130, 91], "finishers": [], "account": 10}
[ 9  2 12  6  8 13 11 15]
14: 4225264681
Received message: {"type": "race_information", "can_bet": "false", "raccoons": [119, 84, 95, 143, 131, 119, 137, 104], "finishers": [], "account": 10}
[ 8 11 15  8  8  5  7 13]
15: 3612905400
...
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received 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}
Received message: {"type": "result", "value": [3, 2, 0, 1, 4, 7, 5, 6]}
Received message: {"type": "bet_status", "value": "you won the bet, your account now has $1280"}
Received message: {"type": "race_information", "can_bet": "true", "raccoons": [0, 0, 0, 0, 0, 0, 0, 0], "finishers": [], "account": 1280}
Received message: {"type": "betting-starts", "until": 1715572927.6933398}
Received message: {"type": "flag", "value": "SDCTF{m3rs3nn3_tw15t3r_15_5cuff3d_b300818768b0}"}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment