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:
- An initial client creates a lobby, the server will send back a join code for the lobby.
- Another client then joins the lobby using the join code.
- Both players need to ready up.
- The creator of the lobby can then send a
start_game
command. - Both players then send a
intro_done
message. - 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.