Gelatino

Points: not listed in the preserved challenge files · Provided: Gelatino.img

The original challenge prompt was not preserved beside the attachment. What we have is a 150 MiB raw disk image named Gelatino.img.

Hint 1

The 0xee partition in the legacy MBR is not the real filesystem. It is a protective marker telling us to inspect the GPT partition table.

Hint 2

The EFI System Partition contains both a normal-looking GRUBX64.EFI and a much smaller, newer BOOTX64.EFI. Search the smaller loader for UTF-16LE strings, then follow the reference to “startup reel.”

Hint 3

Reverse the function that validates jukebox/memdisk.pack. Undo its index permutation, rotate each byte right by index mod 8, XOR it with the index-derived byte, and verify your result with both the stored CRC32 and the 070701 cpio magic.

Hint 4

The recovered order history names the owner's favorite flavor. Its one-based line number in the sorted flavor list is the “ticket number,” producing a passphrase of the form flavor-number.

Artifact map

There is only one attachment, but it contains several layers. Mapping them first keeps us from confusing an early decoy with the real solve path.

Artifact / component What it contains Why it matters
Gelatino.img 150 MiB raw GPT disk image Starting point; SHA-256 0f302380f09b312efd4bca75a6d025b677811993c9c9a1b2274c7b40b0f10233
Partition 1, gelato-data 100 MiB LUKS2 container The locked vault holding the final encrypted recipe
Partition 2, EFI 28 MiB FAT16 EFI System Partition Contains the boot loaders and the jukebox artifacts
EFI/BOOT/BOOTX64.EFI 53,248-byte custom UEFI application Implements the real memdisk.pack decoder
EFI/BOOT/GRUBX64.EFI 5,668,864-byte GRUB loader Baseline showing that BOOTX64.EFI is the unusual replacement
jukebox/vault.b64 Visible junk plus zero-width Unicode Deliberate fake-flag path
jukebox/memdisk.pack GLT1 header plus 2,048 encoded bytes Decodes into a cpio archive containing the password clues

The solve path moves from GPT triage through the EFI loader and custom pack, then into the LUKS vault and final OpenSSL layer.

What it targets

Gelatino tests whether we can move confidently between disk forensics, firmware reversing, a custom binary format, and two standard encryption containers. No single step is enormous. The difficulty comes from recognizing which tool belongs to each layer and demanding an independent correctness check before moving on.

Skill Why it matters outside the CTF
Reading GPT partition tables Raw images often contain several filesystems or encrypted volumes, and the protective MBR can mislead legacy tooling
Recognizing UEFI boot paths and PE/COFF binaries Firmware incident response often starts with changed boot applications on the EFI System Partition
Following string cross-references in a disassembler A fast way to reach custom behavior inside a large binary
Reimplementing byte-level transforms Common in malware configuration decoders, proprietary formats, and embedded firmware
Using magic values and checksums as proof Prevents a plausible but subtly wrong decode from poisoning every later step
Separating encryption layers from password clues Strong encryption is not “cracked” here; the intended passphrase is recovered from surrounding evidence

The core lesson is layer discipline: identify the current format, use the evidence in that layer, and verify the transition to the next one.


Background: the four formats we meet

Before touching the image, it helps to know the small signature that identifies each layer.

Format Recognition clue What it tells us
GPT disk Protective MBR partition type 0xee, followed by GPT metadata Use sfdisk, sgdisk, or another GPT-aware parser
UEFI application PE/COFF executable in EFI/BOOT; strings often use UTF-16LE Search both narrow and wide strings
cpio “newc” archive ASCII magic 070701 The custom decoder has produced a standard archive
OpenSSL enc container First eight bytes are Salted__ The next eight bytes are salt; cipher and KDF are not recorded in the file

A UEFI machine checks a conventional fallback path, EFI/BOOT/BOOTX64.EFI, on x86-64 removable media. That makes this file especially interesting when a disk contains both it and another large boot loader.

LUKS is different from the final OpenSSL file. LUKS stores enough metadata to identify its cipher and key-derivation settings, while the old OpenSSL Salted__ envelope stores neither the cipher name nor whether legacy or PBKDF2 derivation was used. We therefore inspect LUKS metadata directly, but try a small, evidence-based set of OpenSSL options at the final layer.


Walkthrough

1. Fingerprint the image and read the real partition table

We start with cheap, non-destructive checks: identify the file, hash it, and list its partitions. file sees a DOS/MBR boot sector because every GPT disk begins with a compatibility MBR. The important clue is partition type 0xee, the GPT protective marker.

$ file Gelatino.img
Gelatino.img: DOS/MBR boot sector; partition 1 : ID=0xee, ...

$ sha256sum Gelatino.img
0f302380f09b312efd4bca75a6d025b677811993c9c9a1b2274c7b40b0f10233  Gelatino.img

Now use GPT-aware tools:

$ sfdisk -l Gelatino.img
Disk Gelatino.img: 150 MiB, 157286400 bytes, 307200 sectors
Disklabel type: gpt
Disk identifier: 6FDFF7F5-50E7-4C18-9529-D1D4F2611161

Device         Start    End Sectors  Size Type
Gelatino.img1   2048 206847  204800  100M Linux filesystem
Gelatino.img2 206848 264191   57344   28M EFI System

$ sgdisk -p Gelatino.img
Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048          206847   100.0 MiB   8300  gelato-data
   2          206848          264191   28.0 MiB    EF00  EFI

Sector size is 512 bytes, so the partition byte offsets are 2048 × 512 = 1,048,576 for the encrypted data partition and 206848 × 512 = 105,906,176 for the EFI partition.

2. Inspect both partitions without changing the image

On Linux, the convenient route is a read-only loop device with partition scanning:

$ sudo losetup --find --partscan --show --read-only Gelatino.img
/dev/loop0

$ lsblk -f /dev/loop0
NAME      FSTYPE      FSVER LABEL  UUID
loop0
├─loop0p1 crypto_LUKS 2           cabe2e0d-a07b-42f2-9012-0e706fa734a0
└─loop0p2 vfat        FAT16 GELATO D2A2-4F93

The first partition is already telling us that blind filesystem carving is the wrong move: it is a LUKS2 container and needs a passphrase. The second partition is ordinary FAT16, so we mount only that one, read-only.

$ sudo mkdir -p /mnt/gelatino-esp
$ sudo mount -o ro /dev/loop0p2 /mnt/gelatino-esp
$ find /mnt/gelatino-esp -type f -printf '%P  %s bytes\n'
EFI/BOOT/BOOTX64.EFI  53248 bytes
EFI/BOOT/GRUBX64.EFI  5668864 bytes
jukebox/memdisk.pack  2064 bytes
jukebox/vault.b64  1152 bytes

The size and timestamps make BOOTX64.EFI stand out: it is tiny compared with GRUB and was modified five minutes later. That is our first strong reason to reverse the fallback loader rather than GRUB.

If mounting is inconvenient, mtools can read the FAT partition straight from the image. The @@ syntax supplies its byte offset:

$ mdir -/ -i Gelatino.img@@105906176 ::
Volume in drive : is GELATO
...
BOOTX64  EFI     53248 2026-10-15 23:10
GRUBX64  EFI   5668864 2026-10-15 23:05
MEMDIS~1 PAC      2064 2026-10-16 19:36  memdisk.pack
vault    b64      1152 2026-10-05 21:48

3. Prove that vault.b64 is a planted decoy

The name invites us to run Base64 decoding. That fails, and the visible text includes phrases such as tmp cache and do not sync that do not belong to the Base64 alphabet.

$ base64 -d vault.b64 > vault.decoded
base64: invalid input

Failure is useful evidence. Instead of stripping random characters until something decodes, inspect the Unicode code points. Three invisible characters dominate the file: U+200B (zero-width space), U+200C (zero-width non-joiner), and two U+2063 invisible separators.

from collections import Counter

text = open("vault.b64", encoding="utf-8").read()
stream = [c for c in text if c in "\u200b\u200c\u2063"]
print(Counter(hex(ord(c)) for c in stream))

separators = [i for i, c in enumerate(stream) if c == "\u2063"]
payload = stream[separators[0] + 1 : separators[1]]

bits = "".join("0" if c == "\u200c" else "1" for c in payload)
decoded = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits) - 7, 8))
print(decoded.decode())
Counter({'0x200b': 195, '0x200c': 173, '0x2063': 2})
Securinets{z3r0_w1dth_mmm_h0w_d1d_th4t_h4pp3n}

It looks convincing, but several independent clues say it is bait: it came from a file whose visible layer is deliberately invalid, it does not help unlock the LUKS volume, and another unexplained artifact (memdisk.pack) is loaded by the suspicious boot application. We record the value as a decoy and continue.

4. Use firmware strings to find the real validation path

UEFI applications are PE/COFF binaries and frequently store messages as UTF-16LE. A normal strings pass is therefore not enough; run both encodings.

$ strings -n 5 BOOTX64.EFI | grep -Ei 'gelato|reel|jukebox'

$ strings -n 5 -e l BOOTX64.EFI | grep -Ei 'gelato|reel|jukebox'
GELATO FIRMWARE / checking startup reel
Startup reel accepted (%u bytes). Handing off to the jukebox shell.
Startup reel rejected; firmware status 0x%lx

These messages are custom, not stock GRUB text. “Startup reel” also matches the jukebox theme, making memdisk.pack the natural input to the validator.

Load the binary in radare2 with relocations applied in memory, find the string, and jump to its cross-reference:

$ r2 -e bin.relocs.apply=true -e bin.cache=true -A BOOTX64.EFI
[0x00013210]> iz~reel
0   0x00007000 0x0001b000 ... "GELATO FIRMWARE / checking startup reel\r\n"

[0x00013210]> axt @ 0x1b000
fcn.000133bb 0x134ee [STRN:r--] lea rax, str.GELATO_FIRMWARE___checking_startup_reel_r_n

[0x00013210]> s fcn.000133bb
[0x000133bb]> pdf

This avoids scrolling through an entire firmware binary. We enter the one function that prints the unique custom message and examine the code immediately around its file read and success/failure branches.

5. Reconstruct the GLT1 decoder

The validator first parses a 16-byte little-endian header. Reading memdisk.pack directly confirms all four fields:

Offset Size Meaning Value
0x00 4 Magic GLT1
0x04 4 Used decoded length 1536
0x08 4 Expected CRC32 0xb84b4bd0
0x0c 4 Encoded/padded length 2048

The GLT1 file has a 16-byte header followed by 2,048 encoded bytes; the loader permutes, rotates, XORs, trims, and CRC-checks them.

The transform loop has three operations for each output index i:

  1. Read ciphertext byte (13 × i) mod 2048. Because gcd(13, 2048) = 1, this visits every input position exactly once.
  2. Rotate that byte right by i mod 8 bits.
  3. XOR it with (61 × i - 89) mod 256.

In compact notation:

decoded[i] = ROR8(encoded[(13 × i) mod 2048], i mod 8)
             XOR ((61 × i - 89) mod 256)

The small helper called by the loop is a standard 8-bit rotate-right operation. The surrounding function then checks two independent facts: the first six decoded bytes must be 070701, and the CRC32 over the first 1,536 bytes must equal the header value.

Those checks matter. Recognizable English alone could result from an off-by-one error; matching a 32-bit checksum over the whole used region is much stronger proof.

6. Reproduce the decoder and validate every field

The complete decoder is short enough to write directly in Python:

#!/usr/bin/env python3
from pathlib import Path
import struct
import zlib

packed = Path("memdisk.pack").read_bytes()
magic, used_len, expected_crc, encoded_len = struct.unpack_from("<4sIII", packed)

if magic != b"GLT1":
    raise SystemExit(f"unexpected magic: {magic!r}")

encoded = packed[16:16 + encoded_len]
if len(encoded) != encoded_len:
    raise SystemExit("truncated encoded payload")

def ror8(value, count):
    count &= 7
    if count == 0:
        return value
    return ((value >> count) | (value << (8 - count))) & 0xff

decoded = bytearray(encoded_len)
for i in range(encoded_len):
    source = encoded[(13 * i) % encoded_len]
    decoded[i] = ror8(source, i % 8) ^ ((i * 61 - 89) & 0xff)

archive = bytes(decoded[:used_len])
actual_crc = zlib.crc32(archive) & 0xffffffff

print(f"header: {magic!r} {used_len} {expected_crc:#010x} {encoded_len}")
print(f"magic: {archive[:6]!r}")
print(f"crc32: {actual_crc:#010x}")
print(f"crc_match: {actual_crc == expected_crc}")

if archive[:6] != b"070701" or actual_crc != expected_crc:
    raise SystemExit("decoder verification failed")

Path("unpacked.cpio").write_bytes(archive)

Running it against the extracted memdisk.pack gives:

header: b'GLT1' 1536 0xb84b4bd0 2048
magic: b'070701'
crc32: 0xb84b4bd0
crc_match: True

The two checks agree, so we can stop reversing and hand the result to a standard cpio parser.

7. Read the shop records and derive the passphrase

List and extract the archive:

$ cpio -itv -F unpacked.cpio
-rw-r--r--   1 root root 381 Oct 16 16:34 blends.lst
-rw-r--r--   1 root root 722 Oct 16 16:34 order_history.txt
3 blocks

$ mkdir recovered && cd recovered
$ cpio -idv -F ../unpacked.cpio
blends.lst
order_history.txt
3 blocks

The beginning of order_history.txt explains the relationship between the two files:

Mmm... pistachio was always the owner's favorite.
The register wrote tickets as flavor-ticket number.

Later, the owner's own order confirms the flavor:

Filip The Owner : 18:16 : pistachio

blends.lst is a sorted list with one flavor per line. Asking grep for an exact match and line number gives the ticket number:

$ grep -n -x pistachio blends.lst
37:pistachio

Following the stated flavor-ticket number format produces pistachio-37. This is an evidence-derived password, not a brute-force guess.

We can validate it without mounting or changing the LUKS device:

$ printf %s 'pistachio-37' | cryptsetup open \
    --test-passphrase --key-file - /dev/loop0p1
$ echo $?
0

Exit status zero proves that the candidate unlocks a real keyslot.

8. Open the vault and decrypt the recipe

Open the container read-only, mount its inner ext2 filesystem read-only, and list the only file:

$ printf %s 'pistachio-37' | sudo cryptsetup open --readonly \
    --key-file - /dev/loop0p1 gelatino-vault
$ sudo mkdir -p /mnt/gelatino-vault
$ sudo mount -o ro /dev/mapper/gelatino-vault /mnt/gelatino-vault
$ find /mnt/gelatino-vault -maxdepth 1 -type f -printf '%f  %s bytes\n'
recipe.enc  64 bytes

The first bytes identify the final wrapper:

$ file /mnt/gelatino-vault/recipe.enc
/mnt/gelatino-vault/recipe.enc: openssl enc'd data with salted password

$ xxd -l 16 /mnt/gelatino-vault/recipe.enc
00000000: 5361 6c74 6564 5f5f 0424 612d 6291 5de4  Salted__.$a-b.].

The legacy OpenSSL enc format does not record its cipher or key-derivation algorithm. The intended combination is AES-256-CBC with OpenSSL's legacy password derivation, reusing the passphrase we just recovered:

$ openssl enc -d -aes-256-cbc \
    -in /mnt/gelatino-vault/recipe.enc \
    -pass pass:pistachio-37
*** WARNING : deprecated key derivation used.
Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}

Clean up in the reverse order after working with the image:

$ sudo umount /mnt/gelatino-vault /mnt/gelatino-esp
$ sudo cryptsetup close gelatino-vault
$ sudo losetup --detach /dev/loop0

9. Full solver: image to printed flag

This Bash script automates the intended route. It requires Linux with losetup, mount, cryptsetup, cpio, openssl, and Python 3. It keeps all extracted data in a temporary directory, mounts both filesystems read-only, verifies the custom decoder's magic and CRC, prints the final plaintext, and cleans up devices even if a command fails.

#!/usr/bin/env bash
set -euo pipefail

IMAGE="${1:-Gelatino.img}"
PASSWORD='pistachio-37'
WORK="$(mktemp -d)"
LOOP=''
VAULT_OPEN=0
ESP_MOUNTED=0
VAULT_MOUNTED=0

cleanup() {
    if (( VAULT_MOUNTED )); then sudo umount "$WORK/vault" || true; fi
    if (( ESP_MOUNTED )); then sudo umount "$WORK/esp" || true; fi
    if (( VAULT_OPEN )); then sudo cryptsetup close gelatino-vault || true; fi
    if [[ -n "$LOOP" ]]; then sudo losetup --detach "$LOOP" || true; fi
    rm -rf -- "$WORK"
}
trap cleanup EXIT

mkdir -p "$WORK/esp" "$WORK/vault" "$WORK/recovered"
LOOP="$(sudo losetup --find --partscan --show --read-only "$IMAGE")"

sudo mount -o ro "${LOOP}p2" "$WORK/esp"
ESP_MOUNTED=1
cp "$WORK/esp/jukebox/memdisk.pack" "$WORK/memdisk.pack"

python3 - "$WORK/memdisk.pack" "$WORK/unpacked.cpio" <<'PY'
from pathlib import Path
import struct, sys, zlib

source, destination = map(Path, sys.argv[1:])
data = source.read_bytes()
magic, used, expected_crc, size = struct.unpack_from("<4sIII", data)
assert magic == b"GLT1"
payload = data[16:16 + size]
assert len(payload) == size

def ror8(value, count):
    count &= 7
    return value if count == 0 else ((value >> count) | (value << (8-count))) & 0xff

decoded = bytes(
    ror8(payload[(13*i) % size], i % 8) ^ ((i*61 - 89) & 0xff)
    for i in range(size)
)
archive = decoded[:used]
actual_crc = zlib.crc32(archive) & 0xffffffff
assert archive[:6] == b"070701"
assert actual_crc == expected_crc
print(f"[+] GLT1 -> cpio, CRC32 {actual_crc:#010x}")
destination.write_bytes(archive)
PY

(cd "$WORK/recovered" && cpio -id --quiet -F "$WORK/unpacked.cpio")
FLAVOR="$(awk -F' : ' '/The Owner/ {print $3}' "$WORK/recovered/order_history.txt")"
NUMBER="$(grep -n -x "$FLAVOR" "$WORK/recovered/blends.lst" | cut -d: -f1)"
DERIVED="$FLAVOR-$NUMBER"
printf '[+] Derived LUKS passphrase: %s\n' "$DERIVED"
[[ "$DERIVED" == "$PASSWORD" ]]

printf %s "$DERIVED" | sudo cryptsetup open --readonly \
    --key-file - "${LOOP}p1" gelatino-vault
VAULT_OPEN=1
sudo mount -o ro /dev/mapper/gelatino-vault "$WORK/vault"
VAULT_MOUNTED=1

printf '[+] Final plaintext: '
openssl enc -d -aes-256-cbc \
    -in "$WORK/vault/recipe.enc" -pass "pass:$DERIVED" 2>/dev/null
[+] GLT1 -> cpio, CRC32 0xb84b4bd0
[+] Derived LUKS passphrase: pistachio-37
[+] Final plaintext: Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}

Pitfalls and red herrings

Why it works

The challenge is a chain of authenticated clues rather than a cryptographic break:

  1. GPT metadata points to an EFI partition and an encrypted data partition.
  2. The UEFI loader contains custom strings and code that identifies memdisk.pack as the real path.
  3. Reversing that code reveals a deterministic permutation/rotate/XOR transform.
  4. The decoded cpio magic and exact CRC32 prove the reimplementation is correct.
  5. Two recovered records independently identify pistachio and map it to ticket number 37.
  6. LUKS keyslot validation proves pistachio-37 is not merely plausible.
  7. The inner filesystem exposes a standard OpenSSL salted container, and the same password decrypts it cleanly.

In a real system, the custom pack transform would provide obfuscation, not confidentiality. Anyone who can inspect the loader can recover its inverse. The defensive fixes are straightforward: do not embed secret-decoding logic and password clues on the same media; use authenticated firmware and Secure Boot to detect loader replacement; protect recovery secrets outside the disk; and use a modern, explicitly configured KDF such as PBKDF2 or scrypt for password-based files.

Key lessons

Show flag

Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}