Tag List
Sign In

City Control

City Control

A programming challenge from the Hacky Holidays CTF. This challenge is intended to be run on a badge from the MCH 2022 conference, however it can also be run inside an emulator here.

Flag 0: Find the easter egg

The RPI emulator provided allows us to explore the micropython code loaded on the badge. Poking around reveals the file app/secret.py:

def get_secret():
    s0 = "$hlWAF5qAbrAp4ek*N--4Gm0s"
    s1 = 'g<*,1?\x02\x19q\x0c-#\x05\x03:XG,HIPt\tM'

    return "".join([chr(ord(a) ^ ord(b)) for a, b in zip(s0, s1)])

If we run this code we get the first flag:

>>> get_secret()
'CTF{py7h0n_bu7_3mbedd3d}'

Flag 1: Control the City

Taking a look at the source code we find that the game communicates with a webserver using a websocket and the socket.IO protocol.

The first thing I do is write a simple python program to connect to the websocket so we can see what the server sends us:

from __future__ import annotations
import socketio
import asyncio

url = "http://portal.hackazon.org:17001"
sio = socketio.AsyncClient(logger=True)

@sio.event
async def connect():
    print("Connected!")

@sio.on("*")
async def log(event, data=None):
    print("Fall through event (host) !", event, data)

async def main():
    await sio.connect(url)
    await sio.wait()

asyncio.run(main())

Running this prints the following:

λ python initial.py
Engine.IO connection established
Received event "welcome" [/]
Fall through event (host) ! welcome {'uid': 1}
Namespace / is connected
Connected!

Great, we can now start figuring out what we need to send.

Websocket commands

Looking through the code, we find that the server can send the following messages to the client.

Message name Description
welcome Sent when the client connects to the server, contains the uid of the client
game_info Sent when the client creates or a player joins a game
game_started Sent when the game starts, prompts the interface to show an instructional message
game_over You failed to complete the game
command Used to signal an action that should be performed by a player
grid Updates the display with a set of switches and knobs to configure
health_info Updates the current 'health' of the player
next_level Notifies that a level was completed

The client seems to send the following messages to the server:

Message name Description
create_game Used to start a lobby
join_game Used to join a lobby
ready Marks the player as ready to start
start_game If all players in a lobby are ready, start the game
intro_done Seems to be a second 'ready up', the server only starts sending commands after all clients send this message
command Used to update the value a switch or knob on the player's grid
exception Seems to be used for reporting errors

Protocol deciphering

With some playing around and reading of the source code I began to piece together the protocol for the game.

To play a game the following actions need to happen:

  1. An initial client creates a lobby, the server will send back a join code for the lobby.
  2. Another client then joins the lobby using the join code.
  3. Both players need to ready up.
  4. The creator of the lobby can then send a start_game command.
  5. Both players then send a intro_done message.
  6. The server will now begin to send command messages.

I ended up with the following sequence of messages to get a game into a playing state:

Playing the game

Now to playing the game. Once started the server will send command messages to each player with content such as {'text': 'Increase park space to 5', 'time': 25, 'expired': False}, which corresponds to sending the command {'name': 'park space', 'value': 5}. In the game this would be performed by pressing the button mapping to changing one of the knobs on the screen, however since we're automating this we can just cheat.

There's just the one problem: the server sends commands to each client, however a client can only send back commands for controls that appear on its own grid.

My solution is to build a map of controls to client sockets using the body of the grid message, which looks like this:

[
  {
    "name": "flat it",
    "type": "BINARY_SWITCH",
    "value": false
  },
  {
    "name": "villa hungry",
    "type": "ROTARY_SWITCH",
    "min": 1,
    "max": 5,
    "value": 5
  },
  {
    "name": "file red",
    "type": "BINARY_SWITCH",
    "value": false
  },
  {
    "name": "park space",
    "type": "VERTICAL_SLIDER",
    "min": 1,
    "max": 6,
    "value": 1
  }
]

By building a mapping shared between each client, we can just have each client respond to a command with the socket from the correct client, which the server happily accepts.

Understanding english

Commands sent to clients are a English sentence, luckily there's only a few different sentences the server is able to send, so we can just bruteforce it...

GridMap = dict[str, tuple[GridEntry, Any]]

@dataclass
class Activate(Emittable):
    name: str
    value: bool

    @classmethod
    def parse(cls, cmd: str, grid: GridMap) -> Optional[Activate]:
        regexes = [r"Activate (?<name>[a-zA-Z ]+)",
                   r"Turn on (?<name>[a-zA-Z ]+)",
                   r"Switch on (?<name>[a-zA-Z ]+)",
                   r"Engage (?<name>[a-zA-Z ]+)",
                   ]
        for pat in regexes:
            if (match := regex.match(pat, cmd)) is not None:
                return Activate(name=match["name"], value=True)
        regexes = [r"Deactivate (?<name>[a-zA-Z ]+)",
                   r"Turn off (?<name>[a-zA-Z ]+)",
                   r"Switch off (?<name>[a-zA-Z ]+)",
                   r"Disengage (?<name>[a-zA-Z ]+)",
                   ]
        for pat in regexes:
            if (match := regex.match(pat, cmd)) is not None:
                return Activate(name=match["name"], value=False)

    def emit(self):
        return {"name": self.name, "value": self.value}

@dataclass
class Change(Emittable):
    name: str
    to: int

    @classmethod
    def parse(cls, cmd: str, grid: GridMap) -> Optional[Change]:
        regexes = [r"Set (?<name>[a-zA-Z ]+) to (?<to>[0-9]+)",
                   r"Change (?<name>[a-zA-Z ]+) to (?<to>[0-9]+)",
                   r"Increase (?<name>[a-zA-Z ]+) to (?<to>[0-9]+)",
                   r"Position (?<name>[a-zA-Z ]+) at (?<to>[0-9]+)",
                   r"Reduce (?<name>[a-zA-Z ]+) to (?<to>[0-9]+)",
                   r"Diminish (?<name>[a-zA-Z ]+) to (?<to>[0-9]+)",
                   ]
        for pat in regexes:
            if (match := regex.match(pat, cmd)) is not None:
                return Change(name=match["name"], to=int(match["to"]))
        regexes = [r"Set (?<name>[a-zA-Z ]+) to maximum",
                   r"Increase (?<name>[a-zA-Z ]+) to the max",
                   ]
        for pat in regexes:
            if (match := regex.match(pat, cmd)) is not None:
                val = grid[match["name"]][0]["max"]
                assert val is not None
                return Change(name=match["name"], to=val)
        regexes = [r"Set (?<name>[a-zA-Z ]+) to minimum",
                   r"Reduce (?<name>[a-zA-Z ]+) to the minimum",
                   ]
        for pat in regexes:
            if (match := regex.match(pat, cmd)) is not None:
                val = grid[match["name"]][0]["min"]
                assert val is not None
                return Change(name=match["name"], to=val)

    def emit(self):
        return {"name": self.name, "value": self.to}


def parse_command(command: str, grid: GridMap) -> Optional[tuple[dict, Any]]:
    if "Prepare" in command:
        return None
    for c in [Activate, Change]:
        if (r := c.parse(command, grid)) is not None:
            x = r.emit()
            print(f"sending command {x}")
            try:
                s = grid[r.name][1]
            except:
                print("what?? grid:",  grid)
                raise
            return x, s
    print(f"!!!!!!!Unknown command!!!! {command=}")

Simple enough, our command event handler then just looks like this:

@sio.event
async def command(data: CommandRequest):
    print("Got command (other)", data)
    if (cmd := parse_command(data["text"], shared_grid)) is not None:
        await cmd[1].emit("command", cmd[0])

Now we just need to wait for a couple of minutes for the bots to get to level 60 something and the server will send us our flag!

You can find the full solution here.