471 words
2 minutes
CodeVinci CTF 2026 - HisFirstGame - Binary Exploitation Writeup

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_64
CTF.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:    Enabled

That 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.py
pack_format 3
version 4.6.0
file_base 0x70
dir_offset 0x75c60
file_count 106

I 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.

cry

# 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.py
entry0 res://bullet.gd
stored_md5 7d1e5839a813907b95bb4fa704ccdcc1
direct_md5 f43f3428ca9f6e2585a5d42c041fd823
plus_base_md5 7d1e5839a813907b95bb4fa704ccdcc1
direct_match False
plus_base_match True

Once 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.py
zstd_magic_offset 12
header_hex 4744534365000000bc36000028b52ffd

After 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.

smug

# 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.py
CodeVinciCTF{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.py
CodeVinciCTF{Wh3n_tH3_g4m3_Cheats_y0u_sHOUlD_D0_tH3_S4m3!_=)}
CodeVinci CTF 2026 - HisFirstGame - Binary Exploitation Writeup
https://blog.rei.my.id/posts/87/codevinci-ctf-2026-hisfirstgame-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0