← Back to portfolioOpen in the interactive reader
5f bd dd 2f 1e f4 96 3e 99 e1 59 50 02 de 88 26 82 ac 95 94 08 78 1e 4c c7 09 82 a3 22 fe 40 86 5b 11 8c 10 7c ae 61 c9 1b 64 38 3e bc d5 40 8d 0c 90 d1 43 91 18 d8 e3 35 fe 2c 3a 82 8d 1f 39 18 a2 c4 0e eb de e7 8f 17 e8 af dc 72 ae 74 a9 51 66 37 13 b3 e0 55 b1 a7 40 18 64 af 17 1d 8a db 58 9c d5 2d cd 45 f1 65 b9 41 a5 da ab 8e 03 22 72 f6 09 50 48 59 17 7a d9 93 37 6c d3 1c f8 3f 77 53 1e 9d c1 94 02 15 26 a5 a9 bf 5f 8b 66 20 b4 27 47 39 e0 ac 2e 4d 02 25 73 d4 11 f1 35 6d 83 67 02 2a 75 a3 fc 39 66 ff 6f 15 5e e7 8a f3 7e f5 4c 1d b5 21 5e dd a9 5c 96 e8 df d2 e9 aa c9 27 f0 8c fb 74 93 8b b5 ae 5f 18 32 ce d7 29 eb bd f8 6b fa 0b 73 4f 6a 54 d2 2c 7a b7 89 4d d5 20 ad f2 f0 fa f6 6c 84 e0 c4 05 b9 1c 48 2c 7d 1b 29 dc c4 96 87 3d dd 70 55 64 f2 24 0f 30 1b f9 7e ac 5c ed 90 72 fa ed 45 0d ce 72 67 20 2f ab 8d 70 7f bd d9 ee d0 25 03 bd 98 95 c4 34 b5 27 88 8c e9 49 19 98 97 09 af b7 b7 14 59 63 a0 7a df d4 bb 11 cb af 00 74 8a ec b5 3f a4 cd 44 b9 68 59 58 52 da 51 48 52 a0 2f b4 ae c6 7d f4 37 16 0d fc 69 7d 7d 51 9b 03 aa d6 49 12 9b ce 32 54 5a ee 83 98 aa 14 31 2f a4 5e 83 b1 02 ca e7 14 6d 2c 5d 59 11 24 0d 78 cc 97 fb dd 46 3b 2e 9d db 07 ce 39 ed df 17 9a d6 d6 ff f5 09 a4 45 2f 86 3b 16 28 74 71 74 53 19 6a 2b 5f 2e 28 58 82 70 84 5a f0 ad 4c 60 94 97 8f a1 57 Gelatinohardware · 441 pts

Securinets CTF Qualifications 2026 · · 1 h read

Gelatino

Flags are blurred: hover or focus one to reveal it.

Author: gha32a | CTF: Securinets CTF Qualifications 2026 | Category: Hardware / Forensics / Reverse Engineering | Points: 441 | Date: October 19, 2026

Tools: file, sgdisk, dd, xxd, radare2, Python 3, cpio, cryptsetup, openssl, QEMU + OVMF

TL;DR

A raw disk image holds a LUKS2 vault and an EFI partition whose bootloader has been swapped for a custom one. That loader decodes a custom-packed file (memdisk.pack) into a cpio archive containing the shop's old order records. Those records reveal the vault passphrase (pistachio-37), and the vault holds recipe.enc, an OpenSSL-encrypted file that uses the same passphrase. Two decoy flags are planted along the way, and both are rejected by the scoreboard.

Challenge

A recovered jukebox still boots from its original media, but its loader has been replaced. A vault holds a recipe the owner took care to protect. The shop's old order records may reveal how to reach it.

Provided file: Gelatino.img (135 MB). The GPT header expects 275584 sectors but the image is truncated, which does not affect the useful partitions.

The statement gives three milestones, and the solve follows them in order:

  1. a loader (boot firmware) was replaced, so I need to understand what it does;
  2. a vault (encrypted volume) holds the recipe, which is the final flag;
  3. the shop's old order records are the way in.

A note on method: every stage contains a plausible decoy designed to trap rushed solvers and automated tooling. Checking each clue against the narrative of the statement was what separated real leads from fake ones.

1. Initial triage

console
gha32a@gelatino:~/ctf$ file Gelatino.img
Gelatino.img: DOS/MBR boot sector; partition 1 : ID=0xee, start-CHS (0x0,0,2),
end-CHS (0x13,31,12), startsector 1, 307199 sectors, extended partition table (last)

Partition type 0xEE marks a protective MBR. Its only job is to stop legacy tools from treating a GPT disk as empty, so the real layout lives in the GPT.

1.1 Reading the GPT

console
gha32a@gelatino:~/ctf$ sgdisk -p Gelatino.img
Warning! Disk size is smaller than the main header indicates!
Caution: invalid backup GPT header, but valid main header; regenerating
backup header from main header.
[...]
Main header: OK        Backup header: ERROR
Main partition table: OK   Backup partition table: ERROR

Disk Gelatino.img: 275584 sectors, 134.6 MiB
Disk identifier (GUID): 6FDFF7F5-50E7-4C18-9529-D1D4F2611161

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

A GPT keeps two copies of its header and partition table: one at the start of the disk and a backup at the very end. The image was cut short, so the backup is missing, but the primary copy is intact and that is all I need.

# Start sector Sectors Size Type Role
1 2048 204800 100 MiB Linux (8300) gelato-data, the vault
2 206848 57344 28 MiB EFI System (EF00) ESP holding the replaced loader
fig_01

Layout of Gelatino.img

Proportional bar of the disk image: protective MBR and GPT in sectors 0 to 33, dead space in sectors 34 to 2047 where the first decoy flag was hidden, partition 1 gelato-data (LUKS2, 100 MiB, sectors 2048 to 206847), partition 2 EFI (FAT16, 28 MiB, sectors 206848 to 264191), and the missing backup GPT at the end of the truncated image.

Drawn to scale from the sector numbers. The gap between the GPT and partition 1 is where the first decoy was hidden.

1.2 Mounting by offset, without copying

Sector math (512 bytes per sector) lets me mount partitions straight from the image:

  • partition 1 offset: 2048 × 512 = 1,048,576 bytes
  • partition 2 offset: 206848 × 512 = 105,906,176 bytes
console
gha32a@gelatino:~/ctf$ sudo mkdir -p /mnt/p1 /mnt/p2

gha32a@gelatino:~/ctf$ sudo mount -o ro,loop,offset=$((2048*512)),sizelimit=$((204800*512)) \
    Gelatino.img /mnt/p1
mount: can't read superblock          # expected, see 1.3

gha32a@gelatino:~/ctf$ sudo mount -o ro,loop,offset=$((206848*512)),sizelimit=$((57344*512)) \
    Gelatino.img /mnt/p2

Everything is mounted read-only so the evidence stays untouched.

1.3 Identifying each partition from raw headers

console
gha32a@gelatino:~/ctf$ dd if=Gelatino.img bs=512 skip=2048 count=2048 2>/dev/null | file -
/dev/stdin: LUKS encrypted file, ver 2, header size 16384, ID 5, algo sha256,
salt 0xad59e8d6c30a623b..., UUID: cabe2e0d-a07b-42f2-9012-0e706fa734a0, ...

gha32a@gelatino:~/ctf$ dd if=Gelatino.img bs=512 skip=206848 count=2048 2>/dev/null | file -
/dev/stdin: DOS/MBR boot sector, code offset 0x3c+2, OEM-ID "mkfs.fat",
sectors/cluster 4, FAT (16 bit), label: "GELATO"
  • gelato-data is a LUKS2 container, which is the vault from the statement. The mount failure in 1.2 is expected because there is no filesystem to read until the container is unlocked.
  • EFI is a plain FAT16 volume labelled GELATO.

2. Exploring the EFI System Partition

console
gha32a@gelatino:~/ctf$ find /mnt/p2 -type f -exec ls -l {} \;
-rwxr-xr-x 1 root root   53248 Oct 15 23:10 /mnt/p2/EFI/BOOT/BOOTX64.EFI
-rwxr-xr-x 1 root root 5668864 Oct 15 23:05 /mnt/p2/EFI/BOOT/GRUBX64.EFI
-rwxr-xr-x 1 root root    2064 Oct 16 19:36 /mnt/p2/jukebox/memdisk.pack
-rwxr-xr-x 1 root root    1152 Oct  5 21:48 /mnt/p2/jukebox/vault.b64
File Observation
BOOTX64.EFI The replaced loader. It sits at the standard UEFI fallback path, so the firmware runs it first. At 52 KiB it is far smaller than a real GRUB.
GRUBX64.EFI A genuine 5.6 MiB GRUB, loaded only after the fake loader accepts its input.
jukebox/memdisk.pack A 2064-byte blob with no recognizable format.
jukebox/vault.b64 A 1152-byte text file whose name suggests base64.

2.1 Booting the image to confirm the chain

console
gha32a@gelatino:~/ctf$ qemu-system-x86_64 -m 512 -bios OVMF.fd \
    -drive file=Gelatino.img,format=raw -nographic
...
grub> ls
(memdisk) (memdisk) (proc) (proc) (hd0) (hd0) (hd0,gpt2) (hd0,gpt2)
(hd0,gpt1) (hd0,gpt1) (cd0) (cd0)

The image boots into a bare grub> prompt with no grub.cfg, so the custom loader hands control to a blank GRUB. The (memdisk) entry shows that the firmware builds an in-memory disk, most likely from memdisk.pack.

3. Decoy #1: plaintext in the dead space between GPT and partition 1

Before any deep analysis, I looked at the gap between the GPT structures and the first partition (sectors 34 to 2047), where a clean image should be empty:

console
gha32a@gelatino:~/ctf$ dd if=Gelatino.img bs=512 skip=34 count=2014 2>/dev/null | xxd \
    | grep -v "0000 0000 0000 0000" | head

00003c00: 5365 6375 7269 6e65 7473 7b31 5f68 3070  Securinets{1_h0p
00003c10: 335f 755f 6430 6e27 745f 7553 335f 7468  3_u_d0n't_uS3_th
00003c20: 3173 5f73 6c30 707d 0a00 0000 0000 0000  1s_sl0p}........

The dump offset 0x3c00 is relative to sector 34, which puts the string at absolute offset 0x8000 (32 KiB) in the image.

Flag candidate: Securinets{1_h0p3_u_d0n't_uS3_th1s_sl0p}. The scoreboard rejected it.

The text itself ("I hope you don't use this slop") taunts anyone who blindly scans a raw image, and it has no connection to the boot chain or the vault. Lesson: an artifact that is too easy to find and unrelated to the story of the challenge deserves suspicion.

4. Decoy #2: zero-width Unicode steganography in vault.b64

The name vault.b64 suggests base64, but decoding it fails because the file hides invisible characters. I counted the non-ASCII code points:

python
from collections import Counter

data = open('/mnt/p2/jukebox/vault.b64', encoding='utf-8').read()
non_ascii = [ch for ch in data if ord(ch) > 127]
print(Counter(non_ascii))
for ch in set(non_ascii):
    print(hex(ord(ch)), ch.encode('unicode_escape'))
console
Counter({'\u200b': 195, '\u200c': 173, '\u2063': 2})
0x2063 b'\\u2063'   # INVISIBLE SEPARATOR (noise, unused)
0x200c b'\\u200c'   # ZERO WIDTH NON-JOINER
0x200b b'\\u200b'   # ZERO WIDTH SPACE

Only two of the three characters carry data: U+200B and U+200C form a binary channel, while U+2063 appears twice and acts as noise. Mapping them to bits and regrouping into bytes reveals the hidden message:

python
mapping = {'\u200b': '1', '\u200c': '0'}
bits = ''.join(mapping[ch] for ch in data if ch in mapping)
n = len(bits) - (len(bits) % 8)
out = bytes(int(bits[i:i+8], 2) for i in range(0, n, 8))
print(out.decode('ascii', errors='replace'))
console
Securinets{z3r0_w1dth_mmm_h0w_d1d_th4t_h4pp3n}

Flag candidate: Securinets{z3r0_w1dth_mmm_h0w_d1d_th4t_h4pp3n}. Also rejected.

The visible text of the file (bk 4f2a9c1e tmp cache 8831 do not sync) is filler meant to look like real base64. Two increasingly sophisticated decoys in a row told me that the real path is what the statement describes, the loader and the memdisk, and not vault.b64.

5. Reverse engineering the loader (BOOTX64.EFI)

5.1 Strings

console
gha32a@gelatino:~/ctf$ r2 -A -q -c 'iz' /mnt/p2/EFI/BOOT/BOOTX64.EFI
...
0 0x0001b000 utf16le GELATO FIRMWARE / checking startup reel\r\n
1 0x0001b058 utf16le Startup reel accepted (%u bytes). Handing off to the jukebox shell.\r\n
2 0x0001b0e8 utf16le Startup reel rejected; firmware status 0x%lx\r\n

EFI applications use UTF-16 strings, hence the utf16le entries. These three messages describe the whole story: the fake firmware reads a "startup reel" (memdisk.pack), validates it, and only then hands off to the real GRUB.

5.2 Finding the validation routine

A cross-reference on the first string leads straight to the function that does the work:

console
[0x00013000]> axt @ 0x0001b000
fcn.000133bb 0x134ee [STRN:r--] lea rax, str.GELATO_FIRMWARE___checking_startup_reel

gha32a@gelatino:~/ctf$ r2 -A -q -c 'pdf @ fcn.000133bb' /mnt/p2/EFI/BOOT/BOOTX64.EFI > fcn.txt

5.3 What fcn.000133bb does

Reading the disassembly (key parts are in the appendix), the function performs these steps in order:

  1. builds the UTF-16 path \jukebox\memdisk.pack on the stack using a series of movabs instructions;
  2. opens and reads the file through the EFI file protocols (fcn.00018937);
  3. reads the 16-byte header and checks the magic GLT1 byte by byte (cmp al, 0x47/0x4C/0x54/0x31);
  4. reads three little-endian 32-bit fields with fcn.00013029;
  5. checks that the payload size is a power of two using (C-1) & C == 0;
  6. runs a decoding loop over the payload (section 5.5);
  7. verifies that the first 6 decoded bytes equal 070701, the magic of a cpio newc archive;
  8. computes a CRC32 over the first 1536 decoded bytes and compares it with a value from the header;
  9. on success prints "accepted" and builds the GRUB memdisk, otherwise prints "rejected" and spins in an infinite loop (jmp 0x13844).
fig_02

From the replaced loader to the recipe

Flow diagram. BOOTX64.EFI validates memdisk.pack and hands off to GRUBX64.EFI with a memdisk. Then memdisk.pack decodes to a cpio archive holding blends.lst and order_history.txt, which give the passphrase pistachio-37, which opens the LUKS vault holding recipe.enc, which openssl decrypts.

Left to right, top to bottom. The first three steps happen at boot; the rest is analysis on my side.

5.4 Helper functions

fcn.00013029: read a little-endian uint32

asm
movzx eax, byte [rdi]        ; byte 0
movzx edx, byte [rdi+1]      ; byte 1
shl   edx, 8
or    edx, eax
movzx eax, byte [rdi+2]      ; byte 2
shl   eax, 0x10
or    edx, eax
movzx eax, byte [rdi+3]      ; byte 3
shl   eax, 0x18
or    eax, edx
ret                          ; returns a little-endian uint32_t

fcn.0001307e: 8-bit rotate right (ROR8): (byte >> shift) | (byte << (8 - shift)) masked to 8 bits, with shift reduced to shift & 7. A quick example: ROR8(0x03, 1) moves the low bit to the top and gives 0x81.

CRC routine: the loader folds each decoded byte into a running CRC with crc32_update (fcn.000130ce) and finishes with not eax. That final inversion is the standard CRC-32 (Ethernet/zlib) finalization, so Python's zlib.crc32 produces the same value.

5.5 Header layout and decoding algorithm

Header layout (16 bytes) reconstructed from the loader:

Bytes Field Meaning
0 to 3 GLT1 magic
4 to 7 A = 1536 number of decoded bytes the CRC covers
8 to 11 B expected CRC32 (finalized)
12 to 15 C = 2048 size of the encoded payload

The payload starts at offset 0x10, which gives 16 + 2048 = 2064 bytes, matching the file size.

Decoding rule for each logical position i in [0, 2047]:

text
raw        = payload[(13 * i) mod 2048]
shift      = i mod 8
rotated    = ROR8(raw, shift)
decoded[i] = rotated XOR ((61 * i - 89) mod 256)

The read index 13·i mod 2048 is a permutation: 13 is coprime with 2048, so it is invertible. It scrambles the order of bytes on disk relative to the decoded stream. Combined with the rotation and a position-dependent XOR, this is simple obfuscation that is effective against a casual sequential read but trivial once the code is understood.

5.6 Decoder script

python
from pathlib import Path
import struct, zlib

blob = Path("memdisk.pack").read_bytes()
assert blob[:4] == b"GLT1"

# header: magic, A, B, C (little-endian uint32 each)
size, expected_crc, span = struct.unpack_from("<III", blob, 4)
payload = blob[16:]
assert len(payload) == span == 2048

def ror8(b, n):
    n &= 7
    return b if n == 0 else ((b >> n) | (b << (8 - n))) & 0xFF

out = bytes(
    ror8(payload[(13 * i) % span], i % 8) ^ ((61 * i - 89) & 0xFF)
    for i in range(span)
)

print("magic check:", out[:6])                  # must be b'070701'
crc = zlib.crc32(out[:size]) & 0xFFFFFFFF
print(f"expected={expected_crc:#010x}  actual={crc:#010x}")
assert out[:6] == b"070701" and crc == expected_crc, "decode failed"

Path("memdisk_decoded.bin").write_bytes(out)

Output:

console
magic check: bytearray(b'070701')
crc32: matches the value stored in the header
console
gha32a@gelatino:~/ctf$ file memdisk_decoded.bin
memdisk_decoded.bin: ASCII cpio archive (SVR4 with no CRC)

Matching both the magic and the CRC32 proves the decode is exactly what the loader would accept, not just something that looks plausible.

6. Extracting the cpio archive

The 070701 magic marks the newc cpio format. Each member starts with that 6-character magic followed by metadata fields written as ASCII hexadecimal, which is why file reports an ASCII cpio archive. GRUB uses this format for memory disks.

console
gha32a@gelatino:~/ctf$ mkdir memdisk_extracted && cd memdisk_extracted
gha32a@gelatino:~/ctf/memdisk_extracted$ cpio -idv < ../memdisk_decoded.bin
blends.lst
order_history.txt
3 blocks

gha32a@gelatino:~/ctf/memdisk_extracted$ ls -l
-rw-r--r-- 1 gha32a gha32a 381 Oct 16 15:34 blends.lst
-rw-r--r-- 1 gha32a gha32a 722 Oct 16 15:34 order_history.txt

These two files are the "old order records" from the statement.

7. Reading the clue

order_history.txt (excerpt):

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

Molly Carter : 10:12 : vanilla
Ethan Brooks : 10:48 : lemon
...
Filip The Owner : 18:16 : pistachio
...
Lily James : 20:27 : walnut

blends.lst (excerpt, alphabetical):

console
almond
amaretto
banana
...
pecan
pistachio        <-- line 37
raspberry
...
yuzu

Two facts come out of this:

  • the favorite flavor is pistachio, ordered by "Filip The Owner";
  • tickets are written as flavor-ticket number, so the passphrase has the form pistachio-<N>.

7.1 What "ticket number" means

The hint is ambiguous on purpose.

Interpretation Value Result
Position of the owner's order in order_history.txt (chronological rank) 18 fails
Position of the flavor in the catalog blends.lst (alphabetical) 37 works

The first reading feels natural but is wrong. "Ticket number" refers to the number tied to the flavor itself in the shop's catalog.

python
blends = open('blends.lst').read().split()
print(blends.index('pistachio') + 1)   # -> 37

8. Cracking the LUKS2 passphrase

8.1 Read-only loop device

console
gha32a@gelatino:~/ctf$ sudo losetup -r -f --show -o $((2048*512)) --sizelimit $((204800*512)) \
    Gelatino.img
/dev/loop2

gha32a@gelatino:~/ctf$ sudo cryptsetup isLuks /dev/loop2 && echo "valid LUKS"
valid LUKS

To inspect the container metadata (cipher, keyslots, KDF) without unlocking it, cryptsetup luksDump /dev/loop2 is the tool to use.

8.2 Why --test-passphrase

cryptsetup open --test-passphrase only checks whether a passphrase unlocks a keyslot. It creates no device mapping and mounts nothing, which makes it ideal for testing many candidates. LUKS2 slows each attempt down on purpose with its key-derivation function, so a short, well-reasoned candidate list beats blind brute force.

8.3 Tester script

bash
#!/bin/bash
# try_passphrases.sh
LOOPDEV=/dev/loop2
FILE="$1"
while IFS= read -r pass; do
    echo -n "$pass" | sudo cryptsetup open --test-passphrase "$LOOPDEV" 2>/dev/null
    if [ $? -eq 0 ]; then
        echo "FOUND: $pass"
        exit 0
    fi
done < "$FILE"
echo "No passphrase found"

8.4 Targeted candidate list

I generated several plausible formats (separator variants, number-first form, case variants):

python
blends = open('blends.lst').read().split()
pos = blends.index('pistachio') + 1     # 37

candidates = [
    f"pistachio-{pos}", f"pistachio {pos}", f"pistachio_{pos}",
    f"{pos}-pistachio", f"Pistachio-{pos}", f"PISTACHIO-{pos}",
]
open('candidates.txt', 'w').write('\n'.join(candidates))
console
gha32a@gelatino:~/ctf$ ./try_passphrases.sh candidates.txt
FOUND: pistachio-37

8.5 Alternative: sweep the ticket number

If you are unsure how to interpret "ticket number", a numeric sweep avoids committing to one theory:

python
import subprocess

device = "/dev/loop2"
for n in range(1, 101):
    cand = f"pistachio-{n}"
    r = subprocess.run(
        ["cryptsetup", "open", "--type", "luks", "--test-passphrase", "--key-file=-", device],
        input=cand.encode(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    if r.returncode == 0:
        print("MATCH:", cand)
        break

Both approaches land on pistachio-37.

8.6 Opening and mounting the vault

console
gha32a@gelatino:~/ctf$ sudo cryptsetup open --readonly /dev/loop2 vault
Enter passphrase: pistachio-37

gha32a@gelatino:~/ctf$ sudo mkdir -p /mnt/vault
gha32a@gelatino:~/ctf$ sudo mount -o ro /dev/mapper/vault /mnt/vault
gha32a@gelatino:~/ctf$ find /mnt/vault -type f -exec ls -l {} \;
-rwxr-xr-x 1 root root 64 Oct 16 15:34 /mnt/vault/recipe.enc

9. Decrypting recipe.enc

console
gha32a@gelatino:~/ctf$ file /mnt/vault/recipe.enc
/mnt/vault/recipe.enc: openssl enc'd data with salted password

gha32a@gelatino:~/ctf$ xxd /mnt/vault/recipe.enc
00000000: 5361 6c74 6564 5f5f 0424 612d 6291 5de4  Salted__.$a-b.].
00000010: d134 bbdd 2c1f 37f2 de8b c333 a8fa e1c8  .4..,.7....3....
00000020: 8681 ea60 9662 8dba a666 7df8 560c 94d9  ...`.b...f}.V...
00000030: 6d6d 1cd4 1c6f 33c3 5641 2453 3f45 754b  mm...o3.VA$S?EuK

The file starts with Salted__ followed by an 8-byte salt (04 24 61 2d 62 91 5d e4), which is the signature of openssl enc. In that format the key and IV are derived from the passphrase and salt with the legacy EVP_BytesToKey function (MD5-based, single iteration) unless -pbkdf2 is given. The vault passphrase is reused as the symmetric key, with AES-256-CBC:

console
gha32a@gelatino:~/ctf$ openssl enc -d -aes-256-cbc -in /mnt/vault/recipe.enc -pass pass:pistachio-37
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}

If a bad decrypt message follows the output, it only means the PKCS#7 padding check failed on the last block. The flag was already printed earlier in the stream and stays valid. It is not a full decryption failure.

Flag

text
Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}

Solve chain at a glance

Step Action
Triage file, sgdisk → GPT with 2 partitions
Mounting offset/sizelimit → LUKS2 (gelato-data) + FAT16 (EFI)
Decoy 1 plaintext in inter-partition gap → fake flag
Decoy 2 zero-width steganography in vault.b64 → fake flag
Reverse engineering radare2 on BOOTX64.EFI → permutation + ROR8 + XOR decoder for memdisk.pack
Extraction cpio → order_history.txt + blends.lst
Clue position of pistachio in the catalog, not in the chronological log
Passphrase pistachio-37 (LUKS, then reused for AES-256-CBC)
Final decryption openssl enc -d -aes-256-cbc
Flag Securinets{n3xt_time_buy_s0m3th1ng_h34lthy}

Quick reproduction

bash
# 1. mount the EFI partition read-only and decode the startup reel
sudo mount -o ro,loop,offset=$((206848*512)),sizelimit=$((57344*512)) Gelatino.img /mnt/p2
python3 decode_memdisk.py                      # script from section 5.6
mkdir out && cd out && cpio -idv < ../memdisk_decoded.bin

# 2. open the vault with the passphrase from the catalog position
sudo losetup -r -f --show -o $((2048*512)) --sizelimit $((204800*512)) ../Gelatino.img
sudo cryptsetup open --readonly /dev/loopX vault           # pistachio-37
sudo mount -o ro /dev/mapper/vault /mnt/vault

# 3. decrypt the recipe
openssl enc -d -aes-256-cbc -in /mnt/vault/recipe.enc -pass pass:pistachio-37

Technical concepts used

  • GPT partitioning: protective MBR, primary and backup headers, working with a truncated image.
  • Offset mounting: converting sectors to byte offsets and using offset/sizelimit to avoid copying data.
  • UEFI boot chain: the role of the ESP and of EFI/BOOT/BOOTX64.EFI as the default entry point, and what replacing it means.
  • Unicode steganography: binary encoding with zero-width characters (U+200B/U+200C) and noise characters (U+2063).
  • Static reverse engineering: radare2 (iz, axt, pdf), reading x86-64 disassembly, reconstructing a decoder (modular permutation, bit rotation, position-dependent XOR).
  • CRC32: integrity checking with zlib.crc32 and cross-validating against the value stored in a file header.
  • cpio newc archives: the 070701 format GRUB uses for memory disks.
  • LUKS2: header structure and cryptsetup --test-passphrase for targeted guessing without mounting each time.
  • OpenSSL enc: the Salted__ format and the legacy EVP_BytesToKey derivation versus -pbkdf2.

What I learned

  • Validate every clue against the story of the challenge before submitting a flag. Two decoys in a row existed to catch exactly that mistake.
  • Read hints literally and test a numeric range when two interpretations compete, instead of hammering a single theory.
  • Confirm a decode with an integrity check (magic bytes plus CRC32), not by eye.
  • cryptsetup --test-passphrase gives fast, non-destructive passphrase testing, and a small reasoned candidate list beats brute force against a deliberately slow KDF.
  • Keep the evidence read-only (ro mounts, read-only loop devices) throughout an investigation.

Appendix: key parts of fcn.000133bb

asm
; build path \jukebox\memdisk.pack (successive movabs)
...
call fcn.000151d0            ; init path buffer
lea  rax, str.GELATO_FIRMWARE_...
call fcn.00017850            ; Print("GELATO FIRMWARE / checking ...")

; open file
call fcn.00018937            ; OpenVolume / Open file
test rax, rax
js   0x1381f                 ; failure -> rejected

; read 16-byte header
call fcn.0001311c            ; Read(buffer, &size)
test rax, rax
js   0x13822

; magic "GLT1"
cmp  al, 0x47                ; 'G'
cmp  al, 0x4c                ; 'L'
cmp  al, 0x54                ; 'T'
cmp  al, 0x31                ; '1'

; fields A, B, C (LE32) via fcn.00013029
call fcn.00013029            ; A = 1536
call fcn.00013029            ; B = expected CRC32
call fcn.00013029            ; C = 2048

; C must be a power of two
sub  rax, 1
and  rax, qword [var_30h]    ; (C-1) & C == 0 ?
jne  0x1363e

; decode loop, i = 0..C-1
.loop:
mov  rax, rdx
add  rax, rax
add  rax, rdx
shl  rax, 2
add  rax, rdx                ; rax = 13*i
xor  edx, edx
div  qword [var_30h]         ; rdx = (13*i) mod C
...
call fcn.0001307e            ; ROR8(raw, i & 7)
mov  ecx, 0x3d               ; 61
imul eax, ecx
sub  eax, 0x59               ; 89
xor  eax, edx                ; decoded = rotated ^ (61*i - 89)

; magic check "070701" on i = 0..5
; CRC32 accumulation while i < A (1536)
call fcn.000130ce            ; crc32_update(crc, byte)

; finalize and compare with header field B
mov  eax, [var_14h]
not  eax
cmp  [var_24h], eax
je   0x13741                 ; "accepted" + memdisk handoff