Category: Binary Exploitation
Flag: CodeVinciCTF{Wh3n_tH3_g4m3_Cheats_y0u_sHOUlD_D0_tH3_S4m3!_=)}
Challenge Description
just play it… or at least I think so
Analysis
At first glance this looked like a normal Linux pwn binary, but the executable was a stripped Godot export, which immediately changed the strategy from classic stack corruption into asset/script extraction.
file CTF.x86_64
checksec --file=CTF.x86_64CTF.x86_64: ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, stripped
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: EnabledThat output explained why normal ret2win triage was noisy: this was a game runtime container and the real challenge logic lived in CTF.pck. I parsed the pack metadata next to confirm version/layout fields and get the extraction parameters.
# inspect_pck.py
import importlib.util
p = 'pck_list_extract.py'
spec = importlib.util.spec_from_file_location('pck', p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
info = mod.parse_pck('CTF.pck')
print('pack_format', info['pack_format'])
print('version', '.'.join(map(str, info['version'])))
print('file_base', hex(info['file_base']))
print('dir_offset', hex(info['dir_offset']))
print('file_count', info['file_count'])python inspect_pck.pypack_format 3
version 4.6.0
file_base 0x70
dir_offset 0x75c60
file_count 106I got trolled for a while because extraction looked valid but produced garbage in places; the key detail was that entry offsets must be shifted by file_base (0x70). I verified this by comparing stored MD5 against extracted bytes with and without the base offset.

# validate_pck_offsets.py
import hashlib
import importlib.util
p = 'pck_list_extract.py'
spec = importlib.util.spec_from_file_location('pck', p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
info = mod.parse_pck('CTF.pck')
data = info['data']
file_base = info['file_base']
path, offset, size, stored_md5, _flags = info['entries'][0]
direct_md5 = hashlib.md5(data[offset:offset + size]).hexdigest()
plus_base_md5 = hashlib.md5(data[offset + file_base:offset + file_base + size]).hexdigest()
print('entry0', path)
print('stored_md5', stored_md5)
print('direct_md5', direct_md5)
print('plus_base_md5', plus_base_md5)
print('direct_match', direct_md5 == stored_md5)
print('plus_base_match', plus_base_md5 == stored_md5)python validate_pck_offsets.pyentry0 res://bullet.gd
stored_md5 7d1e5839a813907b95bb4fa704ccdcc1
direct_md5 f43f3428ca9f6e2585a5d42c041fd823
plus_base_md5 7d1e5839a813907b95bb4fa704ccdcc1
direct_match False
plus_base_match TrueOnce extraction was corrected, the .gdc scripts still were not directly readable. Looking at ShopUI.gdc showed an embedded Zstandard stream, which was the turning point.
# inspect_shopui_magic.py
import pathlib
data = pathlib.Path('pck_all_fixed/shop/ShopUI.gdc').read_bytes()
position = data.find(b'\x28\xb5\x2f\xfd')
print('zstd_magic_offset', position)
print('header_hex', data[:16].hex())python inspect_shopui_magic.pyzstd_magic_offset 12
header_hex 4744534365000000bc36000028b52ffdAfter decompressing that stream, I extracted the encoded blob from the Flag : ... string in ShopUI, base64-decoded it, then applied ROT47. That yielded the final flag-like string directly.

# solve.py
import base64
import pathlib
import re
import zstandard as zstd
def rot47(text: str) -> str:
out = []
for ch in text:
c = ord(ch)
if 33 <= c <= 126:
out.append(chr(33 + ((c - 33 + 47) % 94)))
else:
out.append(ch)
return ''.join(out)
data = pathlib.Path('pck_all_fixed/shop/ShopUI.gdc').read_bytes()
zstd_magic = b"\x28\xb5\x2f\xfd"
pos = data.find(zstd_magic)
decompressed = zstd.ZstdDecompressor().decompress(data[pos:])
match = re.search(rb"Flag : ([A-Za-z0-9+/=]+)", decompressed)
encoded = match.group(1).decode()
decoded = base64.b64decode(encoded).decode("latin1")
flag = rot47(decoded)
print(flag)python solve.pyCodeVinciCTF{Wh3n_tH3_g4m3_Cheats_y0u_sHOUlD_D0_tH3_S4m3!_=)}Solution
import base64
import pathlib
import re
import zstandard as zstd
def rot47(text: str) -> str:
out = []
for ch in text:
c = ord(ch)
if 33 <= c <= 126:
out.append(chr(33 + ((c - 33 + 47) % 94)))
else:
out.append(ch)
return ''.join(out)
data = pathlib.Path('pck_all_fixed/shop/ShopUI.gdc').read_bytes()
pos = data.find(b"\x28\xb5\x2f\xfd")
decompressed = zstd.ZstdDecompressor().decompress(data[pos:])
enc = re.search(rb"Flag : ([A-Za-z0-9+/=]+)", decompressed).group(1).decode()
raw = base64.b64decode(enc).decode("latin1")
print(rot47(raw))python solve.pyCodeVinciCTF{Wh3n_tH3_g4m3_Cheats_y0u_sHOUlD_D0_tH3_S4m3!_=)}