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:


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:

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:

  1. Combine all treasure values using left-shift and XOR: key = (key << 8) ^ treasure_val
  2. Seed Python’s PRNG with this key
  3. 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:


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:

  1. The treasure locations were calculated correctly
  2. The treasure values were encoded in the correct order
  3. The decryption key derivation matched the game’s logic
  4. 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:

  1. Deterministic Treasure Placement: Using CRC32(mountain_name) as seed makes treasure locations predictable
  2. Weak Key Derivation: Simple bit-shift XOR is cryptographically weak
  3. Stream Cipher Vulnerability: Python’s PRNG is not cryptographically secure
  4. No Authentication: No HMAC or signature to verify correct decryption
  5. 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:


Conclusion

Final Flag

frosty_yet_predictably_random

Challenge Summary

This challenge demonstrated:

  1. Reverse Engineering: Extracting and analyzing Python bytecode from PyInstaller executables
  2. Cryptanalysis: Understanding and breaking a custom encryption scheme
  3. Code Analysis: Reconstructing program logic from disassembled bytecode
  4. 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

Key Takeaways

  1. PyInstaller executables can be decompiled to reveal source logic
  2. Deterministic RNGs are predictable when the seed is known or calculable
  3. Custom encryption schemes often contain exploitable weaknesses
  4. Bytecode analysis can reveal program behavior without running the application

← Back to all posts