$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: GPT disk image of a jukebox with decoy flags, a replaced EFI loader holding an encrypted 'reel', and a LUKS2 vault. Solution: reverse the reel cipher into a cpio archive, derive the LUKS passphrase from the order-record hint, unlock LUKS2 in Python and decrypt the OpenSSL recipe.
"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."
A single 150 MB disk image (Gelatino.img, md5 c21f03cfff2dc239cad2d372ca872ef4) is provided. The goal is to recover a recipe hidden behind three layers: a custom EFI loader that decrypts an embedded "startup reel", order records packed inside that reel, and a LUKS2 encrypted vault holding a recipe file.
Disk layout (GPT). file reports a GPT protective MBR. Parsing the GPT (mmls, or the partition array at LBA 1) gives two partitions:
| # | Name | Start sector | Offset | Size | Type | Content |
|---|---|---|---|---|---|---|
| 1 | gelato-data | 2048 | 0x100000 | 100 MB | Linux fs GUID | LUKS2 vault |
| 2 | EFI | 206848 | 0x3280000 | 28 MB | EFI System | FAT16, label GELATO |
Decoy #1. The unallocated space right after the GPT (offset 0x8000) contains a printable Securinets{...}-formatted string designed to look like the flag. It is a troll: the story explicitly says a vault holds the recipe.
EFI partition. Extracting the FAT16 partition (7z x efi.img or dd + mtools) yields:
EFI/BOOT/BOOTX64.EFI 53248 bytes <- custom "replaced" loader
EFI/BOOT/GRUBX64.EFI 5668864 bytes <- stock GRUB (noise)
jukebox/memdisk.pack 2064 bytes <- magic "GLT1"
jukebox/vault.b64 1152 bytes <- visible text + zero-width Unicode
Decoy #2 (zero-width Unicode). vault.b64 looks like normal ASCII (bk 4f2a9c1e, tmp cache 8831, do not sync) but every visible character is followed by invisible code points: U+200B (bit 0), U+200C (bit 1) and U+2063 separators. Concatenating the hidden bits MSB-first produces a second Securinets{...} string that is also a troll (about zero-width steganography). The visible cover text is a fake passphrase hint. Both decoys exist to punish pattern matching instead of following the story.
The real loader: BOOTX64.EFI. Strings show GELATO FIRMWARE / checking startup reel, Startup reel accepted (%u bytes), Startup reel rejected; firmware status 0x%lx. Disassembly of main (fcn.000133bb, see main_disasm.txt) reveals:
\jukebox\memdisk.pack and checks the first 4 bytes are GLT1.u32 values from the 16-byte header:
@4 = plaintext length (0x600)@8 = CRC32 of the plaintext (0xb84b4bd0)@12 = reel (payload) size (0x800), which must be a power of two, >= plaintext length, and the whole file must be exactly reel + 16 bytes.i in [0, reel) it computes the plaintext byte:plain[i] = ((61*i - 89) & 0xff) ^ ror8(enc[16 + (13*i) % reel], i & 7)
i.e. an index-permutation (13*i mod 2048) plus a byte-wise rotate-right (ror8) plus a running XOR constant — no key at all, pure obfuscation.
4. It sanity-checks the first 6 plaintext bytes against the literal "070701" (cpio newc magic) and the CRC32 LFSR (init 0xffffffff, poly 0xedb88320, final not), i.e. plain zlib.crc32.
5. On success: Startup reel accepted (1536 bytes). Handing off to the jukebox shell.
The plaintext is a 1536-byte cpio newc archive with two members:
blends.lst — 48 ice-cream flavors in alphabetical order (almond … yuzu).order_history.txt — 20 orders Name : HH:MM : flavor, prefixed by the hint:Mmm... pistachio was always the owner's favorite.
The register wrote tickets as flavor-ticket number.
The owner's line is Filip The Owner : 18:16 : pistachio.
LUKS2 vault. The first partition starts with a LUKS2 header: keyslot 1, key_size 64, area aes-xts-plain64 at offset 290816 (size 258048), KDF pbkdf2-sha256 with 3000 iterations, anti-forensic function luks1 with 4000 stripes, digest type pbkdf2 (sha256), data segment at offset 16777216 with sector_size 4096.
file Gelatino.img # GPT protective MBR mmls Gelatino.img # or: parse GPT at LBA 1 dd if=Gelatino.img of=efi.img bs=512 skip=206848 count=57344 7z x efi.img -oefi_extract # FAT16 -> EFI/, jukebox/
#!/usr/bin/env python3 # Decode U+200B/U+200C bit stream hidden after each visible character. ZW = {"\u200b": "0", "\u200c": "1"} text = open("efi_extract/jukebox/vault.b64", encoding="utf-8").read() bits = "".join(ZW[c] for c in text if c in ZW) bits = bits[: len(bits) // 8 * 8] print("".join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8))) # -> Securinets{...} (TROLL: zero-width decoy, not the real vault key)
memdisk.pack)Reversed from BOOTX64.EFI; no key material, only a permutation + rotate + XOR constant.
#!/usr/bin/env python3 import struct, zlib def ror8(b, n): n &= 7 return ((b >> n) | (b << (8 - n))) & 0xFF data = open("efi_extract/jukebox/memdisk.pack", "rb").read() assert data[:4] == b"GLT1" plain_len, crc_exp, reel = struct.unpack_from("<III", data, 4) # 0x600, crc, 0x800 assert reel & (reel - 1) == 0 and reel >= plain_len assert len(data) == reel + 16 enc = data[16:] plain = bytearray(reel) for i in range(reel): src = enc[(13 * i) % reel] # index permutation plain[i] = ((61 * i - 89) & 0xFF) ^ ror8(src, i & 7) # constant XOR + rotate assert plain[:6] == b"070701" # cpio newc magic assert zlib.crc32(plain[:plain_len]) == crc_exp # firmware CRC check open("memdisk_plain.cpio", "wb").write(plain[:plain_len])
Extract the archive:
mkdir -p cpio_out && (cd cpio_out && cpio -idmv < ../memdisk_plain.cpio) cat cpio_out/order_history.txt
The reel says tickets were written as flavor-ticket number. The owner's ticket is pistachio, and its 1-based index in blends.lst is 37 → passphrase pistachio-37.
grep -n pistachio cpio_out/blends.lst # 37:pistachio
Two gotchas make this the hard part:
luks1 anti-forensic merge applies a hash diffusion per round (diffuse: sha256(iv_BE || block)); a plain XOR merge will never verify. Reference: dissect.fve.luks.af.merge.#!/usr/bin/env python3 """LUKS2 keyslot unlock: PBKDF2 -> AES-XTS(512) -> LUKS1 AF merge (with diffuse) -> digest.""" import base64, json, hashlib from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes IMG, LUKS_OFF = "Gelatino.img", 0x100000 raw = open(IMG, "rb").read() js = raw[LUKS_OFF + 4096: LUKS_OFF + 4096 + 12288] # LUKS2 JSON area hdr = json.loads(js[js.find(b"{"): js.rfind(b"}") + 1]) KS, DIG = hdr["keyslots"]["1"], hdr["digests"]["0"] area, kdf = KS["area"], KS["kdf"] area_key = int(area["key_size"]) # 64 (aes-xts-plain64) salt = base64.b64decode(kdf["salt"]) iters = int(kdf["iterations"]) # 3000 stripes = int(KS["af"]["stripes"]) # 4000 ct = raw[LUKS_OFF + int(area["offset"]): LUKS_OFF + int(area["offset"]) + int(area["size"])] # ---- AES-XTS with explicit sector size (little-endian tweak, GF(2^128) x*alpha) ---- def mul_alpha(n): carry = n >> 127 n = (n << 1) & ((1 << 128) - 1) return n ^ 0x87 if carry else n def xts_decrypt(key, data, sector_size): k1, k2 = (key[:32], key[32:64]) if len(key) == 64 else (key[:16], key[16:32]) dec = Cipher(algorithms.AES(k1), modes.ECB()).decryptor() enc = Cipher(algorithms.AES(k2), modes.ECB()).encryptor() out = bytearray(len(data)) for sec in range(len(data) // sector_size): T = int.from_bytes(enc.update(sec.to_bytes(16, "little")), "little") for j in range(sector_size // 16): o = sec * sector_size + j * 16 c = int.from_bytes(data[o:o + 16], "little") p = int.from_bytes(dec.update((c ^ T).to_bytes(16, "little")), "little") ^ T out[o:o + 16] = p.to_bytes(16, "little") T = mul_alpha(T) return bytes(out) # ---- LUKS1 anti-forensic merge (diffused) ---- DSIZE = {"sha1": 20, "sha256": 32, "sha512": 64} def _h(buf, h, iv): c = hashlib.new(h) c.update((iv & 0xFFFFFFFF).to_bytes(4, "big")) c.update(buf) return c.digest() def diffuse(buf, h): ds, n = DSIZE[h], len(buf) passes, rem = divmod(n, ds) out = bytearray(n) for i in range(passes): out[i * ds:(i + 1) * ds] = _h(buf[i * ds:(i + 1) * ds], h, i) if rem: out[passes * ds:] = _h(buf[n - rem:], h, passes)[:rem] return bytes(out) def af_merge(buf, block_size, block_num, h="sha256"): tmp = bytearray(block_size) for i in range(block_num - 1): block = buf[i * block_size:(i + 1) * block_size] tmp = bytearray(diffuse(bytes(a ^ b for a, b in zip(block, tmp)), h)) last = buf[(block_num - 1) * block_size: block_num * block_size] return bytes(a ^ b for a, b in zip(last, tmp)) # ---- try a passphrase -> (ok, master_key) ---- def try_pass(pw): kek = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, iters, dklen=area_key) mk = af_merge(xts_decrypt(kek, ct, 512), 64, stripes) d_salt, d_iters = base64.b64decode(DIG["salt"]), int(DIG["iterations"]) d_val = base64.b64decode(DIG["digest"]) return hashlib.pbkdf2_hmac("sha256", mk, d_salt, d_iters, dklen=len(d_val)) == d_val, mk ok, mk = try_pass("pistachio-37") print("keyslot unlocked:", ok, "master key bytes:", len(mk))
#!/usr/bin/env python3 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes import base64, hashlib, json, struct import importlib.util, sys spec = importlib.util.spec_from_file_location("u", "luks2_unlock.py") u = importlib.util.module_from_spec(spec); spec.loader.exec_module(u) ok, mk = u.try_pass("pistachio-37") SEG = int(u.hdr["segments"]["0"]["offset"]) # 16777216 SEC = int(u.hdr["segments"]["0"]["sector_size"]) # 4096 part = open("luks_part.img", "rb") # dd of partition 1 part.seek(SEG) data = part.read(104857600 - SEG) data = data[:len(data) - len(data) % SEC] k1, k2 = mk[:32], mk[32:] out = open("recipe_volume.img", "wb") for sec in range(len(data) // SEC): tw = sec.to_bytes(16, "little") dec = Cipher(algorithms.AES(k1), modes.XTS(tw)).decryptor() out.write(dec.update(data[sec*SEC:(sec+1)*SEC]) + dec.finalize()) out.close()
The result is an ext2 filesystem with volume name recipe:
fls -r recipe_volume.img # -> inode 12: recipe.enc icat recipe_volume.img 12 > recipe.enc # 64-byte OpenSSL blob xxd recipe.enc | head -1 # 53616c7465645f5f... ("Salted__")
Salted__, same passphrase)The file is Salted__ + 8-byte salt + AES-256-CBC ciphertext, key/IV derived with EVP_BytesToKey over SHA256 (OpenSSL's non-default digest choice — try -md sha256).
openssl enc -d -aes-256-cbc -md sha256 -in recipe.enc -k 'pistachio-37' # -> Securinets{REDACTED}
Pure-Python equivalent:
import hashlib from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes blob = open("recipe.enc", "rb").read() assert blob[:8] == b"Salted__" salt, ct = blob[8:16], blob[16:] def evp_bytes_to_key(pw, salt, klen, dgst="sha256"): # OpenSSL EVP_BytesToKey d, prev = b"", b"" while len(d) < klen: prev = hashlib.new(dgst, prev + pw + salt).digest() d += prev return d[:klen] kv = evp_bytes_to_key(b"pistachio-37", salt, 32 + 16) dec = Cipher(algorithms.AES(kv[:32]), modes.CBC(kv[32:48])).decryptor() pt = dec.update(ct) + dec.finalize() print(pt[:-pt[-1]].decode()) # Securinets{REDACTED}
Use this technique when:
Securinets{...} in slack space is a classic decoy.U+200B/U+200C/U+2063): map bits MSB-first, but expect the result to be a deliberate red herring layered on a real hint.GLT1) and combo strings like checking startup reel: extract the custom loader and reverse its validation loop instead of running it.070701) and a CRC32 — that is the oracle that proves a reversed byte transform is correct.LUKS2 header with kdf: pbkdf2, af.stripes: 4000 and a pbkdf2 digest: unlock = PBKDF2 → AES-XTS(512-byte keyslot sectors) → LUKS1 merge with sha256 diffusion → PBKDF2 digest comparison.Salted__: it is openssl enc output — brute the cipher and digest (-md sha256), and reuse the passphrase already recovered.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar