Neven Marić
Hidden Investigations CTF 2026

ReversingHidden Investigations CTF 2026

Snapfile

snaptool is a stripped Linux x86-64 Crystal binary that reads a .snap file, validates it, and on success prints its embedded label. Reverse-engineer the 104-byte format spec, forge a valid file, and the binary prints the flag.

  • reverse-engineering
  • binary-analysis
  • file-format
  • crystal
  • wyhash
  • gdb
  • sha256
  • xor-keystream

What this one teaches

  • Locating format-validation logic in a stripped binary by tracing back from the only error string.
  • Recognising a wyhash-style checksum loop and reimplementing it to forge valid inputs.
  • Verifying that section contents don't affect the flag output (constant XOR keystream), so zero-filled sections suffice.

Task description

A small research-tooling team got tired of shelling out to tar/zip for their dataset snapshots, so they rolled their own compact binary container format, the snapfile (.snap), and a Crystal command-line tool, snaptool, that validates and unpacks them.

The format was never documented outside the team, and the only thing that leaked is the tool itself: a single stripped release binary. No source, no spec, no sample .snap file. If you can hand it a .snap it considers genuine, it will unpack the snapshot’s embedded label, which is the flag.

Run it with ./snaptool somefile.snap. It prints the unpacked label on success and invalid snapfile on any failure. There are no other options and no diagnostics. Linux x86-64 ELF, no runtime dependencies beyond a normal glibc system.

Approach

Find the parser

The only diagnostic string is invalid snapfile (string object at 0x461f70), lea’d at 0x436711 into a no-return “print error + exit” helper (0x436710) called from the real failure site 0x4035e4 (and an exception landing pad 0x403bc2). To locate the format logic directly, I ran the binary in gdb (Docker, ubuntu 24.04 — the handout needs glibc 2.38+), broke on the file read, and scanned the stack for the return address into the top-level function: 0x402c02. The validation runs from there.

Recover the header format

Starting at 0x402c02, the total file must be exactly 104 bytes:

  • [0:4] = "SNAP"
  • [4:6] = BE16 version == 2
  • [6] == 0x03, [7] == 0x00
  • [8:16] = "SNAP-LNG" (checked as a BE64 == 0x534e41502d4c4e47)
  • A big-endian u32 section table (reader at 0x438800): [16:20]=40, [20:24]=16, [24:28]=56, [28:32]=32, [32:36]=88, [36:40]=8. These are (offset, length) for sections A [40:56], B [56:88], C [88:96]; 88+8=96 is checked. The three section bodies’ contents are not validated.

Reverse the checksum

Bytes [96:104] are a BE64 checksum over file[8:96] (88 bytes) using a wyhash-style loop:

h = 0x1357bdf02468ace1
for b in file[8:96]:
    s = (b + h) & 2**64-1
    s = (s * 0xd1b54a32d192ed03) & 2**64-1
    h = (s >> 29) ^ s
require  BE64(file[96:104]) ^ h == 88
=> file[96:104] = BE64(h ^ 88)

Understand the label logic

The tool computes SHA256(file[0:40]) (helper 0x438890 = OpenSSL EVP SHA-256 — libcrypto is linked but no HMAC is used) and uses it as a keystream to build a constant 46-byte buffer (section-A content ends up ignored). Then decrypted = built XOR KEY, where KEY is a 46-byte constant assembled into a global at 0x401ee9. A gate requires SHA256(decrypted) == EXPECTED, a 32-byte digest baked in at 0x401f2b. If it matches, the 46-byte decrypted buffer is printed — this is the flag, embedded in the binary.

Forge and run

Since section A/B/C contents aren’t checked, I built a structurally valid 104-byte file with zero-filled sections and the correct BE64 wyhash checksum:

import struct

def wyh(data):
    h = 0x1357bdf02468ace1
    M = 0xd1b54a32d192ed03
    mask = (1 << 64) - 1
    for b in data:
        s = (b + h) & mask
        s = (s * M) & mask
        h = ((s >> 29) ^ s) & mask
    return h

def build():
    hdr = b"SNAP"
    hdr += struct.pack(">H", 2)       # version BE16 = 2
    hdr += bytes([3, 0])
    hdr += b"SNAP-LNG"
    hdr += struct.pack(">IIIIII", 40, 16, 56, 32, 88, 8)
    assert len(hdr) == 40

    body = hdr + bytes(16) + bytes(32) + bytes(8)  # zero-filled sections
    assert len(body) == 96

    h = wyh(body[8:96])
    body += struct.pack(">Q", h ^ 88)
    assert len(body) == 104
    return body

with open("valid.snap", "wb") as f:
    f.write(build())

Running the binary on the forged file prints the flag:

$ ./snaptool valid.snap
hiCTF{r3c0nstruct3d_th3_sn4pf1l3_by73_by_by73}   (exit 0)

In gdb, the decrypted buffer was identical for different section-A values, confirming the flag is constant regardless of section content.

Lessons

  • A stripped Crystal binary still links OpenSSL and has recognizable EVP call patterns — useful landmarks when string-based xrefs are sparse.
  • When the checksum is over a known region and the sections themselves are unchecked, forging a valid file is trivial: fill with zeros and compute the expected digest.
  • The flag being a constant XOR of two baked-in buffers means it’s extractable statically, but running the binary on a forged input is faster and confirms correctness.
The flagRevealhiCTF{r3c0nstruct3d_th3_sn4pf1l3_by73_by_by73}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.