1348 words
7 minutes
UniVsThreats 26 Quals CTF - Starfield Relay - Reverse Engineering Writeup

Category: Reverse Engineering
Flag: UVT{Kr4cK_M3_N0w-cR4Km3_THEN-5T4rf13Ld_piNgS_uR_pR0b3Z_xTND-I_h1D3_in_l0Gz_1n_v01D_iN_ZEN}

Challenge Description#

A recovered spacecraft utility binary is believed to validate a multi-part unlock phrase. The executable runs a staged validation flow and eventually unlocks additional artifacts for deeper analysis. Your goal is to reverse the binary, recover each stage fragment and reconstruct the final flag.

Analysis#

I treated this as a staged validator with likely decoys, so I started with cheap static triage instead of going straight into heavy debugging. The file type confirmed a 64-bit Windows console PE, and the first string pass already showed the challenge structure: base prefix check, two token checks, VM execution, then stage2 artifact extraction (pings, logs, void).

file "crackme.exe"
crackme.exe: PE32+ executable (console) x86-64, for MS Windows
strings -a "crackme.exe" | rg -i "UVT\{|enter base prefix|enter stage2 token|enter token \(8 chars\)|starfield_pings|system.log|zen_void.bin"
UVT{
enter base prefix (4 chars):
enter stage2 token (8 chars):
enter token (8 chars):
stage5: next: decode starfield_pings/pings.txt (filter ttl=1337)
stage5: next: inspect logs/system.log for shuffled zen-tagged fragments
stage5: next: inspect void/zen_void.bin (islands inside zero-runs)

From there I mapped stage handlers in main and pulled each primitive. Stage 0/1 were intentionally simple: UVT{ and a 3-byte generator function that writes 0x4b 0x72 0x34.

r2 -q -e scr.color=false -A -c "s 0x140115aa0; pdf" "crackme.exe"
...
mov byte [rcx], 0x4b
mov byte [rcx+1], 0x72
mov byte [rcx+2], 0x34
...

Then I inverted stage2/stage3 byte equations to recover the two 8-byte tokens, and validated both directly.

python3.12 -c "
t2 = bytes.fromhex('31 24 dc fa 25 2c e4 c5')
s2 = bytes((((b - 0x13 - i*7) & 0xff) ^ ((i*0x11 + 0x6d) & 0xff)) for i, b in enumerate(t2))

t3 = [0xd7,0xd1,0xa7,0xed,0x54,0x39,0x68,0x49]
s3 = bytes((((b - 3*i) & 0xff) ^ ((0xa7 - 0xb*i) & 0xff)) for i, b in enumerate(t3))

print('stage2_token', s2.decode())
print('stage3_token', s3.decode())
"
stage2_token st4rG4te
stage3_token pR0b3Z3n

Those tokens are not final fragments; they drive crypto checks. Stage2 decrypts to cK_M3_ (PBKDF2-SHA256 + ChaCha20), and stage3 decrypts to N0w-cR4Km3_ (PBKDF2-SHA256 + AES-GCM).

python3.12 -c "
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Hash import SHA256
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms

km = PBKDF2(b'st4rG4te', b'uvt::s2::pbkdf2::v2', dkLen=48, count=60000, hmac_hash_module=SHA256)
pt = Cipher(algorithms.ChaCha20(km[:32], km[32:48]), mode=None).decryptor().update(bytes.fromhex('cc056fdab9be'))
print(pt.decode())
"
cK_M3_
python3.12 -c "
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Hash import SHA256
from Crypto.Cipher import AES

km = PBKDF2(b'pR0b3Z3n', b'uvt::s3::pbkdf2::v4', dkLen=44, count=90000, hmac_hash_module=SHA256)
c = AES.new(km[:32], AES.MODE_GCM, nonce=km[32:44], mac_len=16)
c.update(b'uvt::stage3::aad::v4')
pt = c.decrypt_and_verify(bytes.fromhex('8f998d30eb808c858b8f01'), bytes.fromhex('e0c31b0565d6a3eb07d57cb916b592c4'))
print(pt.decode())
"
N0w-cR4Km3_

The VM stage produced THEN- after bytecode decode/emulation, and that gave enough material to pass the stage5 checksum gate and build the stage5 fragment 5T4rf13Ld_piNgS_.

smug

Stage5 was the annoying pivot: decryption kept failing until I corrected the AAD literal to the exact double-colon form from the binary (uvt::stage2blob::aad::v4|id=101). With that fixed, the embedded blob decrypted to a ZIP containing the real stage6–9 evidence files.

facepalm

python3.12 -c "
from pathlib import Path
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
import io, zipfile

blob = Path('res_type10_id101_lang1033.bin').read_bytes()
nonce, tag, ct = blob[9:21], blob[21:37], blob[37:]

prefix = b'UVT{Kr4cK_M3_N0w-cR4Km3_THEN-'
u = sum(prefix[-5:]) & 0xff
const = bytes([0x69,0x08,0x68,0x2e,0x3a,0x6d,0x6f,0x10,0x38,0x03,0x2c,0x35,0x12,0x3b,0x0f,0x03])
stage5 = bytes([u ^ c for c in const])

key = PBKDF2(prefix + stage5, b'uvt::stage2blob::v4', dkLen=32, count=120000, hmac_hash_module=SHA256)
c = AES.new(key, AES.MODE_GCM, nonce=nonce, mac_len=16)
c.update(b'uvt::stage2blob::aad::v4|id=101')
pt = c.decrypt_and_verify(ct, tag)

z = zipfile.ZipFile(io.BytesIO(pt))
print(*z.namelist(), sep='\n')
"
logs/README_LOGS.txt
logs/system.log
logs/telemetry.log
probe_extender/README.txt
probe_extender/probe_extender.py
starfield_pings/pings.txt
void/zen_void.bin
void/zen_void_readme.txt
web/app.js
web/index.html
web/style.css

Stage6 came from ttl=1337 rows in pings.txt with the parity-split 5-bit mapping. My first interpretation produced a clue-like decoy; the hash-matching decode was uR_pR0b3Z_xTND-.

python3.12 -c "
import re
from pathlib import Path

txt = Path('stage2_extracted/starfield_pings/pings.txt').read_text()
vals = [int(x)-64 for x in re.findall(r'time=(\\d+)ms ttl=1337', txt)]

even = bytes([b ^ 0x52 for b in bytes.fromhex('270d62612a1c7f3036343a383e3c2220')])
odd  = bytes([b ^ 0x13 for b in bytes.fromhex('60627c7e787a74767072574749716341')[::-1]])

alpha = bytearray(32)
for i in range(16):
    alpha[2*i] = even[i]
    alpha[2*i+1] = odd[i]

print(bytes(alpha[v] for v in vals).decode())
"
uR_pR0b3Z_xTND-

Stage7 came from system.log: keep zen entries, sort by slot, XOR fragx by k, then base64-decode to get I_h1D3_in_l0Gz_.

python3.12 -c "
import json, base64
from pathlib import Path

rows = []
for line in Path('stage2_extracted/logs/system.log').read_text().splitlines():
    if line.startswith('{'):
        o = json.loads(line)
        if o.get('subsys') == 'zen' and 'fragx' in o:
            rows.append((o['slot'], int(o['k'], 16), bytes.fromhex(o['fragx'])))
rows.sort()

b = b''.join(bytes([x ^ k for x in frag]) for _, k, frag in rows)
print(base64.b64decode(b).decode())
"
I_h1D3_in_l0Gz_

Stage8/9 came from non-zero islands in zen_void.bin: valid-range island XOR 0x2a gave 1n_v01D_, then sum(stage8) % 256 decoded the 7-byte island to iN_ZEN}.

python3.12 -c "
from pathlib import Path

b = Path('stage2_extracted/void/zen_void.bin').read_bytes()

islands = []
i = 0
while i < len(b):
    if b[i] == 0:
        i += 1
        continue
    j = i
    while j < len(b) and b[j] != 0:
        j += 1
    islands.append((i, b[i:j]))
    i = j

s8 = None
for off, d in islands:
    if 0x9000 <= off < 0xF000 and len(d) == 8:
        cand = bytes(x ^ 0x2a for x in d)
        if cand == b'1n_v01D_':
            s8 = cand
            break

k9 = sum(s8) % 256
s9 = None
for off, d in islands:
    if len(d) == 7:
        cand = bytes(x ^ k9 for x in d)
        if cand == b'iN_ZEN}':
            s9 = cand
            break

print(s8.decode())
print(s9.decode())
"
1n_v01D_
iN_ZEN}

With all ten fragments rebuilt and reassembled, the final flag string was:

dance

python3.12 -c "
flag = (
    'UVT{'
    'Kr4'
    'cK_M3_'
    'N0w-cR4Km3_'
    'THEN-'
    '5T4rf13Ld_piNgS_'
    'uR_pR0b3Z_xTND-'
    'I_h1D3_in_l0Gz_'
    '1n_v01D_'
    'iN_ZEN}'
)
print(flag)
"
UVT{Kr4cK_M3_N0w-cR4Km3_THEN-5T4rf13Ld_piNgS_uR_pR0b3Z_xTND-I_h1D3_in_l0Gz_1n_v01D_iN_ZEN}

Solution#

# solve.py
#!/usr/bin/env python3.12

import base64
import io
import json
import re
import zipfile
from pathlib import Path

import pefile
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Protocol.KDF import PBKDF2
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms


def extract_blob_101(pe_path: Path) -> bytes:
    pe = pefile.PE(str(pe_path))
    for t in pe.DIRECTORY_ENTRY_RESOURCE.entries:
        if t.struct.Id != 10:
            continue
        for e in t.directory.entries:
            if e.struct.Id != 101:
                continue
            for lang in e.directory.entries:
                d = lang.data.struct
                data = pe.get_memory_mapped_image()[d.OffsetToData : d.OffsetToData + d.Size]
                if data.startswith(b"UVTBLOB4"):
                    return data
    raise RuntimeError("resource 10/101 not found")


def stage2_fragment() -> bytes:
    km = PBKDF2(b"st4rG4te", b"uvt::s2::pbkdf2::v2", dkLen=48, count=60000, hmac_hash_module=SHA256)
    return Cipher(algorithms.ChaCha20(km[:32], km[32:48]), mode=None).decryptor().update(bytes.fromhex("cc056fdab9be"))


def stage3_fragment() -> bytes:
    km = PBKDF2(b"pR0b3Z3n", b"uvt::s3::pbkdf2::v4", dkLen=44, count=90000, hmac_hash_module=SHA256)
    c = AES.new(km[:32], AES.MODE_GCM, nonce=km[32:44], mac_len=16)
    c.update(b"uvt::stage3::aad::v4")
    return c.decrypt_and_verify(
        bytes.fromhex("8f998d30eb808c858b8f01"),
        bytes.fromhex("e0c31b0565d6a3eb07d57cb916b592c4"),
    )


def stage5_fragment(prefix_to_stage4: bytes) -> bytes:
    u = sum(prefix_to_stage4[-5:]) & 0xFF
    const = bytes([0x69, 0x08, 0x68, 0x2E, 0x3A, 0x6D, 0x6F, 0x10, 0x38, 0x03, 0x2C, 0x35, 0x12, 0x3B, 0x0F, 0x03])
    return bytes([u ^ c for c in const])


def decode_stage6(pings_text: str) -> bytes:
    vals = [int(x) - 64 for x in re.findall(r"time=(\d+)ms ttl=1337", pings_text)]
    even = bytes([b ^ 0x52 for b in bytes.fromhex("270d62612a1c7f3036343a383e3c2220")])
    odd = bytes([b ^ 0x13 for b in bytes.fromhex("60627c7e787a74767072574749716341")[::-1]])
    alpha = bytearray(32)
    for i in range(16):
        alpha[2 * i] = even[i]
        alpha[2 * i + 1] = odd[i]
    return bytes(alpha[v] for v in vals)


def decode_stage7(system_log_text: str) -> bytes:
    rows = []
    for line in system_log_text.splitlines():
        if not line.startswith("{"):
            continue
        obj = json.loads(line)
        if obj.get("subsys") == "zen" and "fragx" in obj:
            rows.append((obj["slot"], int(obj["k"], 16), bytes.fromhex(obj["fragx"])))
    rows.sort()
    blob = b"".join(bytes([x ^ k for x in frag]) for _, k, frag in rows)
    return base64.b64decode(blob)


def decode_stage8_stage9(void_data: bytes) -> tuple[bytes, bytes]:
    islands = []
    i = 0
    while i < len(void_data):
        if void_data[i] == 0:
            i += 1
            continue
        j = i
        while j < len(void_data) and void_data[j] != 0:
            j += 1
        islands.append((i, void_data[i:j]))
        i = j

    stage8 = None
    for off, d in islands:
        if 0x9000 <= off < 0xF000 and len(d) == 8:
            cand = bytes(x ^ 0x2A for x in d)
            if cand == b"1n_v01D_":
                stage8 = cand
                break
    if stage8 is None:
        raise RuntimeError("stage8 not found")

    k9 = sum(stage8) % 256
    stage9 = None
    for _, d in islands:
        if len(d) == 7:
            cand = bytes(x ^ k9 for x in d)
            if cand == b"iN_ZEN}":
                stage9 = cand
                break
    if stage9 is None:
        raise RuntimeError("stage9 not found")

    return stage8, stage9


def main() -> None:
    s0 = b"UVT{"
    s1 = b"Kr4"
    s2 = stage2_fragment()
    s3 = stage3_fragment()
    s4 = b"THEN-"
    s5 = stage5_fragment(s0 + s1 + s2 + s3 + s4)

    blob = extract_blob_101(Path("crackme.exe"))
    nonce, tag, ct = blob[9:21], blob[21:37], blob[37:]

    key = PBKDF2(s0 + s1 + s2 + s3 + s4 + s5, b"uvt::stage2blob::v4", dkLen=32, count=120000, hmac_hash_module=SHA256)
    c = AES.new(key, AES.MODE_GCM, nonce=nonce, mac_len=16)
    c.update(b"uvt::stage2blob::aad::v4|id=101")
    pt_zip = c.decrypt_and_verify(ct, tag)

    z = zipfile.ZipFile(io.BytesIO(pt_zip))
    s6 = decode_stage6(z.read("starfield_pings/pings.txt").decode())
    s7 = decode_stage7(z.read("logs/system.log").decode())
    s8, s9 = decode_stage8_stage9(z.read("void/zen_void.bin"))

    flag = (s0 + s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8 + s9).decode()
    print(flag)


if __name__ == "__main__":
    main()
python3.12 solve.py
UVT{Kr4cK_M3_N0w-cR4Km3_THEN-5T4rf13Ld_piNgS_uR_pR0b3Z_xTND-I_h1D3_in_l0Gz_1n_v01D_iN_ZEN}
UniVsThreats 26 Quals CTF - Starfield Relay - Reverse Engineering Writeup
https://blog.rei.my.id/posts/58/univsthreats-26-quals-ctf-starfield-relay-reverse-engineering-writeup/
Author
Reidho Satria
Published at
2026-02-27
License
CC BY-NC-SA 4.0