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 |
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 transform loop has three operations for each output index i:
- Read ciphertext byte
(13 × i) mod 2048. Becausegcd(13, 2048) = 1, this visits every input position exactly once. - Rotate that byte right by
i mod 8bits. - 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
- Treating
0xeeas the real partition: it is the protective MBR entry for a GPT disk. A GPT-aware listing reveals the actual two partitions. - Running only ordinary
strings: the useful UEFI messages are UTF-16LE. Addstrings -e lor use a reverse-engineering tool that recognizes wide strings. - Assuming
GRUBX64.EFIis the target because it is larger: size is not relevance. The small, newerBOOTX64.EFIoccupies the firmware's fallback boot path and contains unique Gelatino messages. - The zero-width fake flag:
Securinets{z3r0_w1dth_mmm_h0w_d1d_th4t_h4pp3n}is deliberately hidden invault.b64. It is a complete decoy and never helps unlock the encrypted partition. - Guessing the rotation direction: rotate-left and rotate-right look similar in disassembly. The
070701magic and exact CRC32 settle the question immediately. - Using zero-based numbering for
blends.lst:grep -nand ordinary ticket language are one-based, so pistachio is line 37, not index 36. - Trying to crack AES or LUKS: both cryptographic layers work as designed. The challenge gives us the password through the decoded shop records.
- Adding
-pbkdf2to the final OpenSSL command: normally that is better security, but it does not match this legacySalted__file. The warning is evidence that the older derivation path was used.
Why it works
The challenge is a chain of authenticated clues rather than a cryptographic break:
- GPT metadata points to an EFI partition and an encrypted data partition.
- The UEFI loader contains custom strings and code that identifies
memdisk.packas the real path. - Reversing that code reveals a deterministic permutation/rotate/XOR transform.
- The decoded cpio magic and exact CRC32 prove the reimplementation is correct.
- Two recovered records independently identify
pistachioand map it to ticket number 37. - LUKS keyslot validation proves
pistachio-37is not merely plausible. - 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
- A protective MBR is a signpost to GPT, not the whole partition map.
- On PE/UEFI files, search both ASCII and UTF-16LE strings.
- String cross-references are often the shortest route into custom firmware behavior.
- Magic values and checksums turn reverse-engineering hypotheses into testable claims.
- Strong encryption remains strong; surrounding clues and key management are usually the attack surface.
Show flag
Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}