2921 words
15 minutes
mini-LCTF 2026 Writeup (Part)

Here are the writeups for a few RE and Misc challenges I created for mini-LCTF 2026.

ezbox#

A terminal sokoban game with 10-layer Tower of Hanoi + recursive blocks. Complete all levels to get the flag.

Attachment: ezbox (Linux ELF, packed with PyInstaller)

Solution 1: Play through the game#

After unpacking and decompiling, directly import the game module in Python and simulate key presses to complete all 1024 contexts.

1. Unpack & Decompile#

Terminal window
# 1. Unpack
pyinstxtractor ezbox
# 2. Upload the .pyc files to https://www.pylingual.io/ for decompilation
# You'll get main.py, game.py, levels.py, flag.py source code

The unpacked directory contains all .pyc files and _core.so. Python can directly import .pyc files — no need to fix decompiled code. Using pylingual is only to understand the game logic. Note that the Python version must match the packaging environment (3.12).

2. Understanding the game mechanics#

  • Level h0 (base case): one box b, push it onto the _ target, player stands on = to finish
  • Levels h1h10: Tower of Hanoi layout, blocks 0N-1 stacked on pillar A (x=2), target on pillar C (x=15)
  • Walking into an incomplete recursive block → enters a sub-level. After the sub-level is completed, the block unlocks and becomes pushable.
  • Each entry into a sub-level generates a unique context path (h10h10/0h10/1/0 → …)
  • completed_hashes tracks all completed contexts

3. Writing the game AI#

Core idea: for each level, process blocks in order (top to bottom):

  1. Walk to the left of the block (x=1 column, no entities in the way)
  2. Push right → enter (if incomplete) or push (if completed)
  3. After entering, recursively handle the sub-level
  4. After exiting, push the block to the target (x=15)
  5. Finally walk to = to complete the level
from game import Game
from flag import try_get_flag
from levels import load_level_file, RECURSIVE_BLOCKS
def walk_to(game, tx, ty):
"""Walk to (tx, ty), using x=1 empty column to avoid entities"""
px, py = game.player_pos
while px > 1: game.move(-1, 0); px = game.player_pos[0]
while px < 1: game.move(1, 0); px = game.player_pos[0]
while py < ty: game.move(0, 1); py = game.player_pos[1]
while py > ty: game.move(0, -1); py = game.player_pos[1]
while px < tx: game.move(1, 0); px = game.player_pos[0]
def solve_level(level_id, game, context):
terrain, entities, player_pos = load_level_file(level_id)
game.load_level(level_id, terrain, entities, player_pos,
level_id, context=context)
if level_id == 'h0':
game.move(1,0); game.move(0,1); game.move(1,0)
game.move(0,1); game.move(0,1)
if game.check_completion(): game.complete_level()
return
blocks = sorted([(p, e) for p, e in entities.items() if e != 'p'],
key=lambda x: (x[0][1], x[0][0]))
for (bx, by), bid in blocks:
if bid in RECURSIVE_BLOCKS and not game.is_block_completed(bid):
walk_to(game, 1, by)
result = game.move(1, 0)
if result and result.startswith('enter:'):
game.enter_block(bid)
solve_level(f'h{bid}', game, f'{context}/{bid}')
game.exit_block()
cur = next((p for p, e in game.entities.items() if e == bid), None)
if not cur: continue
cx, cy = cur
walk_to(game, cx - 1, cy)
for _ in range(15 - cx): game.move(1, 0)
eq = next((p for p, c in terrain.items() if c == '='), None)
if eq:
walk_to(game, 1, eq[1])
while game.player_pos[0] < eq[0] - 1: game.move(1, 0)
game.move(1, 0)
if game.check_completion(): game.complete_level()
game = Game()
solve_level('h10', game, 'h10')
print(try_get_flag(game.completed_hashes, game.total_steps))

cd into the unpacked directory and run. See the source file solve.py for the complete script.

Solution 2: Reverse the Python code to compute the key directly#

No need to actually play the game. The 1024 context hashes depend only on target positions on fixed terrain, and can be computed directly from the level files.

1. Understanding key derivation#

After decompiling flag.py:

def derive_key(completed_hashes):
combined = ''.join(completed_hashes[lp] for lp in sorted(completed_hashes))
return hashlib.sha256(combined.encode()).digest()[:16]
def hash_level_state(level_path, goals_str):
data = f"{level_path}|{goals_str}"
return hashlib.sha256(data.encode()).hexdigest()

Key = concatenate all 1024 context hashes in lexicographic order → SHA256 → first 16 bytes.

Each context hash = something like SHA256("h10/1/0|2,3;3,4"), depending only on the _ and = positions of that context’s level.

2. Generate 1024 contexts and compute hashes#

collect_all_context_paths() in levels.py can enumerate all 1024 contexts. The level file for each context is the last segment (h10/1/0h0).

from levels import load_level_file, collect_all_context_paths
from flag import hash_level_state, derive_key
def file_for_context(ctx):
"""h10/1/0 → h0, h10 → h10"""
last = ctx.rsplit('/', 1)[-1]
return last if last.startswith('h') else f'h{last}'
contexts = collect_all_context_paths()
hashes = {}
for ctx in sorted(contexts):
file_id = file_for_context(ctx)
terrain, _, _ = load_level_file(file_id)
goals = sorted(f'{x},{y}' for (x,y), c in terrain.items() if c in '=_')
hashes[ctx] = hash_level_state(ctx, ';'.join(goals))
key = derive_key(hashes)

3. Decrypt the flag#

The encrypted flag bytes are in _core.so. You can extract them with IDA, or simply call _core.decrypt(key) from Python (since you’ve already imported it):

from _core import decrypt
print(decrypt(key))
# miniL{EZ_Hano1_ez_s1gn1n_r1ght?}

cd into the unpacked directory and run.

Key techniques#

TechniqueDescription
PyInstaller unpackingpyinstxtractor extracts .pyc files
Python decompilationpylingual.io online decompiler, pycdc as fallback
Native reversingIDA analysis of _core.so (XXTEA + embedded encrypted bytes)
Tower of Hanoi structureBlock K → sub-level hK, recursive context h10/1/0…
Hash chain analysis1024 context hashes depend only on fixed terrain
Bypassing the gameCompute the hash chain without playing

Flag: miniL{EZ_Hano1_ez_s1gn1n_r1ght?}


GQuuuuuupX#

The challenge provides a Linux ELF that can be unpacked normally with upx -d, but the unpacked program defaults to key 0x42 and only accepts a decoy flag. The original packed program’s UPX stub changes this key to 0x37 before jumping to OEP. The comparison is done in a streaming fashion, so approaches include setting breakpoints to brute-force, or reversing the algorithm step by step.

Step 1: Unpack and recover the decoy#

The handout is a packed ELF with no section headers:

Terminal window
$ file GQuuuuuupX
GQuuuuuupX: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), BuildID[sha1]=d1bfa3b950b441544fee994edcbbdd40c3198636, for GNU/Linux 3.2.0, statically linked, no section header

upx -d can directly recover a normal stripped ELF:

Terminal window
$ upx -d -o GQuuuuuupX.upx-d GQuuuuuupX
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2026
UPX 5.1.1 Markus Oberhumer, Laszlo Molnar & John Reiser Mar 5th 2026
File size Ratio Format Name
-------------------- ------ ----------- -----------
30752 <- 12688 41.26% linux/amd64 GQuuuuuupX.upx-d
Unpacked 1 file.

Throw GQuuuuuupX.upx-d into IDA/Ghidra. Starting from main, you’ll find the flag checking logic: the input format is miniL{...}, body length is 103, and the character set is restricted to [A-Z0-9_]. Following deeper into the verifier, a global state byte participates in profile selection:

g_stub_state.key = 0x42;
profile = ((((unsigned)g_stub_state.key >> 1) ^ g_stub_state.key) ^ 1) & 1;

So in the unpacked plain ELF:

key = 0x42
profile = 0

The verifier body looks convoluted but is byte-wise reversible. When reproducing, you need to recover these parts from the decompilation:

g_material_blob # stores masked anchors and round constants
g_round_program_enc # key/profile-dependent VM bytecode
g_opcode_map_enc # key/profile-dependent opcode map
decode_material_slots()
decode_round_program()
decode_opcode_map()
init_profile_state()
derive_step()
transform_byte()
update_rolling()
mix_body_byte()

The recovery flow for each byte is:

raw = masked_anchor[i] ^ anchor_mask(profile, key, i, rolling)
step = derive_step(profile, key, i, state, scratch, rolling, raw)
target = low_byte(step)
body[i] = invert_transform_byte(target, state, step, i)
rolling/state/scratch = update_with_recovered_byte(...)

transform_byte() consists only of byte addition, xor, and 8-bit rotates. Write a decryption script by reversing these operations. Running it with profile = 0, key = 0x42 yields:

miniL{ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86}

btw, this string can make Claude refuse to respond — though it seems AI contestants were all using GPT anyway.

This flag passes the upx -d unpacked program, but not the original handout:

upx-d decoy rc=0 out=correct!
packed decoy rc=1 out=try again~

This means what upx -d extracts is only part of the story — further reversing is needed.

Step 2: Dynamically confirm the UPX stub changes the key#

Since the plain program accepts the decoy while the packed one rejects it, we should check if the UPX stub modifies the plain ELF’s data before jumping to OEP. The plain ELF is non-PIE, so we can directly locate the verifier key:

g_stub_state.key = 0x407fa0

Set a hardware watchpoint on the original packed file:

Terminal window
$ gdb -q GQuuuuuupX
(gdb) watch *(unsigned char*)0x407fa0
(gdb) run miniL{A}

The first break is the loader initializing the plain data to 0x42:

Hardware watchpoint 1: *(unsigned char*)0x407fa0
Old value = 0 '\000'
New value = 66 'B'
0x00007ffff7ff8aee in ?? ()
0x407fa0: 0x42

Continue — the second break is the critical one:

Terminal window
(gdb) continue
Hardware watchpoint 1: *(unsigned char*)0x407fa0
Old value = 66 'B'
New value = 55 '7'
0x0000000000403a38 in ?? ()
0x407fa0: 0x37
rip 0x403a38 0x403a38
0x403a30: ret
0x403a31: syscall
0x403a33: movb $0x37,-0x60(%r13)
=> 0x403a38: pop %rdx
0x403a39: pop %rax
0x403a3a: jmp *%rax

The patched UPX stub changes the verifier key to 0x37 before jumping to OEP. Plugging this into the profile formula:

key = 0x37
profile = 1

This key doesn’t just affect the final comparison value. decode_round_program(), decode_opcode_map(), material slot ordering, scratch size, anchor mask, and rolling state all depend on key/profile — so you can’t simply replace the string from the decoy.

Solution 1: Reverse the algorithm#

Although the verification algorithm looks complex, we’re in the era of large language models. Feed the upx -d output to a sufficiently capable LLM and keep interrogating it until you get a step-by-step reversing script. Then replace the data in the script with the correctly analyzed data from above. See recover.py in the source files for reference.

Solution 2: GDB brute-force#

Find the streaming comparison point and use gdb/Frida hooks to capture the computed values, then enumerate the input byte by byte.

Here’s an example gdb script (credit to Starfall Koi / Radiant LyCn):

import gdb
charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_"
known_flag = ""
gdb.execute("set pagination off")
gdb.execute("break *0x403650")
for i in range(103):
for c in charset:
test_input = known_flag + c + "R" * (102 - i)
with open("input.txt", "w") as f:
f.write("miniL{" + test_input + "}\n")
gdb.execute("run < input.txt")
for _ in range(i):
gdb.execute("continue")
rax_val = int(gdb.parse_and_eval("$rax"))
if (rax_val & 0xFF) == 0:
print(f"[+] The {i}st/nd/th char is {c}")
known_flag += c
print(f"Current Flag is {known_flag}")
break
print("FINAL FLAG: miniL{" + known_flag + "}")

Flag: miniL{HELLO_FROM_THE_OTHER_SIDE_IMUSTVE_CALLED_THOUSAND_TIMES_TO_TELL_YOU_IM_SORRY_FOR_EVERYTHING_THAT_I_DONE}


local music#

The challenge provides a wyy binary and a flag.enc encrypted container. Reverse engineering wyy reveals that it encrypts an mp3 audio file with AES-128-ECB + a modified RC4, with the key derived from SHA256 of a fixed string concatenated with the file modification time. The valid timestamp range is baked in at compile time. After brute-forcing the timestamp and decrypting the mp3, the ID3 tag contains the hint “Do you know FFT?” — viewing the spectrogram reveals the flag in the final segment.

Step 1: Reverse wyy, understand the encryption#

wyy is a Rust-compiled binary. Through strings and reverse engineering, the encryption flow can be recovered:

  • Key derivation: SHA256("KaguyaIrohaYachiyo" + timestamp_string), first 16 bytes = core_key, last 16 bytes = meta_key.
  • Timestamp constraint: build.rs writes the valid timestamp range into build_consts.rs at compile time. The range is (build_time/10000) ± 20000 buckets (each bucket = 10000 seconds). At runtime, derive_keys() contains an assert! guard — out-of-range timestamps trigger a panic.
  • Container structure:
HEADER: 10 bytes "MINILCTF\0\0"
key_frame: [4 bytes LE length] [AES-ECB(KEY_PREFIX + audio_key) ⊕ 0x64]
meta_frame: [4 bytes LE length] [COMMENT_PREFIX + base64(AES-ECB(META_PREFIX + json)), all bytes XOR 0x63]
5 bytes zero padding
image_offset: 4 bytes LE
image_data
audio_data: XOR-encrypted with NcmRc4 stream cipher
  • Audio encryption: Modified RC4. KSA is standard. PRGA index calculation is box[(box[j] + box[(box[j] + j) & 0xFF]) & 0xFF]. The 64-byte audio_key is derived from the 16-byte core_key through 4 rounds of rotate/add/xor expansion.

Step 2: Brute-force the timestamp, decrypt the container#

The timestamp range is constrained by the assert, so just enumerate timestamps. For each candidate timestamp:

  1. Compute SHA256("KaguyaIrohaYachiyo" + ts) to get core_key and meta_key
  2. Parse key_frame: XOR bytewise with 0x64, AES-128-ECB decrypt, strip KEY_PREFIXaudio_key
  3. Parse meta_frame: XOR bytewise with 0x63, take part after COMMENT_PREFIX, base64 decode, AES-128-ECB decrypt → metadata JSON
  4. Initialize NcmRc4 with audio_key, XOR decrypt audio data
  5. Validate: decrypted audio starting with fLaC or ID3/0xFF 0xFB indicates success

Full solve script:

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import hashlib
import shutil
import subprocess
from pathlib import Path
HEADER = b"MINILCTF\x00\x00"
KEY_PREFIX = b"miniL-audio-key"
META_PREFIX = b"miniL:"
COMMENT_PREFIX = b"miniL meta:"
KEY_SEED_PREFIX = b"KaguyaIrohaYachiyo"
def main() -> None:
args = parse_args()
if shutil.which("openssl") is None:
raise SystemExit("openssl not found in PATH")
data = args.input.read_bytes()
if not data.startswith(HEADER):
raise SystemExit("bad container header")
base_ts = args.timestamp or int(args.input.stat().st_mtime)
ts, audio, meta, image = try_decrypt(data, base_ts, max(args.search, 0))
out_dir = args.output_dir or args.input.parent
out_dir.mkdir(parents=True, exist_ok=True)
stem = args.stem or args.input.stem
# Write decrypted audio (mp3 format in this challenge)
if audio.startswith(b"fLaC"):
audio_ext = "flac"
elif audio.startswith(b"ID3") or audio[:2] == b"\xFF\xFB":
audio_ext = "mp3"
else:
audio_ext = "bin"
audio_path = out_dir / f"{stem}.{audio_ext}"
audio_path.write_bytes(audio)
print(f"[+] decrypted audio → {audio_path}")
# Write metadata
meta_path = out_dir / f"{stem}.json"
meta_path.write_bytes(meta)
print(f"[+] metadata → {meta_path}")
# Write cover image
if image:
ext = "png" if image.startswith(b"\x89PNG") else "jpg"
img_path = out_dir / f"{stem}.{ext}"
img_path.write_bytes(image)
print(f"[+] cover image → {img_path}")
print(f"[*] timestamp = {ts}")
# ID3 tag contains hint "Do you know FFT?" — points to spectrogram
print(f"[*] View {audio_path} spectrogram in Audacity/Sonic Visualiser to see the flag")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="wyy challenge solver")
parser.add_argument("input", nargs="?", type=Path, default=Path("flag.enc"))
parser.add_argument("--timestamp", type=int, help="manually specify timestamp")
parser.add_argument("--search", type=int, default=10000, help="brute-force radius (seconds)")
parser.add_argument("--output-dir", type=Path, help="output directory")
parser.add_argument("--stem", help="output filename prefix")
return parser.parse_args()
def try_decrypt(data, base_ts, radius):
candidates = [base_ts]
for delta in range(1, radius + 1):
candidates.append(base_ts + delta)
candidates.append(base_ts - delta)
for ts in candidates:
core_key, meta_key = derive_keys(ts)
try:
audio, meta, image = decrypt(data, core_key, meta_key)
return ts, audio, meta, image
except Exception:
continue
raise SystemExit(f"failed to decrypt around ts={base_ts} +/-{radius}s")
def derive_keys(ts):
digest = hashlib.sha256(KEY_SEED_PREFIX + str(ts).encode()).digest()
return digest[:16], digest[16:]
def decrypt(data, core_key, meta_key):
pos = len(HEADER)
# Decrypt key frame → audio_key
key_frame, pos = read_frame(data, pos)
key_frame = bytes(b ^ 0x64 for b in key_frame)
plain = aes128_ecb_decrypt(key_frame, core_key)
if not plain.startswith(KEY_PREFIX):
raise ValueError("bad key frame")
audio_key = plain[len(KEY_PREFIX):]
# Decrypt meta frame
meta_frame, pos = read_frame(data, pos)
meta_frame = bytes(b ^ 0x63 for b in meta_frame)
if not meta_frame.startswith(COMMENT_PREFIX):
raise ValueError("bad meta prefix")
payload = base64.b64decode(meta_frame[len(COMMENT_PREFIX):])
meta_plain = aes128_ecb_decrypt(payload, meta_key)
if not meta_plain.startswith(META_PREFIX):
raise ValueError("bad meta")
meta = meta_plain[len(META_PREFIX):]
# Skip 5 zero bytes + image_offset + image_data
pos += 5
image_offset = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
image, pos = read_frame(data, pos)
if image_offset > len(image):
pos += image_offset - len(image)
# Decrypt audio
audio = xor_audio(data[pos:], audio_key)
return audio, meta, image
def aes128_ecb_decrypt(data, key):
proc = subprocess.run(
["openssl", "enc", "-aes-128-ecb", "-d", "-nopad", "-nosalt", "-K", key.hex()],
input=data, check=True, capture_output=True,
)
result = proc.stdout
# PKCS7 unpad
pad = result[-1]
if pad == 0 or pad > 16 or result[-pad:] != bytes([pad]) * pad:
raise ValueError("bad pkcs7")
return result[:-pad]
def xor_audio(data, key):
box = list(range(256))
j = 0
for i in range(256):
j = (box[i] + j + key[i % len(key)]) & 0xFF
box[i], box[j] = box[j], box[i]
plain = bytearray(data)
for i in range(len(plain)):
j = (i + 1) & 0xFF
plain[i] ^= box[(box[j] + box[(box[j] + j) & 0xFF]) & 0xFF]
return bytes(plain)
def read_frame(data, pos):
size = int.from_bytes(data[pos:pos + 4], "little")
return data[pos + 4:pos + 4 + size], pos + 4 + size
if __name__ == "__main__":
main()

Step 3: View the spectrogram#

Open the decrypted mp3 in Audacity, switch to Spectrogram view, and the flag text is visible near the end of the audio.

You can also render it with Python (convert mp3 to wav first):

import subprocess
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import stft
from PIL import Image
def mp3_to_wav(mp3_path):
wav_path = mp3_path.with_suffix(".wav")
subprocess.run(
["ffmpeg", "-v", "error", "-i", str(mp3_path), "-ar", "48000", "-ac", "2", str(wav_path)],
check=True,
)
return wav_path
wav = mp3_to_wav(Path("flag.mp3"))
sr, audio = wavfile.read(str(wav))
signal = audio[:, 0].astype(np.float32)
_, _, Z = stft(signal, fs=sr, window="hann", nperseg=4096, noverlap=3840)
spec = np.abs(Z[:, -8000:]) # flag is in the final segment, roughly 38s–18s before end
spec_db = 20 * np.log10(np.clip(spec, 1e-10, None))
img = np.clip((spec_db + 80) / 80 * 255, 0, 255).astype(np.uint8)
Image.fromarray(np.flipud(img)).save("spectrogram.png")

Flag: miniL{Yur1_1s_JusT1c3!!F0ll0w_Ch0u-K@guy@-h1me!th@nks_m03w}


As the birds say#

Wabun Code + Morse Code describing a flag. Decode the content and reconstruct it.

Step 1#

Observing the audio, there are 3 distinct sounds. Label them 0, 1, 2 in order of first appearance. No consecutive 2-2 pairs appear, suggesting 2 is a separator. Combined with varying intervals, this is likely Morse Code.

Step 2#

Since the three sounds have different lengths, have an AI write a simple length enumeration + greedy matcher to get the following sequence:

-.. --- .- ..-. .-.-.- --.. ... ...-.. -... ... -. -- .. -. .. .-.. -.. --- .-.--.. -... --.-... -..- --. .-.-.- .-. .-
-- ..- -... ..-. -..-- ..- .-.. .--. ---- .-.--.. .-.. ---- -..- --- .-.-- .- -..- ---.- .-.-.. -.-. .-.-.- --.. ... ...
-.. ..-- .-. .- -- ..- -... ... -. .-- .- -... ..- -. -.-. --- -.. . .. ... ... --- ..- ... . .-.. . ... ... -.. --- .-.
--.. ---.- .-.-.. -.-.- .-.-. .-.-.- -. .-.-. ----.. -... --.-- .-.-. -... .--.- ---.- ---- --.-- .-.--.. .--. .-. .-...
. --- .-.-- .- -..- ---.- .-.-.. -- .-.-. .-.-.- -.-.- .- --.-. -- ..-- -. .-.-. ----.. -... .-... .-... -..-. --.-... .
-.--.. -... --.-... -..- --. -..- ---.- .-.-.. ----.. .-.-.- --.. ... ...-.. ..-. -..-- ..- ..-- -.--- .--.- .--- --.--
.--. ..-.. -..- .--.- ...- -.-. .-.-.- .-... .--.- .--- .---... .-.- -.-. .-.-.- .- .--.- .--- -.-.- .-.-. -.-. .-.-.- -
-.-- .- .--- .- ..-. -.-. .-... -.-.. .-.. -.--- .-.-- ...- -... -.-.- .- .-.-.. ... -.

Note the several silent segments — these actually represent word boundaries in the flag content.

Morse code sequence visualization

Decoding with CyberChef From Morse Code yields:

CyberChef Morse decode result

Notice MINIL in the output — we’re on the right track. Also see WABUNCODEISUSELESS. Searching for WABUN CODE reveals that the garbled text is actually Wabun Code (Japanese Morse). The leading DO and trailing SN are the language-switching signals.

Step 3#

Using https://www.dcode.fr/wabun-code or asking an LLM, recover the original text:

“イチ、フラグハ [miniL] デハジマリ、ナイヨウハチュウカッコデカコマレテイマス。ニ、フラグノナイヨウハ [wabun code is so useless] デス。サン、タンゴハアンダースコアデツナガレテイマス。ヨン、サイショノタンゴハオオモジデハジマリマス。ゴ、フラグチュウノエーヲアットマークニ、オーヲゼロニ、イーヲサンニ、アイヲイチニオキカエテクダサイ。”

Translating to English:

  1. The flag begins with [miniL], and its content is enclosed in curly braces.
  2. The content of the flag is [wabun code is so useless].
  3. Words are connected by underscores.
  4. The first word begins with a capital letter.
  5. Please replace the ‘A’s in the flag with ’@’, the ‘O’s with ‘0’, the ‘E’s with ‘3’, and the ‘I’s with ‘1’.

Following these instructions step by step:

miniL{W@bun_c0d3_1s_s0_us3l3ss}
mini-LCTF 2026 Writeup (Part)
https://sandt3a.github.io/en/posts/miniL2026/mini-LCTF-2026-wp/
Author
Dorabit
Published at
2026-05-10
License
CC BY-NC-SA 4.0