Prologue
When playtesting Holiday Hack 24 ACT II Snowball Showdown, I needed another player to test the challenge with.
Instead of pinging others to test it with me, I really wanted to bend the challenge and try to get a bot to play with me.
My go-to way of testing a challenge is to not play the challenge.
If I play a challenge how it’s intended, it has already been playtested by the developer.
If I don’t “play” the challenge, I can find loopholes before others.
Also worth noting, I only black box these challenges.
This is because I want to be a player like everyone else!
Anyway, I want a simple bot that joins the game and absolutely wrecks all opposition.
Let’s dig into it!
Deconstructing the challenge
The challenge requires two players to join the lobby and then throw snowballs at other elves.
Starting off, I opened Burp Suite to check out what is being transmitted when playing the challenge.
The first HTTP message sent to initiate the game was a GET request with some parameters for the backend to interpret.
I’ve pasted an example of the GET request below and removed the uninteresting parts:
1
2
3
4
5
6
7
8
|
GET /ws/?username=TGC&roomId=1c885b02&roomType=private&id=00000000-0000-0000-0000-000000000000&dna=ATATA[...]&pos=258%2C1040&singlePlayer=false HTTP/2
Host: hhc24-snowballshowdown.holidayhackchallenge.com
Connection: Upgrade
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.60 Safari/537.36
Upgrade: websocket
Origin: https://hhc24-snowballshowdown.holidayhackchallenge.com
Sec-Websocket-Version: 13
Sec-Websocket-Key: TktageFqNeLUVJ3nvow01w==
|
The URI values in the GET request are:
- username: The players username that will be displayed
- roomId: Room ID used to set up the game lobby
- roomType: If the room is public or private
- id: The player ID that the server will eventually submit the challenge trophy to
- DNA: What the player looks like
- pos: The current position of the player when we spawn in
- singlePlayer: If the current match is single or multiplayer
These values are good to know when building the bot as they are a part of initializing the connection for the bot.
The GET request also tells the server to swap protocols to the websocket for the rest of the game.
Before the game starts, the first websocket message is an initialization message {"type":"init"}
to start the game lobby.
The server responds with a HUGE JSON blob describing where all elves are, if they are hit, and so on.
It’s basically the entire game state with all players, elves, and so on.
I’ve shortened it down to what we need for simplicity’s sake.
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
|
{
"type":"init",
[...]
"alabasterElves":
[
{
"x":206,
"y":1050,
"uuid":"00000000-0000-0000-0000-000000000000",
"lastThrowTime":1737666645474,
"isWomb":false,
"isKo":false,
"timeOfKo":0,
"hitbox":
{
"x":-16.666666666666668,
"y":-133.33333333333334,
"width":33.333333333333336,
"height":100
},
"throwPosition":
{
"x":206,
"y":950
},
"obj_type":"elf",
"adjustedhitbox":
{
"x":189.33333333333334,
"y":920,
"width":33.333333333333336,
"height":100
}
},
[...]
]
}
|
When the player is ready to play, the webclient sends a ready message.
Both players need to have sent an initialization and ready message before the game starts.
1
|
{"type":"ready","playerId":"00000000-0000-0000-0000-000000000000"}
|
So the game starts, snowballs are flying and we need to fight back!
We cast the snowball and captured the request.
1
2
3
4
5
6
7
8
9
10
|
{
"type":"snowballp",
"x":657,
"y":920.9432373046875,
"owner":"00000000-0000-0000-0000-000000000000",
"isWomb":false,
"blastRadius":24,
"velocityX":181.46405386766966,
"velocityY":-511.13564547988574
}
|
Building the bot
Great, we have all we need for the bot!
The game in it self will record how many hits to Alabaster and Wombley, but I won’t bother with that.
So we start off with a simple Python script that is able to join our game.
We use the original GET request as a basis https://hhc24-snowballshowdown.holidayhackchallenge.com/ws/?username=TGC_BOT&roomId=1c885b02&roomType=private&id=00000000-0000-0000-0000-000000000000&dna=ATAT[...]&pos=258%2C1040&
.
We will add argparse
for simplicity.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import websocket
import argparse
import time
# Get the lobby key from the client
parser = argparse.ArgumentParser()
parser.add_argument("lobby", help="lobby key")
args = parser.parse_args()
# The socket we want to connect to
socket_name = f"wss://hhc24-snowballshowdown.chc-ops.com/ws/?username=TGC_BOT&roomId={args.lobby}&roomType=private&id=00000000-0000-0000-0000-000000000000&dna=ATATATTAATATATATATATTACGATATATATCGGCGCCGATATATATATATATGCATATATATATATTAATATATTACGATATATATATATGCCGATATATATATATTAGCATATTAGC&pos=423%2C1040&singlePlayer=false"
# Create and connect to the socket
ws = websocket.WebSocket()
ws.connect(socket_name)
# Sleep for 10 seconds to verify that the bot is in fact there
time.sleep(10)
# Leave the game
ws.close()
|
Testing that out, the bot joins the lobby! Success!
Now we need to make sure the bot does more than just stand there.
We know that when a player joins a lobby, they need to send the initialization message and a ready message when the player is ready.
To do this we need to add a ws.send()
message to communicate with the server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import argparse
import time
# Get the lobby key from the client
parser = argparse.ArgumentParser()
parser.add_argument("lobby", help="lobby key")
args = parser.parse_args()
# The socket we want to connect to
socket_name = f"wss://hhc24-snowballshowdown.chc-ops.com/ws/?username=TGC_BOT&roomId={args.lobby}&roomType=private&id=00000000-0000-0000-0000-000000000000&dna=ATATATTAATATATATATATTACGATATATATCGGCGCCGATATATATATATATGCATATATATATATTAATATATTACGATATATATATATGCCGATATATATATATTAGCATATTAGC&pos=423%2C1040&singlePlayer=false"
# Create and connect to the socket
ws = websocket.WebSocket()
#websocket.enableTrace(True) # Add this if you want to debug your websocket connections
ws.connect(socket_name)
ws.send('{"type":"init"}')
ws.send('{"type":"ready"}')
# Sleep for 10 seconds to verify that the bot is in fact there
time.sleep(10)
# Leave the game
ws.close()
|
Sweet the bot joins, starts the game, and stands there!
Progress!
Now we need to throw snowballs!
Let’s grab the snowball-throwing JSON blob from earlier and implement that as well.
However, the environment reacts to snowballs, it is removed and regenerates over time.
So we can crank up the velocity to blast straight through the ice wall and hit Wombley directly!
This admittedly took some trial and error to get the correct settings for velocity x, y, velocityX and velocityY.
Oh, and one more thing, let’s increase the blast range as well.
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
|
import websocket
import argparse
import time
# Get the lobby key from the client
parser = argparse.ArgumentParser()
parser.add_argument("lobby", help="lobby key")
args = parser.parse_args()
# The socket we want to connect to
socket_name = f"wss://hhc24-snowballshowdown.chc-ops.com/ws/?username=TGC_BOT&roomId={args.lobby}&roomType=private&id=00000000-0000-0000-0000-000000000000&dna=ATATATTAATATATATATATTACGATATATATCGGCGCCGATATATATATATATGCATATATATATATTAATATATTACGATATATATATATGCCGATATATATATATTAGCATATTAGC&pos=423%2C1040&singlePlayer=false"
# Create and connect to the socket
ws = websocket.WebSocket()
#websocket.enableTrace(True) # Add this if you want to debug your websocket connections
ws.connect(socket_name)
ws.send('{"type":"init"}')
ws.send('{"type":"ready"}')
# Try Except for error and socket handling
try:
# Keep throwing snowballs
while True:
# Throw the snowball
ws.send('{"type":"snowballp","x":468,"y":915.0354614257812,"owner":"00000000-0000-0000-0000-000000000000","isWomb":false,"blastRadius":2400000,"velocityX":1509.5181086112393,"velocityY":-27.847382067389763}')
# Sleep a little to avoid overloading the server
time.sleep(0.2)
# Add logic to terminate the connection with Ctrl+C
except KeyboardInterrupt:
print("Terminating connection...")
finally:
# Leave the game
ws.close()
|
Sweet! We have a bot that joins the game, and absolutely wrecks all moving targets on the other side of the icewall!
One thing that annoys me with this setup is that the other elves standing on platforms still throw snowballs and they hit the bot from time to time.
The server doesn’t allow snowballs to be thrown if the bot is frozen.
We need to target them as well, but they randomly spawn each time a lobby is created.
To reliably target these elves, we need to get their positions from the initialization message.
To break it up, we first create a function to receive the JSON message and return the contents.
We need to use ws.recv()
to retrieve the answer from the server.
1
2
3
4
5
6
7
8
9
10
11
|
import json
[...]
# Get position function
def get_pos():
# Send the init message
ws.send('{"type":"init"}')
# Assume that the next message from the server is the initialization message
init_msg = ws.recv()
# Return the message loaded into a JSON object
return json.loads(init_msg)
[...]
|
After getting the contents, we need to extract the positions of each Wombley elves.
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
|
import json
[...]
# Get position function
def get_pos():
# Send the init message
ws.send('{"type":"init"}')
# Assume that the next message from the server is the initialization message
init_msg = ws.recv()
# Return the message loaded into a JSON object
return json.loads(init_msg)
[...]
# Function to get the positions of all wombleyElves
def get_wombley_elves_positions(data):
# Extract the Wombley elves
wombley_elves = data.get("wombleyElves", [])
# Create an array to store all positions and return them
positions = []
for elf in wombley_elves:
pos = {
"hitbox_x": elf.get("adjustedhitbox", {}).get("x"),
"hitbox_y": elf.get("adjustedhitbox", {}).get("y")
}
positions.append(pos)
return positions
|
Now we can target each elf individually.
The simplest way to do this is just to chuck a snowball at each elf in succession. ¯\_(ツ)_/¯
I tested to find the correct settings for velocity, x, y, and so forth, so that is implemented.
The final script is below, and oh boy it works!
[!] If you are going to try this yourself, please add a time.sleep(0.2)
to the snowballs to avoid overloading the server.
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
|
import websocket
import argparse
import json
import time
parser = argparse.ArgumentParser()
parser.add_argument("lobby", help="lobby key")
args = parser.parse_args()
socket_name = f"wss://hhc24-snowballshowdown.chc-ops.com/ws/?username=TGC_BOT&roomId={args.lobby}&roomType=private&id=00000000-0000-0000-0000-000000000000&dna=ATATATTAATATATATATATTACGATATATATCGGCGCCGATATATATATATATGCATATATATATATTAATATATTACGATATATATATATGCCGATATATATATATTAGCATATTAGC&pos=423%2C1040&singlePlayer=false"
def get_wombley_elves_positions(data):
wombley_elves = data.get("wombleyElves", [])
positions = []
for elf in wombley_elves:
pos = {
"hitbox_x": elf.get("adjustedhitbox", {}).get("x"),
"hitbox_y": elf.get("adjustedhitbox", {}).get("y")
}
positions.append(pos)
return positions
def get_pos():
ws.send('{"type":"init"}')
init_msg = ws.recv()
def init():
def get_pos():
ws.send('{"type":"init"}')
init_msg = ws.recv()
return json.loads(init_msg)
ws = websocket.WebSocket()
ws.connect(socket_name)
ws.send('{"type":"init"}')
init_msg = ws.recv()
wombley_positions = get_wombley_elves_positions(json.loads(init_msg))
ws.send('{"type":"ready"}')
try:
while True:
if not wombley_positions:
pos = get_pos()
wombley_positions = get_wombley_elves_positions(pos)
ws.send('{"type":"snowballp","x":468,"y":915.0354614257812,"owner":"00000000-0000-0000-0000-000000000000","isWomb":false,"blastRadius":2400000,"velocityX":1509.5181086112393,"velocityY":-27.847382067389763}')
for elf in wombley_positions:
snowball = '{"type":"snowballp","x":'+str(elf['hitbox_x']-900)+',"y":'+str(elf['hitbox_y'])+',"owner":"00000000-0000-0000-0000-000000000000","isWomb":false,"blastRadius":2400000,"velocityX":925.9499181921042,"velocityY":-843.8110982877932}'
ws.send(snowball)
time.sleep(0.2)
except KeyboardInterrupt:
print("Terminating connection...")
finally:
ws.close()
init()
|