👨‍💻 Challenge provided by: Olivia
🗺️ Location: Retro Emporium - Area: retroshop. Coordinates: 9, 2
Challenge File: FreeSki.exe
GitHub URL (if HHC2025 is no longer available): FreeSki.exe
Challenge Description
Go to the retro store and help Goose Olivia ski down the mountain and collect all five treasure chests to reveal the hidden flag in this classic SkiFree-inspired challenge.
Hints
Extraction
Have you ever used PyInstaller Extractor?
Decompilation!
Many Python decompilers don’t understand Python 3.13, but Decompyle++ does!
Solution
First thing I tried is running the executable:

It is trying to load an image called skier.png from a folder called img in my home folder. As this does not exist, it fails to load. From the game name, I guessed that we were dealing with a modified version of the classic SkiFree which I had played myself as a kid:

You can just place all the needed images in the right folder. Then it will complain about fonts that you need to place in a folder called fonts in your home folder. Once everything is in place, you will be able to play the game:

Looks like there are 7 different mountains (Mount Snow, Aspen, Whistler, Mount Baker, Mount Norquay, Mount Erciyes, and Dragonmount) and the objective is to find 5 treasures in each mountain. This looks complicated to do just by playing the game fairly. So let’s follow Olivia’s advice: “If you ain’t cheatin’, you ain’t tryin’“.
From the error we got when first executing the game and all the hints, we know that we are dealing with a Python program using Pygame and packaged using PyInstaller.
Step 1 - Decompiling
Following the hints, I downloaded a copy of pyinstxtractor.py and executed it as follows:
python3 pyinstxtractor.py FreeSki.exe
This created a folder called FreeSki.exe_extracted. The folder contains the result of unpacking the executable, basically it gives us compiled Python bytecode (.pyc) files. We can see that one of the files in the folder is FreeSki.pyc.
The next step is to decompile FreeSki.pyc, which contains the game logic. The hints suggest that we use Decompyle++ so I installed as follow:
git clone https://github.com/zrax/pycdc.git
cd pycdc
mkdir build
cd build
cmake ..
makeThat will create 2 executables at pycdc-master/build/:
pycdas: To disassemble a.pycfilepycdc: To decompile a.pycfile:
I initially tried pycdc to decompile FreeSki.pyc as follow:
./pycdc FreeSki.exe_extracted/FreeSki.pycThis was the result:
# Source Generated with Decompyle++
# File: FreeSki.pyc (Python 3.13)
Unsupported opcode: MAKE_FUNCTION (122)
import pygame
import enum
import random
import binascii
None()
pygame.font.init()
screen_width = 800
screen_height = 600
framerate_fps = 60
object_horizonal_hitbox = 1.5
object_vertical_hitbox = 0.5
max_speed = 0.4
accelerate_increment = 0.02
decelerate_increment = 0.05
scale_factor = 0.1
pixels_per_meter = 30
skier_vertical_pixel_location = 100
mountain_width = 1000
obstacle_draw_distance = 23
skier_start = 5
grace_period = 10
screen = pygame.display.set_mode((screen_width, screen_height))
clock = pygame.time.Clock()
dt = 0
pygame.key.set_repeat(500, 100)
pygame.display.set_caption('FreeSki v0.0')
skierimage = pygame.transform.scale_by(pygame.image.load('img/skier.png'), scale_factor)
skier_leftimage = pygame.transform.scale_by(pygame.image.load('img/skier_left.png'), scale_factor)
skier_rightimage = pygame.transform.scale_by(pygame.image.load('img/skier_right.png'), scale_factor)
skier_crashimage = pygame.transform.scale_by(pygame.image.load('img/skier_crash.png'), scale_factor)
skier_pizzaimage = pygame.transform.scale_by(pygame.image.load('img/skier_pizza.png'), scale_factor)
treeimage = pygame.transform.scale_by(pygame.image.load('img/tree.png'), scale_factor)
yetiimage = pygame.transform.scale_by(pygame.image.load('img/yeti.png'), scale_factor)
treasureimage = pygame.transform.scale_by(pygame.image.load('img/treasure.png'), scale_factor)
boulderimage = pygame.transform.scale_by(pygame.image.load('img/boulder.png'), scale_factor)
victoryimage = pygame.transform.scale_by(pygame.image.load('img/victory.png'), 0.7)
gamefont = pygame.font.Font('fonts/VT323-Regular.ttf', 24)
text_surface1 = 'Use arrow keys to ski and find the 5 treasures!'(False, pygame.Color, None('blue'))
text_surface2 = " find all the lost bears. don't drill into a rock. Win game."(False, pygame.Color, None('yellow'))
flagfont = pygame.font.Font('fonts/VT323-Regular.ttf', 32)
flag_text_surface = 'replace me'(False, pygame.Color, None('saddle brown'))
flag_message_text_surface1 = 'You win! Drill Baby is reunited with'(False, pygame.Color, None('yellow'))
flag_message_text_surface2 = 'all its bears. Welcome to Flare-On 12.'(False, pygame.Color, None('yellow'))
# WARNING: Decompyle incompleteThe decompilation did not work as expected. We can see at the top the error:
Unsupported opcode: MAKE_FUNCTION (122). This means the decompiler doesn’t yet support this specific bytecode instruction introduced or changed in Python 3.13. It turns out that because Python 3.13 is quite new, Decompyle++ does not yet support Python 3.13 completely to fully decompile the code. We can see a crucial hint in the partially decompiled code:
text_surface2 = " find all the lost bears. don't drill into a rock. Win game."(False, pygame.Color, None('yellow'))
flagfont = pygame.font.Font('fonts/VT323-Regular.ttf', 32)
flag_text_surface = 'replace me'(False, pygame.Color, None('saddle brown'))
flag_message_text_surface1 = 'You win! Drill Baby is reunited with'(False, pygame.Color, None('yellow'))
flag_message_text_surface2 = 'all its bears. Welcome to Flare-On 12.'(False, pygame.Color, None('yellow'))The text Welcome to Flare-On 12 refers to Google’s FLARE team’s annual CTF contest. If we go to Flare-On 12, we can see the solutions to 2025’s challenges. The first puzzle is called: Drill Baby Drill!
That’s too much of a coincidence, so let’s check the solution to that challenge. That challenge is definitely very similar to Free Ski. So it is very likely that the treasure positions in the 5 mountains will be fixed, and we will be able to figure out the position from the source code. However, in our case, the decompilation failed. I checked other challenges from Flare-On 12. The second challenge, Project Chimera, had a similar problem to ours: decompiling the code was giving errors. The suggestion was to use pycdas instead to get the disassembled Python bytecode:
./pycdas FreeSki.exe_extracted/FreeSki.pyc > FreeSki.pyasmThe entire assembly code is here. Looking through it, we can see some interesting data, for example:
2498 LOAD_CONST 58: 'Aspen'
2500 LOAD_CONST 59: 11211
2502 LOAD_CONST 60: 11000
2504 LOAD_CONST 61: 10000
2506 LOAD_CONST 62: b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'
Through experimenting with the game, looks like the mountains have some constants defined: the name, max elevation, elevation at which trees start, and elevation at which the Yeti comes. The hex string is likely the flag for that level, and if the same approach as in Drill Baby Drill from Flare-On 12 is used, it is likely an XORed value.
Although it would be possible to reverse engineer the assembly to understand exactly how to retrieve the flag, I decided to follow the same approach from Project Chimera. AI tools are quite good at interpreting assembly so we can upload it and get the original python code for the game. I used DeepSeek for no other reason that I had never tested it and wanted to give it a go, but I’m pretty sure any of the other available options would have done an equally good job. This was the result: FreeSki-deepseek.py. The game seems to work just like using the original .exe, indicating that the code is good enough.
Step 2 - Winning the Game
The code has a mountain object defined as follow:
class Mountain:
def __init__(self, name, height, treeline, yetiline, encoded_flag):Within the Mountain class, we can also see how the treasures are placed in the game:
def GetTreasureLocations(self):
locations = {}
random.seed(binascii.crc32(self.name.encode('utf-8')))
prev_height = self.height
prev_horiz = 0
for i in range(0, 5):
e_delta = random.randint(200, 800)
h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
locations[prev_height - e_delta] = prev_horiz + h_delta
prev_height -= e_delta
prev_horiz += h_delta
return locationsrandom.seed(binascii.crc32(self.name.encode('utf-8'))) is basically setting a deterministic seed, based on the CRC32 of the mountain name. To clarify, random.seed(...) initializes Python’s pseudorandom number generator with that integer. From this point onward, any calls to random.random(), random.randint(), etc. will produce the same sequence of values every time you run the program. This means that we can calculate where the 5 treasures for a specific map will be, and these coordinates will always be the same.
The 5 treasure locations are then set in the for loop:
- Elevation delta:
e_delta = random.randint(200, 800). The treasure row moves down each time between 200 and 800 units. - Horizontal delta:
h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4)). The horizontal position is a quarter of what e_delta was and it can be left or right. - Final position:
- Elevation:
prev_height - e_delta - Horizontal:
prev_horiz + h_delta
- Elevation:
Let’s use Mount Erciyes (which has a maximum height of 12,848) as an example and run through loop 1 to understand better how the position of the treasures is calculated:
locations = {}
random.seed(binascii.crc32('Mount Erciyes'.encode('utf-8'))) # seed = 4189245800
prev_height = 12848
prev_horiz = 0
e_delta = random.randint(200, 800) # e_delta = 613
h_delta = random.randint(int(-e_delta/4), int(e_delta/4)) # h_delta = 10
locations[prev_height - e_delta] = prev_horiz + h_delta # locations = {12235: 10}Now let’s play the game to test it:

It matches! There’s a slight difference with the coordinates in the screenshot, for elevation because I was still just above the treasure and for horizontal due to the PNG I used being too big. The game also calculates a hit range so you don’t have to be exactly at 10.00 to collect the treasure, but this is not relevant to the challenge so I won’t explain how this is calculated.
Using that information, I created treasure-calculator.py. The script calculates and prints the treasure positions for all the mountains. Below an example of the output for Mount Norquay:
============================================================
Mount Norquay (Height: 6998)
Treasure locations (elevation, horizontal):
------------------------------------------------------------
Treasure 1:
Elevation: 6642
Horizontal: -67
Elevation diff from initial position = 351
Treasure 2:
Elevation: 5901
Horizontal: -13
Elevation diff from initial position = 1092
Treasure 3:
Elevation: 5692
Horizontal: -8
Elevation diff from initial position = 1301
Treasure 4:
Elevation: 5486
Horizontal: -57
Elevation diff from initial position = 1507
Treasure 5:
Elevation: 5115
Horizontal: -146
Elevation diff from initial position = 1878
Even knowing where the treasures are would make playing fair very hard. So I created a modified version of the game with treeline and yetiline set to 0:
Mountains = [
Mountain('Mount Snow', 3586, 0, 0, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
Mountain('Aspen', 11211, 0, 0, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
Mountain('Whistler', 7156, 0, 0, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
Mountain('Mount Baker', 10781, 0, 0, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
Mountain('Mount Norquay', 6998, 0, 0, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
Mountain('Mount Erciyes', 12848, 0, 0, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb31^\x93F\x1a\xee'),
Mountain('Dragonmount', 16282, 0, 0, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5')
]With these changes, playing the game and collecting all five treasures becomes easy:

Solution: frosty_yet_predictably_random
Extras
It is not really needed to win the game to get the flag. Once we have the source code, we can simply figure out how the flag is being calculated to get it. This is the code:
def SetFlag(mountain, treasure_list):
product = 0
for treasure_val in treasure_list:
product = (product << 8) ^ treasure_val
random.seed(product)
decoded = []
for i in range(0, len(mountain.encoded_flag)):
r = random.randint(0, 255)
decoded.append(chr(mountain.encoded_flag[i] ^ r))
flag_text = 'Flag: %s' % ''.join(decoded)
print(flag_text)
global flag_text_surface
flag_text_surface = flagfont.render(flag_text, False, pygame.Color('saddle brown'))This is how setFlag is called from main():
if collided_object == Obstacles.TREASURE:
collided_row[1][collided_row_offset] = None
treasures_collected.append(collided_row[0] * mountain_width + collided_row_offset)
if len(treasures_collected) == 5:
SetFlag(mnt, treasures_collected)
victory_mode = TrueWhen you collect a treasure, the game calculates a value and stores it in treasures_collected:
treasures_collected.append(collided_row[0] * mountain_width + collided_row_offset)Basically, the treasure value is: elevation * mountain_width + horizontal_position.
When we have the 5 treasures, setFlag is called with the mountain name and the treasure values stored in treasures_collected. Then we have:
product = 0
for treasure_val in treasure_list:
product = (product << 8) ^ treasure_valEach treasure value (32-bit integer) is shifted left 8 bits and XORed into the product. The result is used to initialize the seed: random.seed(product). This means that the seed is based on the treasure locations, and as we learned before, these locations are predictable and always the same.
The final decryption is done here:
decoded = []
for i in range(0, len(mountain.encoded_flag)):
r = random.randint(0, 255)
decoded.append(chr(mountain.encoded_flag[i] ^ r))- For each byte in the encrypted flag, generate a random byte (0-255) - randomly but predictable due to how the seed is initialized.
- XOR the encrypted byte with the random byte to get the plaintext character.
If we use the code we had before to calculate the treasure locations and we pass it to SetFlag we can calculate the flag value without having to win the game:
import binascii
import random
class Mountain:
def __init__(self, name, height, encoded_flag):
self.name = name
self.height = height
self.encoded_flag = encoded_flag
def calculate_treasure_locations(mountain_name, mountain_height):
"""Returns list of (elevation, horizontal_position) for all 5 treasures"""
random.seed(binascii.crc32(mountain_name.encode('utf-8')))
prev_height = mountain_height
prev_horiz = 0
locations = []
for i in range(5):
e_delta = random.randint(200, 800)
h_delta = random.randint(int(-e_delta/4), int(e_delta/4))
elevation = prev_height - e_delta
horiz = prev_horiz + h_delta
locations.append((elevation, horiz))
prev_height = elevation
prev_horiz = horiz
return locations
def SetFlag(mountain, treasure_list):
product = 0
for treasure_val in treasure_list:
product = (product << 8) ^ treasure_val
random.seed(product)
decoded = []
for i in range(0, len(mountain.encoded_flag)):
r = random.randint(0, 255)
decoded.append(chr(mountain.encoded_flag[i] ^ r))
flag_text = 'Flag: %s' % ''.join(decoded)
return flag_text
# Mount Snow data
mountain_name = 'Mount Snow'
mountain_height = 3586
encoded_flag = b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'
# Create Mountain object
mountain = Mountain(mountain_name, mountain_height, encoded_flag)
# Get treasure locations
treasure_locations = calculate_treasure_locations(mountain_name, mountain_height)
# Build treasure list
treasure_list = []
for elev, horiz in treasure_locations:
treasure_val = elev * 1000 + horiz # MOUNTAIN_WIDTH = 1000
treasure_list.append(treasure_val)
# Decrypt the flag
flag_text = SetFlag(mountain, treasure_list)
# Print only the flag (remove "Flag: " prefix)
print(flag_text[6:])Result: frosty_yet_predictably_random