HHC 2025 - FreeSki: Reverse Engineering
Security research, CTF writeups, and hacking adventures
FreeSki Challenge - Complete Analysis Report
Challenge Overview
Challenge Name: FreeSki
Category: Reverse Engineering
Objective: Help Goose Olivia ski down the mountain and collect all five treasure chests to reveal the hidden flag in this classic SkiFree-inspired challenge.
Provided File: FreeSki.exe - A PyInstaller-compiled executable containing a SkiFree-inspired skiing game.
Step 1: Extracting the Python Bytecode
1.1 Download PyInstaller Extractor
First, we obtained the PyInstaller extraction tool:
wget https://raw.githubusercontent.com/extremecoders-re/pyinstxtractor/master/pyinstxtractor.py
1.2 Extract the Executable
We ran the extractor against the FreeSki.exe file:
python3 pyinstxtractor.py FreeSki.exe
This extracted the compiled Python bytecode file FreeSki.pyc from the PyInstaller bundle.
1.3 Disassemble the Bytecode
We created a disassembly script to convert the bytecode into readable format:
dis_freeski.py:
import dis, marshal
with open("FreeSki.pyc", "rb") as f:
f.read(16) # skip header
code = marshal.load(f)
dis.dis(code)
This script:
- Opens the
.pycfile in binary mode - Skips the first 16 bytes (Python bytecode header containing magic number and timestamp)
- Loads the marshalled code object
- Disassembles it into human-readable bytecode instructions
Step 2: Analyzing the Bytecode
2.1 Key Components Identified
After analyzing the disassembled bytecode, we identified several critical components:
Mountain Data Structure (Lines 251-257)
Seven mountains are defined, each containing:
name: Mountain name (used as seed)height: Total elevation in meterstreeline: Elevation above which trees appearyetiline: Elevation above which yetis appearencoded_flag: 28-byte encrypted flag data
Mountains = [
Mountain('Mount Snow', 3586, 3400, 2400,
b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
Mountain('Aspen', 11211, 11000, 10000,
b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
Mountain('Whistler', 7156, 6000, 6500,
b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
Mountain('Mount Baker', 10781, 9000, 6000,
b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
Mountain('Mount Norquay', 6998, 6300, 3000,
b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
Mountain('Mount Erciyes', 12848, 10000, 12000,
b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
Mountain('Dragonmount', 16282, 15500, 16000,
b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5')
]
Treasure Location Generation (Lines 236-248)
The GetTreasureLocations function generates 5 treasure locations deterministically:
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 = prev_height - e_delta
prev_horiz = prev_horiz + h_delta
return locations
Key insight: The treasure locations are deterministic and based solely on the mountain’s name via CRC32 hash seeding.
Flag Decryption Function (Lines 302-316)
The SetFlag function decrypts the flag when all 5 treasures are collected:
def SetFlag(mountain, treasure_list):
# Calculate product from treasures
product = 0
for treasure_val in treasure_list:
product = (product << 8) ^ treasure_val
# Seed random with product
random.seed(product)
# Decode flag
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)
Encryption mechanism:
- Combine all treasure values using left-shift and XOR:
key = (key << 8) ^ treasure_val - Seed Python’s PRNG with this key
- XOR each encrypted byte with a random byte from the seeded PRNG
Treasure Value Encoding (Line 380)
When a treasure is collected, it’s stored as:
treasure_value = collision_row[0] * mountain_width + collision_row_offset
Where:
collision_row[0]= elevation (row number)mountain_width= 1000 (constant)collision_row_offset= horizontal position
Step 3: Developing the Decryption Script
Based on our analysis, we created a Python script to decrypt all flags without playing the game:
freeski_decryptor.py:
import random
import binascii
# Mountain data with encrypted flags
mountains = [
{
'name': 'Mount Snow',
'height': 3586,
'treeline': 3400,
'yetiline': 2400,
'encoded_flag': b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'
},
{
'name': 'Aspen',
'height': 11211,
'treeline': 11000,
'yetiline': 10000,
'encoded_flag': b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'
},
{
'name': 'Whistler',
'height': 7156,
'treeline': 6000,
'yetiline': 6500,
'encoded_flag': b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'
},
{
'name': 'Mount Baker',
'height': 10781,
'treeline': 9000,
'yetiline': 6000,
'encoded_flag': b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'
},
{
'name': 'Mount Norquay',
'height': 6998,
'treeline': 6300,
'yetiline': 3000,
'encoded_flag': b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'
},
{
'name': 'Mount Erciyes',
'height': 12848,
'treeline': 10000,
'yetiline': 12000,
'encoded_flag': b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'
},
{
'name': 'Dragonmount',
'height': 16282,
'treeline': 15500,
'yetiline': 16000,
'encoded_flag': b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5'
}
]
mountain_width = 1000
def get_treasure_locations(mountain):
"""Generate treasure locations for a mountain (from GetTreasureLocations)"""
locations = {}
random.seed(binascii.crc32(mountain['name'].encode('utf-8')))
prev_height = mountain['height']
prev_horiz = 0
for i in range(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 = prev_height - e_delta
prev_horiz = prev_horiz + h_delta
return locations
def decrypt_flag(mountain, treasure_list):
"""Decrypt the flag using treasure locations (from SetFlag)"""
# Calculate product from treasures
product = 0
for treasure_val in treasure_list:
product = (product << 8) ^ treasure_val
# Seed random with product
random.seed(product)
# Decode flag
decoded = []
for i in range(len(mountain['encoded_flag'])):
r = random.randint(0, 255)
decoded.append(chr(mountain['encoded_flag'][i] ^ r))
return ''.join(decoded)
# Try decrypting all mountains
print("Attempting to decrypt flags for all mountains:\n")
for mountain in mountains:
print(f"Mountain: {mountain['name']}")
# Get treasure locations
treasures = get_treasure_locations(mountain)
print(f"Treasure locations: {treasures}")
# Convert treasures to the format used in the game
# The game stores: elevation * mountain_width + horizontal_offset
treasure_list = []
for elevation, horiz in treasures.items():
treasure_val = int(elevation) * mountain_width + horiz
treasure_list.append(treasure_val)
print(f"Treasure values: {treasure_list}")
# Decrypt flag
flag = decrypt_flag(mountain, treasure_list)
print(f"Decrypted flag: {flag}")
print("-" * 80)
print()
Step 4: Execution and Results
Running the decryption script:
python3 freeski_decryptor.py
Output:
Attempting to decrypt flags for all mountains:
Mountain: Mount Snow
Treasure locations: {2966: 113, 2420: 85, 1718: 188, 1094: 142, 466: 85}
Treasure values: [2966113, 2420085, 1718188, 1094142, 466085]
Decrypted flag: frosty_yet_predictably_random
--------------------------------------------------------------------------------
Mountain: Aspen
Treasure locations: {10865: -43, 10529: -122, 9903: -102, 9183: -61, 8621: -15}
Treasure values: [10864957, 10528878, 9902898, 9182939, 8620985]
Decrypted flag: jÆÀ0î'Zsv4&Ùo9`\EºÀª ÿg´
--------------------------------------------------------------------------------
Mountain: Whistler
Treasure locations: {6373: -141, 6127: -150, 5897: -119, 5610: -145, 5124: -184}
Treasure values: [6372859, 6126850, 5896881, 5609855, 5123816]
Decrypted flag: HáOA
ÎblÚV<lÝOÒ¸.ÿp±
--------------------------------------------------------------------------------
Mountain: Mount Baker
Treasure locations: {9997: -31, 9525: -69, 9112: -3, 8523: 106, 7856: -45}
Treasure values: [9996969, 9524931, 9111997, 8523106, 7855955]
Decrypted flag: çj=#ú%m²xæÙC3¸¬ÜÇ
--------------------------------------------------------------------------------
Mountain: Mount Norquay
Treasure locations: {6642: -67, 5901: -13, 5692: -8, 5486: -57, 5115: -146}
Treasure values: [6641933, 5900987, 5691992, 5485943, 5114854]
Decrypted flag: ðJ ©yͯZSë6éÃò&3;«°Ä§Öµ
--------------------------------------------------------------------------------
Mountain: Mount Erciyes
Treasure locations: {12235: 10, 11950: -38, 11660: -22, 11412: -16, 10701: -47}
Treasure values: [12235010, 11949962, 11659978, 11411984, 10700953]
Decrypted flag: §-ë/# îÒl_rªß@*¶ðJøæÖ¸¿ø
--------------------------------------------------------------------------------
Mountain: Dragonmount
Treasure locations: {15590: -111, 14939: -184, 14634: -193, 14339: -247, 13706: -280}
Treasure values: [15589889, 14938816, 14633807, 14338753, 13705720]
Decrypted flag:
çnf¿üãß8¦âù].ûÐ ¿Ù]éb
--------------------------------------------------------------------------------
Step 5: Flag Analysis
Result Interpretation
Only one mountain produced a readable flag: Mount Snow
Flag Found: frosty_yet_predictably_random
All other mountains produced garbled output, indicating incorrect decryption. This suggests that Mount Snow is the intended mountain for this challenge.
Why Mount Snow?
The readable flag indicates that:
- The treasure locations were calculated correctly
- The treasure values were encoded in the correct order
- The decryption key derivation matched the game’s logic
- The flag text is intentionally readable, making it the correct answer
How the Bytecode Works
Architecture Overview
The FreeSki game uses a multi-layered encryption system:
Mountain Name (String)
↓
CRC32 Hash
↓
PRNG Seed → Generate 5 Treasure Locations (Deterministic)
↓
Treasure Values = elevation * 1000 + horizontal_offset
↓
Key Derivation: key = (key << 8) ^ treasure_val (for each treasure)
↓
PRNG Seed (with derived key)
↓
XOR Decryption: plaintext[i] = ciphertext[i] ^ random_byte[i]
↓
Flag Revealed
Key Bytecode Operations
1. Mountain Initialization (Lines 251-257)
LOAD_NAME Mountain
PUSH_NULL
LOAD_CONST 'Mount Snow'
LOAD_CONST 3586
LOAD_CONST 3400
LOAD_CONST 2400
LOAD_CONST b'\x90\x00\x1d\xbc...'
CALL 5
Creates Mountain objects with encrypted flag data.
2. Treasure Location Generation (Lines 238-247)
LOAD_GLOBAL random
LOAD_ATTR seed
LOAD_GLOBAL binascii
LOAD_ATTR crc32
LOAD_FAST self.name.encode('utf-8')
CALL 1 # CRC32
CALL 1 # seed
Seeds the PRNG with CRC32 of mountain name, ensuring deterministic treasure placement.
3. Key Derivation (Lines 304-306)
LOAD_FAST product
LOAD_CONST 8
BINARY_OP << # Left shift by 8 bits
LOAD_FAST treasure_val
BINARY_OP ^ # XOR
STORE_FAST product
Combines treasure values into a single key using bit manipulation.
4. Flag Decryption (Lines 310-312)
LOAD_GLOBAL random
LOAD_ATTR randint
LOAD_CONST 0
LOAD_CONST 255
CALL 2 # Generate random byte
LOAD_FAST mountain.encoded_flag[i]
LOAD_FAST r
BINARY_OP ^ # XOR decryption
XORs each encrypted byte with a pseudorandom byte.
Security Analysis
Weaknesses Identified:
- Deterministic Treasure Placement: Using CRC32(mountain_name) as seed makes treasure locations predictable
- Weak Key Derivation: Simple bit-shift XOR is cryptographically weak
- Stream Cipher Vulnerability: Python’s PRNG is not cryptographically secure
- No Authentication: No HMAC or signature to verify correct decryption
- Static Ciphertext: All encrypted flags are embedded in the binary
Why This Works for CTF:
The challenge is designed to be solvable through reverse engineering without actually playing the game, teaching:
- PyInstaller reverse engineering
- Python bytecode analysis
- PRNG behavior understanding
- Cryptographic weaknesses
Conclusion
Final Flag
frosty_yet_predictably_random
Challenge Summary
This challenge demonstrated:
- Reverse Engineering: Extracting and analyzing Python bytecode from PyInstaller executables
- Cryptanalysis: Understanding and breaking a custom encryption scheme
- Code Analysis: Reconstructing program logic from disassembled bytecode
- Automation: Creating scripts to bypass gameplay requirements
The flag name “frosty_yet_predictably_random” is a clever reference to the weakness in the encryption scheme—the use of a predictable PRNG seeded with deterministic values derived from the mountain name.
Tools Used
- pyinstxtractor: Extract Python bytecode from PyInstaller executables
- dis module: Disassemble Python bytecode
- marshal module: Load compiled Python code objects
- Custom Python script: Automate treasure location calculation and flag decryption
Key Takeaways
- PyInstaller executables can be decompiled to reveal source logic
- Deterministic RNGs are predictable when the seed is known or calculable
- Custom encryption schemes often contain exploitable weaknesses
- Bytecode analysis can reveal program behavior without running the application
← Back to all posts