Writeup
srdnlenCTF 2026 The Legendary Armory (Chapter II) — Writeup
srdnlenCTF The Legendary Armory (Chapter II) — Writeup
Compact writeup for a forensics challenge (srdnlenCTF: The Trilogy of Death — Chapter II) involving a Windows process minidump of the 86Box PC emulator. The emulated Windows 98 machine uses a volatile RAM drive to hide XOR-encrypted data containing a classic ZZT game world with the flag embedded as invisible tiles.
Summary
The challenge provides a 1.4 GB Windows minidump (chall.dmp) of an 86Box.exe process — a retro PC emulator running a Windows 98 guest. The guest’s CONFIG.SYS loads RAMDRIVE.SYS 4096 /E, creating a 4 MB RAM drive (D:) that exists only in volatile memory. This RAM drive contains two files: K (an 8-byte XOR key) and T (176 KB of encrypted data). XOR-decrypting T with K yields a ZIP archive of ZZT (Epic MegaGames, 1991). Inside the modified TOWN.ZZT game world, the Armory board has the flag spelled out using black-on-black text tiles — invisible during normal gameplay but readable by parsing the board’s tile data.
Step 1 — Identify the Dump
Standard tools (Volatility, WinDbg) fail because this is a process minidump, not a full memory image. The Python minidump library parses it successfully:
from minidump.minidumpfile import MinidumpFile
mf = MinidumpFile.parse("chall.dmp")reader = mf.get_reader()The process is 86Box.exe — a cycle-accurate x86 PC emulator. Among the 141 memory segments, a contiguous 128 MB region at host VA 0x1540b1a0000 is the emulated machine’s physical RAM.
# Dump the emulated guest RAMemu_ram = reader.read(0x1540b1a0000, 134_221_824)open('/tmp/emu_ram.bin', 'wb').write(emu_ram)Step 2 — Recover the Screen
86Box stores its rendered output in a buffer32 framebuffer (32bpp BGRA, 2048-pixel stride). Three identical 16 MB segments contain this buffer. The visible 1024×768 display is split across buffer columns 1040–2047 and 0–15 (a horizontal wrap):
from PIL import Image
img = Image.new('RGB', (1024, 768))for y in range(768): for sx in range(1024): bx = (sx + 1040) % 2048 off = y * 8192 + bx * 4 b, g, r = data[off], data[off+1], data[off+2] img.putpixel((sx, y), (r, g, b))The reconstructed screen reveals a Windows 98 desktop with:
- Notepad showing
CONFIG.SYS— critically, the linedevice=C:\WINDOWS\RAMDRIVE.SYS 4096 /E - Explorer window for the C: drive
- MSPaint with
armory.bmpopen - Taskbar showing Ms-ramdrive (D:) — the volatile RAM drive
Step 3 — Find the RAM Drive
RAMDRIVE.SYS creates a FAT12 filesystem entirely in extended memory (XMS). Searching the guest RAM for the volume label MS-RAMDRIVE locates two cached copies of the root directory:
Offset 0x172E00: "MS-RAMDRIVE" (volume label, attr=0x08) "K" (8 bytes, cluster 2, read-only) "T" (176,578 bytes, cluster 3, read-only)Two files — K and T — the “two relics” from the challenge poem.
Working backward from the root directory, the full RAMDRIVE layout is:
| Structure | Guest Offset | Size |
|---|---|---|
| Boot sector | 0x172000 | 512 B (OEM: RDV 1.20) |
| FAT12 | 0x172200 | 6 sectors (3072 B) |
| Root directory | 0x172E00 | 4 sectors (64 entries) |
| Data area | 0x173600 | Clusters start here |
Step 4 — Extract and XOR
File K is the 8-byte XOR key. File T is the encrypted payload:
# K: 8-byte XOR key at cluster 2k_data = emu_ram[0x173600:0x173600 + 8]# -> f4 14 a5 31 17 02 0b 84
# T: 176,578 bytes starting at cluster 3 (follow FAT12 chain)t_data = read_fat12_chain(emu_ram, cluster=3, size=176578)
# XOR decryptresult = bytes(t_data[i] ^ k_data[i % 8] for i in range(len(t_data)))# -> PK\x03\x04... (ZIP archive!)The decrypted output is a ZIP file containing a full distribution of ZZT (Epic MegaGames, 1991) — a classic DOS game. Most files have a timestamp of 2026-02-21, but TOWN.ZZT was modified on 2026-02-25, marking it as the tampered file.
Step 5 — Parse the ZZT Board
ZZT stores game worlds as a sequence of boards, each containing a 60×25 grid of RLE-encoded tiles. Each tile has an element type and a color byte. For “text” elements (types 45–51), the color byte is the ASCII character displayed. Element type 53 is black text — invisible against ZZT’s default black background.
Parsing Board 2 (“Armory”) from TOWN.ZZT:
# Parse RLE tiles for the Armory boardfor y in range(25): for x in range(60): elem, color = tiles[y * 60 + x] if elem == 53: # Black text (invisible in-game) print(chr(color), end='')Row 1, columns 10–53 spell out:
srdnlen{rdvr4md1sk_h1d3s_th3_s3cret_4rmory!}The flag is rendered as black text on a black background — completely invisible during normal ZZT gameplay, but present in the tile data. On the board it appears as a row of ? characters in the ASCII rendering (since black text tiles show their character only when the color contrast allows it), perfectly camouflaged among the Armory’s shop layout.
Solve Script
from minidump.minidumpfile import MinidumpFilefrom zipfile import ZipFilefrom io import BytesIOimport struct
# --- Parse minidump and extract guest RAM ---mf = MinidumpFile.parse("chall.dmp")reader = mf.get_reader()emu_ram = reader.read(0x1540b1a0000, 134_221_824)
# --- RAMDRIVE layout ---FAT_OFF = 0x172200ROOTDIR = 0x172E00DATA_START = 0x173600CLUSTER_SZ = 2048 # 4 sectors × 512 bytes
# --- Read FAT12 entry ---fat = emu_ram[FAT_OFF:FAT_OFF + 3072]
def fat12_entry(n): off = (n * 3) // 2 if n % 2 == 0: return fat[off] | ((fat[off + 1] & 0x0F) << 8) else: return (fat[off] >> 4) | (fat[off + 1] << 4)
# --- Follow cluster chain ---def read_chain(start_cluster, size): buf, c = bytearray(), start_cluster while c < 0xFF8: off = DATA_START + (c - 2) * CLUSTER_SZ buf.extend(emu_ram[off:off + CLUSTER_SZ]) c = fat12_entry(c) return bytes(buf[:size])
# --- Extract K (8-byte key, cluster 2) and T (encrypted, cluster 3) ---K = emu_ram[DATA_START:DATA_START + 8]T = read_chain(3, 176578)
# --- XOR decrypt ---decrypted = bytes(T[i] ^ K[i % 8] for i in range(len(T)))
# --- Extract TOWN.ZZT from ZIP ---with ZipFile(BytesIO(decrypted)) as zf: town = zf.read("TOWN.ZZT")
# --- Parse Board 2 ("Armory") ---# Skip header (0x200), then boards 0 and 1offset = 0x200for _ in range(2): board_size = struct.unpack_from('<H', town, offset)[0] offset += board_size + 2
board_size = struct.unpack_from('<H', town, offset)[0]board = town[offset:offset + board_size + 2]
# Decode RLE tiles (start at byte 53 within board)tiles, pos = [], 53while len(tiles) < 1500: count = board[pos] or 256 elem, color = board[pos + 1], board[pos + 2] tiles.extend([(elem, color)] * count) pos += 3
# --- Read black text tiles (element 53) from row 1 ---flag = ''.join( chr(tiles[1 * 60 + x][1]) for x in range(60) if tiles[1 * 60 + x][0] == 53)print(flag)# srdnlen{rdvr4md1sk_h1d3s_th3_s3cret_4rmory!}