Free Ski¶
Table of Contents¶
- Free Ski
Overview¶
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.
Introduction¶
Olivia
HONK! Well hello there! Fancy meeting you here in the Dosis Neighborhood.
You know, it's the strangest thing... I used to just waddle around the Geese Islands going 'BONK' all day long. Random noises, no thoughts, just vibes. But then something changed, and now here I am—speaking, thinking, wondering how I even got here!
HONK! You know what happens to geese in a permanent winter? We can't migrate! And trust me, being stuck in one place forever isn't natural—even for someone who just discovered they can think and talk. Frosty needs to chill out... wait, that's exactly the problem!
This game looks simple enough, doesn't it? Almost too simple. But between you and me... it seems nearly impossible to win fair and square.
My advice? If you ain't cheatin', you ain't tryin'. wink
Now get out there and show that mountain who's boss!
Items¶
Olivia provides the FreeSki.exe binary executable to analyze.
Hints¶
Hint 1: Extraction¶
Have you ever used PyInstaller Extractor (https://github.com/extremecoders-re/pyinstxtractor)?
Hint 2: Decompilation!¶
Many Python decompilers don't understand Python 3.13, but Decompyle++ (https://github.com/zrax/pycdc) does!
Set Up the Environment¶
Install PyInstaller Extractor¶
PyInstaller Extractor is a Python script to extract the contents of a PyInstaller generated executable file.
The specific pyinstxtractor.py Python script can be downloaded from https://github.com/extremecoders-re/pyinstxtractor.
Install Decompyle++¶
Decompyle++ aims to translate compiled Python byte-code back into valid and human-readable Python source code.
The C++ source code can be downloaded from https://github.com/zrax/pycdc.
Cloning into 'pycdc'...
remote: Enumerating objects: 3023, done.
remote: Total 3023 (delta 0), reused 0 (delta 0), pack-reused 3023 (from 1)
Receiving objects: 100% (3023/3023), 944.25 KiB | 1.44 MiB/s, done.
Resolving deltas: 100% (1903/1903), done.
Build¶
Need to generate a project or makefile with CMake.
Install CMake¶
Generate the pycdc Build Files¶
-- The C compiler identification is AppleClang 17.0.0.17000404
-- The CXX compiler identification is AppleClang 17.0.0.17000404
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found Python3: /path/to/python3.14 (found suitable version "3.14.2", minimum required is "3.6") found components: Interpreter
-- Configuring done (5.2s)
-- Generating done (1.2s)
-- Build files have been written to: /path/to/pycdc/build
Build the Targets¶
[ 2%] Building CXX object CMakeFiles/pycxx.dir/bytecode.cpp.o
[ 4%] Building CXX object CMakeFiles/pycxx.dir/data.cpp.o
[ 6%] Building CXX object CMakeFiles/pycxx.dir/pyc_code.cpp.o
[ 9%] Building CXX object CMakeFiles/pycxx.dir/pyc_module.cpp.o
[ 11%] Building CXX object CMakeFiles/pycxx.dir/pyc_numeric.cpp.o
[ 13%] Building CXX object CMakeFiles/pycxx.dir/pyc_object.cpp.o
[ 16%] Building CXX object CMakeFiles/pycxx.dir/pyc_sequence.cpp.o
[ 18%] Building CXX object CMakeFiles/pycxx.dir/pyc_string.cpp.o
[ 20%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_1_0.cpp.o
[ 23%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_1_1.cpp.o
[ 25%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_1_3.cpp.o
[ 27%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_1_4.cpp.o
[ 30%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_1_5.cpp.o
[ 32%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_1_6.cpp.o
[ 34%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_0.cpp.o
[ 37%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_1.cpp.o
[ 39%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_2.cpp.o
[ 41%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_3.cpp.o
[ 44%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_4.cpp.o
[ 46%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_5.cpp.o
[ 48%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_6.cpp.o
[ 51%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_2_7.cpp.o
[ 53%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_0.cpp.o
[ 55%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_1.cpp.o
[ 58%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_2.cpp.o
[ 60%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_3.cpp.o
[ 62%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_4.cpp.o
[ 65%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_5.cpp.o
[ 67%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_6.cpp.o
[ 69%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_7.cpp.o
[ 72%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_8.cpp.o
[ 74%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_9.cpp.o
[ 76%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_10.cpp.o
[ 79%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_11.cpp.o
[ 81%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_12.cpp.o
[ 83%] Building CXX object CMakeFiles/pycxx.dir/bytes/python_3_13.cpp.o
[ 86%] Linking CXX static library libpycxx.a
[ 86%] Built target pycxx
[ 88%] Building CXX object CMakeFiles/pycdas.dir/pycdas.cpp.o
[ 90%] Linking CXX executable pycdas
[ 90%] Built target pycdas
[ 93%] Building CXX object CMakeFiles/pycdc.dir/pycdc.cpp.o
[ 95%] Building CXX object CMakeFiles/pycdc.dir/ASTree.cpp.o
[ 97%] Building CXX object CMakeFiles/pycdc.dir/ASTNode.cpp.o
[100%] Linking CXX executable pycdc
[100%] Built target pycdc
Analysis¶
Extract Files from FreeSki.exe¶
Let's extract the files using the default Python 3 installation:
[+] Processing FreeSki.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.13
[+] Length of package: 16806404 bytes
[+] Found 98 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgres.pyc
[+] Possible entry point: pyi_rth_setuptools.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: FreeSki.pyc
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python 3.13 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: FreeSki.exe
You can now use a python decompiler on the pyc files within the extracted directory
Because I did not use the exact Python version used to build the executable (i.e., Python 3.13), it skipped the PYZ extraction.
PyInstaller bundles most real code inside PYZ. What was decompiled is mostly:
- Loader stubs. - Resource initialization. - Minimal top-level code.
Let's run it again with the python3.13 binary.
[+] Processing FreeSki.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.13
[+] Length of package: 16806404 bytes
[+] Found 98 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgres.pyc
[+] Possible entry point: pyi_rth_setuptools.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: FreeSki.pyc
[+] Found 471 files in PYZ archive
[!] Error: Failed to decompress PYZ.pyz_extracted/jaraco.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_distutils/compilers.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_distutils/compilers/C.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_vendor.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_vendor/jaraco.pyc, probably encrypted. Extracting as is.
[+] Successfully extracted pyinstaller archive: FreeSki.exe
You can now use a python decompiler on the pyc files within the extracted directory
This time the Python version matched (3.13) and the PYZ archive extracted (471 files). There are a few encrypted files, which is normal.
We can see that the FreeSki.pyc file exists outside the PYZ. Most likely the game logic is not inside PYZ, but in this file.
Decompile FreeSki.pyc¶
# 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 incomplete
Unfortunately, the decompilation fails and there is no clean decompilation path:
- Python 3.13 changed bytecode structure.
- Function bodies use
MAKE_FUNCTION,CALL_INTRINSIC_*, and new stack semantics. - As of this date, there is no public decompiler able to reconstruct them yet.
Disassemble FreeSki.pyc¶
This looks like a bytecode-level challenge.
Let's disassemble FreeSki.pyc and analyze the bytecode instead of the source code.
Bytecode Analysis¶
Let's examine the FreeSki.pyasm file and see what we can discover.
SetFlag Function¶
We can see a SetFlag function in the [Names] section. This is almost certainly where the flag is generated.
[Code]
File Name: FreeSki.py
Object Name: SetFlag
Qualified Name: SetFlag
Arg Count: 2
Pos Only Arg Count: 0
KW Only Arg Count: 0
Stack Size: 7
Flags: 0x00000003 (CO_OPTIMIZED | CO_NEWLOCALS)
[Names]
'random'
'seed'
'range'
'len'
'encoded_flag'
'randint'
'append'
'chr'
'join'
'print'
'flagfont'
'render'
'pygame'
'Color'
'flag_text_surface'
[Locals+Names]
'mountain'
'treasure_list'
'product'
'treasure_val'
'decoded'
'i'
'r'
'flag_text'
[Constants]
None
0
8
255
'Flag: %s'
''
False
'saddle brown'
[Disassembly]
0 RESUME 0
2 LOAD_CONST 1: 0
4 STORE_FAST 2: product
6 LOAD_FAST 1: treasure_list
8 GET_ITER
10 FOR_ITER 11 (to 34)
14 STORE_FAST 3: treasure_val
16 LOAD_FAST 2: product
18 LOAD_CONST 2: 8
20 BINARY_OP 3 (<<)
24 LOAD_FAST 3: treasure_val
26 BINARY_OP 12 (^)
30 STORE_FAST 2: product
32 JUMP_BACKWARD 13 (to 8)
36 END_FOR
38 POP_TOP
40 LOAD_GLOBAL 0: random
50 LOAD_ATTR 2: seed
70 PUSH_NULL
72 LOAD_FAST 2: product
74 CALL 1
82 POP_TOP
84 BUILD_LIST 0
86 STORE_FAST 4: decoded
88 LOAD_GLOBAL 5: NULL + range
98 LOAD_CONST 1: 0
100 LOAD_GLOBAL 7: NULL + len
110 LOAD_FAST 0: mountain
112 LOAD_ATTR 8: encoded_flag
132 CALL 1
140 CALL 2
148 GET_ITER
150 FOR_ITER 68 (to 288)
154 STORE_FAST 5: i
156 LOAD_GLOBAL 0: random
166 LOAD_ATTR 10: randint
186 PUSH_NULL
188 LOAD_CONST 1: 0
190 LOAD_CONST 3: 255
192 CALL 2
200 STORE_FAST 6: r
202 LOAD_FAST 4: decoded
204 LOAD_ATTR 13: append
224 LOAD_GLOBAL 15: NULL + chr
234 LOAD_FAST 0: mountain
236 LOAD_ATTR 8: encoded_flag
256 LOAD_FAST 5: i
258 BINARY_SUBSCR
262 LOAD_FAST 6: r
264 BINARY_OP 12 (^)
268 CALL 1
276 CALL 1
284 POP_TOP
286 JUMP_BACKWARD 70 (to 148)
290 END_FOR
292 POP_TOP
294 LOAD_CONST 4: 'Flag: %s'
296 LOAD_CONST 5: ''
298 LOAD_ATTR 17: join
318 LOAD_FAST 4: decoded
320 CALL 1
328 BINARY_OP 6 (%)
332 STORE_FAST 7: flag_text
334 LOAD_GLOBAL 19: NULL + print
344 LOAD_FAST 7: flag_text
346 CALL 1
354 POP_TOP
356 LOAD_GLOBAL 20: flagfont
366 LOAD_ATTR 23: render
386 LOAD_FAST 7: flag_text
388 LOAD_CONST 6: False
390 LOAD_GLOBAL 24: pygame
400 LOAD_ATTR 26: Color
420 PUSH_NULL
422 LOAD_CONST 7: 'saddle brown'
424 CALL 1
432 CALL 3
440 STORE_GLOBAL 14: flag_text_surface
442 RETURN_CONST 0: None
[Exception Table]
Here is a readable Python reconstruction of that function:
def SetFlag(mountain, treasure_list):
product = 0
# Build seed from treasures
for treasure_val in treasure_list:
product = (product << 8) ^ treasure_val
# Seed RNG
import random
random.seed(product)
decoded = []
# Decode the flag
for i in range(len(mountain.encoded_flag)):
random_stream = random.randint(0, 255)
decoded.append(
chr(mountain.encoded_flag[i] ^ random_stream)
)
# Build final string
flag_text = "Flag: %s" % "".join(decoded)
print(flag_text)
# Render to screen (not important for solving)
global flag_text_surface
flag_text_surface = flagfont.render(
flag_text,
False,
pygame.Color("saddle brown")
)
It looks like the flag is determined based on a given list of collected treasures.
The core logic is mountain.encoded_flag XOR random_stream, where random_stream is generated from random.seed(product).
The product value is generated with:
This is basically:
- Shifting left 8 bits (like building a number byte-by-byte).
- XORing each treasure value.
The order and values of the given treasure_list fully determine the flag to solve the game.
The origin of encoded_flag¶
From the Mountain.__init__ disassembly:
[Code]
File Name: FreeSki.py
Object Name: __init__
Qualified Name: Mountain.__init__
Arg Count: 6
[...]
[Names]
'name'
'height'
'treeline'
'yetiline'
'encoded_flag'
'GetTreasureLocations'
'treasures'
[Locals+Names]
'self'
'name'
'height'
'treeline'
'yetiline'
'encoded_flag'
[Disassembly]
0 RESUME 0
2 LOAD_FAST_LOAD_FAST 16: name, self
4 STORE_ATTR 0: name
14 LOAD_FAST_LOAD_FAST 32: height, self
16 STORE_ATTR 1: height
26 LOAD_FAST_LOAD_FAST 48: treeline, self
28 STORE_ATTR 2: treeline
38 LOAD_FAST_LOAD_FAST 64: yetiline, self
40 STORE_ATTR 3: yetiline
50 LOAD_FAST_LOAD_FAST 80: encoded_flag, self
52 STORE_ATTR 4: encoded_flag
62 LOAD_FAST 0: self
64 LOAD_ATTR 11: GetTreasureLocations
84 CALL 0
92 LOAD_FAST 0: self
94 STORE_ATTR 6: treasures
104 RETURN_CONST 0: None
Only treasures is not passed. It is initialized internally from the GetTreasureLocations function.
There are 7 mountains, each with its own encoded_flag.
2456 LOAD_CONST 51: <CODE> Mountain
2458 MAKE_FUNCTION
2460 LOAD_CONST 52: 'Mountain'
2462 CALL 2
2470 STORE_NAME 75: Mountain
2472 LOAD_NAME 75: Mountain
2474 PUSH_NULL
2476 LOAD_CONST 53: 'Mount Snow'
2478 LOAD_CONST 54: 3586
2480 LOAD_CONST 55: 3400
2482 LOAD_CONST 56: 2400
2484 LOAD_CONST 57: b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'
2486 CALL 5
2494 LOAD_NAME 75: Mountain
2496 PUSH_NULL
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'
2508 CALL 5
2516 LOAD_NAME 75: Mountain
2518 PUSH_NULL
2520 LOAD_CONST 63: 'Whistler'
2522 LOAD_CONST 64: 7156
2524 LOAD_CONST 65: 6000
2526 LOAD_CONST 66: 6500
2528 LOAD_CONST 67: b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'
2530 CALL 5
2538 LOAD_NAME 75: Mountain
2540 PUSH_NULL
2542 LOAD_CONST 68: 'Mount Baker'
2544 LOAD_CONST 69: 10781
2546 LOAD_CONST 70: 9000
2548 LOAD_CONST 65: 6000
2550 LOAD_CONST 71: b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'
2552 CALL 5
2560 LOAD_NAME 75: Mountain
2562 PUSH_NULL
2564 LOAD_CONST 72: 'Mount Norquay'
2566 LOAD_CONST 73: 6998
2568 LOAD_CONST 74: 6300
2570 LOAD_CONST 75: 3000
2572 LOAD_CONST 76: b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'
2574 CALL 5
2582 LOAD_NAME 75: Mountain
2584 PUSH_NULL
2586 LOAD_CONST 77: 'Mount Erciyes'
2588 LOAD_CONST 78: 12848
2590 LOAD_CONST 61: 10000
2592 LOAD_CONST 79: 12000
2594 LOAD_CONST 80: b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'
2596 CALL 5
2604 LOAD_NAME 75: Mountain
2606 PUSH_NULL
2608 LOAD_CONST 81: 'Dragonmount'
2610 LOAD_CONST 82: 16282
2612 LOAD_CONST 83: 15500
2614 LOAD_CONST 84: 16000
2616 LOAD_CONST 85: b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5'
2618 CALL 5
2626 BUILD_LIST 7
2628 STORE_GLOBAL 76: Mountains
We can see that the encoded_flag bytes are passed into the Mountain constructor as a bytes constant.
Let's put all the Mountain constructors together:
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\xb31^\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')
]
Here is how each argument is used, based on bytecode access patterns:
| Position | Parameter | Meaning | Usage |
|---|---|---|---|
| 1 | name |
Display name of the mountain | Shows the name of the mountain |
| 2 | height |
Summit height (feet) | Used to determine win condition (ski to bottom) |
| 3 | treeline |
Elevation above which trees stop spawning | Controls tree obstacle spawning |
| 4 | yetiline |
Elevation above which yetis spawn | Controls yeti enemy spawning |
| 5 | encoded_flag |
XOR-obfuscated flag bytes | Only used when all treasures are collected |
Each mountain gives a different encoded flag, but the flag is decoded with the same algorithm regardless. Thus, only one mountain likely produces a valid readable flag, and the others are decoys or wrong paths.
The game is won when all treasures are collected and the SetFlag function is called. Hence, only the winning mountain's encoded_flag value matters.
The content of treasure_list¶
After analyzing the logic in the main() function, we find the section that checks the skier movements and calls the SetFlag function when all treasures are collected:
1734 POP_TOP
1736 LOAD_FAST 5: skier
1738 LOAD_ATTR 93: isMoving
1758 CALL 0
1766 TO_BOOL
1774 POP_JUMP_IF_FALSE 132 (to 2040)
1778 LOAD_FAST 7: obstacles
1780 LOAD_ATTR 95: CollisionDetect
1800 LOAD_FAST 5: skier
1802 CALL 1
1810 STORE_FAST 11: collided_data
1812 LOAD_FAST 11: collided_data
1814 LOAD_CONST 0: None
1816 COMPARE_OP 119 (!=)
1820 POP_JUMP_IF_FALSE 109 (to 2040)
1824 LOAD_FAST 11: collided_data
1826 UNPACK_SEQUENCE 3
1830 STORE_FAST_STORE_FAST 205: collided_object, collided_row
1832 STORE_FAST 14: collided_row_offset
1834 LOAD_FAST 12: collided_object
1836 LOAD_GLOBAL 96: Obstacles
1846 LOAD_ATTR 98: TREASURE
1866 COMPARE_OP 88 (==)
1870 POP_JUMP_IF_FALSE 68 (to 2008)
1874 LOAD_CONST 0: None
1876 LOAD_FAST 13: collided_row
1878 LOAD_CONST 7: 1
1880 BINARY_SUBSCR
1884 LOAD_FAST 14: collided_row_offset
1886 STORE_SUBSCR
1890 LOAD_FAST_CHECK 4: treasures_collected
1892 LOAD_ATTR 101: append
1912 LOAD_FAST 13: collided_row
1914 LOAD_CONST 8: 0
1916 BINARY_SUBSCR
1920 LOAD_GLOBAL 102: mountain_width
1930 BINARY_OP 5 (*)
1934 LOAD_FAST 14: collided_row_offset
1936 BINARY_OP 0 (+)
1940 CALL 1
1948 POP_TOP
1950 LOAD_GLOBAL 105: NULL + len
1960 LOAD_FAST 4: treasures_collected
1962 CALL 1
1970 LOAD_CONST 9: 5
1972 COMPARE_OP 88 (==)
1976 POP_JUMP_IF_FALSE 14 (to 2006)
1980 LOAD_GLOBAL 107: NULL + SetFlag
1990 LOAD_FAST_CHECK 6: mnt
1992 LOAD_FAST 4: treasures_collected
1994 CALL 2
2002 POP_TOP
2004 LOAD_CONST 2: True
2006 STORE_FAST 0: victory_mode
2008 JUMP_FORWARD 16 (to 2042)
2010 LOAD_FAST 5: skier
2012 LOAD_ATTR 109: Crash
2032 CALL 0
This is the reconstructed Python code:
if skier.isMoving():
collided_data = obstacles.CollisionDetect(skier)
if collided_data is not None:
collided_object, collided_row, collided_row_offset = collided_data
if collided_object == Obstacles.TREASURE:
# Remove the treasure from the map
collided_row[1][collided_row_offset] = None
# Convert 2D position → 1D value
treasure_val = (
collided_row[0] * mountain_width
+ collided_row_offset
)
treasures_collected.append(treasure_val)
# Check win condition
if len(treasures_collected) == 5:
SetFlag(mnt, treasures_collected)
victory_mode = True
else:
skier.Crash()
A critical part of this logic is the way the collected treasure is given a unique identifier by linearizing the 2D coordinate into a 1D value:
The treasure_list has the following characteristics:
- The list contains exactly 5 treasures.
- Each entry is a value with linearized coordinates.
- The order of collection matters.
- There are no extra transformations.
Treasure Locations¶
Here is the GetTreasureLocations disassembly:
[Code]
File Name: FreeSki.py
Object Name: GetTreasureLocations
Qualified Name: Mountain.GetTreasureLocations
Arg Count: 1
Pos Only Arg Count: 0
KW Only Arg Count: 0
Stack Size: 8
Flags: 0x00000003 (CO_OPTIMIZED | CO_NEWLOCALS)
[Names]
'random'
'seed'
'binascii'
'crc32'
'name'
'encode'
'height'
'range'
'randint'
'int'
[Locals+Names]
'self'
'locations'
'prev_height'
'prev_horiz'
'i'
'e_delta'
'h_delta'
[Constants]
None
'utf-8'
0
5
200
800
4
[Disassembly]
0 RESUME 0
2 BUILD_MAP 0
4 STORE_FAST 1: locations
6 LOAD_GLOBAL 0: random
16 LOAD_ATTR 2: seed
36 PUSH_NULL
38 LOAD_GLOBAL 4: binascii
48 LOAD_ATTR 6: crc32
68 PUSH_NULL
70 LOAD_FAST 0: self
72 LOAD_ATTR 8: name
92 LOAD_ATTR 11: encode
112 LOAD_CONST 1: 'utf-8'
114 CALL 1
122 CALL 1
130 CALL 1
138 POP_TOP
140 LOAD_FAST 0: self
142 LOAD_ATTR 12: height
162 STORE_FAST 2: prev_height
164 LOAD_CONST 2: 0
166 STORE_FAST 3: prev_horiz
168 LOAD_GLOBAL 15: NULL + range
178 LOAD_CONST 2: 0
180 LOAD_CONST 3: 5
182 CALL 2
190 GET_ITER
192 FOR_ITER 93 (to 380)
196 STORE_FAST 4: i
198 LOAD_GLOBAL 0: random
208 LOAD_ATTR 16: randint
228 PUSH_NULL
230 LOAD_CONST 4: 200
232 LOAD_CONST 5: 800
234 CALL 2
242 STORE_FAST 5: e_delta
244 LOAD_GLOBAL 0: random
254 LOAD_ATTR 16: randint
274 PUSH_NULL
276 LOAD_GLOBAL 19: NULL + int
286 LOAD_CONST 2: 0
288 LOAD_FAST 5: e_delta
290 LOAD_CONST 6: 4
292 BINARY_OP 11 (/)
296 BINARY_OP 10 (-)
300 CALL 1
308 LOAD_GLOBAL 19: NULL + int
318 LOAD_FAST 5: e_delta
320 LOAD_CONST 6: 4
322 BINARY_OP 11 (/)
326 CALL 1
334 CALL 2
342 STORE_FAST 6: h_delta
344 LOAD_FAST_LOAD_FAST 54: prev_horiz, h_delta
346 BINARY_OP 0 (+)
350 LOAD_FAST_LOAD_FAST 18: locations, prev_height
352 LOAD_FAST 5: e_delta
354 BINARY_OP 10 (-)
358 STORE_SUBSCR
362 LOAD_FAST_LOAD_FAST 37: prev_height, e_delta
364 BINARY_OP 10 (-)
368 STORE_FAST 2: prev_height
370 LOAD_FAST_LOAD_FAST 54: prev_horiz, h_delta
372 BINARY_OP 0 (+)
376 STORE_FAST 3: prev_horiz
378 JUMP_BACKWARD 95 (to 190)
382 END_FOR
384 POP_TOP
386 LOAD_FAST 1: locations
388 RETURN_VALUE
[Exception Table]
(
'encoded_flag'
'height'
'name'
'treasures'
'treeline'
'yetiline'
)
None
We can reconstruct this Python code from it:
def GetTreasureLocations(self):
import random, binascii
locations = {}
# Seed RNG using CRC32 of mountain name
random.seed(binascii.crc32(self.name.encode("utf-8")))
prev_height = self.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
The treasure list is NOT random at runtime. For each mountain, all treasure positions are 100% deterministic and their placement depends only on:
- Mountain name.
- Mountain height.
- Python's random value with a fixed seed:
random.seed(crc32(mountain.name)).
No player input, no runtime state, or gameplay randomness is needed to recover them.
From the code, we can infer that:
- Each mountain has exactly 5 treasures.
- Their elevations and horizontal offsets are reproducible.
- You can compute them offline.
Each treasure is placed at (elevation, horizontal_position), where:
elevationis an absolute mountain elevation.horizontal_positionis a signed horizontal offset.
From before, we know that this location is converted into a linear treasure identifier:
The linear treasure_id derived from its location is what feeds into the flag encoding logic.
Alternate Source Code Recovery¶
With the help from an AI-powered tool with the DeepSeek model, I was able to convert the FreeSki.pyasm file into a FreeSki-deepseek.py Python script.
With this file, we can confirm that the bytecode analysis above is accurate.
Solution¶
Here is a summary of the reverse engineering findings:
-
Each mountain contains an
encoded_flag(byte string). -
The flag is decoded by XORing each byte with a PRNG stream:
-
The PRNG is seeded with a value (
product) derived from collected treasures: -
Treasure values are NOT random at runtime. They are deterministically generated:
- RNG seeded with CRC32(
mountain_name) - 5 treasure positions generated via pseudo-random deltas
- RNG seeded with CRC32(
-
Each treasure is represented as a 2D coordinate:
-
The game converts this into a 1D integer:
-
The ordered list of these 5 treasure IDs forms the key used to decode the flag.
The get_flag.py Python script reconstructs the flag generation logic from the FreeSki challenge and replicates the entire pipeline for all seven (7) mountains identified:
Here is the output:
============================================================
Mount Snow (Height: 3586)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 2966
Horizontal: 113
ID: 2966113
Treasure 2:
Elevation: 2420
Horizontal: 85
ID: 2420085
Treasure 3:
Elevation: 1718
Horizontal: 188
ID: 1718188
Treasure 4:
Elevation: 1094
Horizontal: 142
ID: 1094142
Treasure 5:
Elevation: 466
Horizontal: 85
ID: 466085
Decoded Flag:
frosty_yet_predictably_random
============================================================
Aspen (Height: 11211)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 10865
Horizontal: -43
ID: 10864957
Treasure 2:
Elevation: 10529
Horizontal: -122
ID: 10528878
Treasure 3:
Elevation: 9903
Horizontal: -102
ID: 9902898
Treasure 4:
Elevation: 9183
Horizontal: -61
ID: 9182939
Treasure 5:
Elevation: 8621
Horizontal: -15
ID: 8620985
Decoded Flag:
jÆÀ0î'Zsv4&Ùo9`\ ÿg´
============================================================
Whistler (Height: 7156)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 6373
Horizontal: -141
ID: 6372859
Treasure 2:
Elevation: 6127
Horizontal: -150
ID: 6126850
Treasure 3:
Elevation: 5897
Horizontal: -119
ID: 5896881
Treasure 4:
Elevation: 5610
Horizontal: -145
ID: 5609855
Treasure 5:
Elevation: 5124
Horizontal: -184
ID: 5123816
Decoded Flag:
HáOA
ÎblÚV<lÝOÒ¸.ÿp±
============================================================
Mount Baker (Height: 10781)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 9997
Horizontal: -31
ID: 9996969
Treasure 2:
Elevation: 9525
Horizontal: -69
ID: 9524931
Treasure 3:
Elevation: 9112
Horizontal: -3
ID: 9111997
Treasure 4:
Elevation: 8523
Horizontal: 106
ID: 8523106
Treasure 5:
Elevation: 7856
Horizontal: -45
ID: 7855955
Decoded Flag:
çj=#ú%m²xæÙC3¸¬ÜÇ
============================================================
Mount Norquay (Height: 6998)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 6642
Horizontal: -67
ID: 6641933
Treasure 2:
Elevation: 5901
Horizontal: -13
ID: 5900987
Treasure 3:
Elevation: 5692
Horizontal: -8
ID: 5691992
Treasure 4:
Elevation: 5486
Horizontal: -57
ID: 5485943
Treasure 5:
Elevation: 5115
Horizontal: -146
ID: 5114854
Decoded Flag:
ðJ ©yͯZSë6éÃò&3;«°Ä§Öµ
============================================================
Mount Erciyes (Height: 12848)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 12235
Horizontal: 10
ID: 12235010
Treasure 2:
Elevation: 11950
Horizontal: -38
ID: 11949962
Treasure 3:
Elevation: 11660
Horizontal: -22
ID: 11659978
Treasure 4:
Elevation: 11412
Horizontal: -16
ID: 11411984
Treasure 5:
Elevation: 10701
Horizontal: -47
ID: 10700953
Decoded Flag:
§-ë/# îÒl_rªß@*¶ðJùæÖ¸¿ø
============================================================
Dragonmount (Height: 16282)
Treasure locations:
------------------------------------------------------------
Treasure 1:
Elevation: 15590
Horizontal: -111
ID: 15589889
Treasure 2:
Elevation: 14939
Horizontal: -184
ID: 14938816
Treasure 3:
Elevation: 14634
Horizontal: -193
ID: 14633807
Treasure 4:
Elevation: 14339
Horizontal: -247
ID: 14338753
Treasure 5:
Elevation: 13706
Horizontal: -280
ID: 13705720
Decoded Flag:
çnf¿üãß8¦âù].ûÐ ¿Ù]éb
Answer¶
The flag is:
Found on Mount Snow, which is the only mountain whose decoded output is readable ASCII.
Outro¶
Olivia
Looks like you found your own way down that mountain... and maybe took a few shortcuts along the way. No judgment here—sometimes the clever path IS the right path. Now I'm one step closer to figuring out my own mystery. Thanks for the company, friend!
Files¶
| File | Description |
|---|---|
FreeSki.exe |
Python application binary to reverse engineer |
pyinstxtractor.py |
Python script to extract the contents of a PyInstaller generated executable file |
FreeSki.pyasm |
Bytecode disassembly extracted from the application binary |
FreeSki-deepseek.py |
Python code reverse engineered from the bytecode using an AI-powered tool |
get_flag.py |
Python script that replicates the flag decoding game logic to recover the value needed to complete the challenge |
References¶
ctf-techniques/reverse/— PyInstaller unpacking, bytecode disassembly with pycdc/pycdas, AI-assisted source reconstructionctf-techniques/crypto/— XOR decryption with PRNG-seeded keystream
Navigation¶
| ← On the Wire |