Logo

Writeup

srdnlenCTF 2026 The Legendary Armory (Chapter II) — Writeup

M Mohammed-Amine Bouaafia
March 1, 2026
6 min read
forensics memory-dump minidump 86box ramdrive xor zzt

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 RAM
emu_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 line device=C:\WINDOWS\RAMDRIVE.SYS 4096 /E
  • Explorer window for the C: drive
  • MSPaint with armory.bmp open
  • 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:

StructureGuest OffsetSize
Boot sector0x172000512 B (OEM: RDV 1.20)
FAT120x1722006 sectors (3072 B)
Root directory0x172E004 sectors (64 entries)
Data area0x173600Clusters 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 2
k_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 decrypt
result = 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 board
for 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 MinidumpFile
from zipfile import ZipFile
from io import BytesIO
import 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 = 0x172200
ROOTDIR = 0x172E00
DATA_START = 0x173600
CLUSTER_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 1
offset = 0x200
for _ 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 = [], 53
while 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!}