$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: A patched Doom RNG hides 256 secret bits in its lookup table, while a gameplay video exposes two screen wipes. Solution: Fit each wipe's paired-pixel columns to the Doom melt algorithm, merge recovered RNG residues, and verify the one ambiguous bit against a pristine patched Git blob.
watch how I rip and tear through E1M1
The supplied Chocolate Doom patch replaces the vanilla RNG table's low bits with the bits of a 32-byte secret. A gameplay video is the observable oracle; recover the secret from the game behavior without treating the short hash in the patch as a standalone cracking target.
The patch initializes rndtable[i] ^= (secret[i // 8] >> (7 - i % 8)) & 1 for all 256 entries. M_Random() advances its index before reading the table. In src/doom/f_wipe.c, wipe_initMelt() makes one M_Random() % 16 call for the first column and then one M_Random() % 3 call for each of 159 successive paired-pixel columns. Each latter value produces an initial vertical step of -1, 0, or 1, clipped at the source's -15 and 0 limits. The new image descends over a displaced copy of the old image; the descent is governed by wipe_doMelt() and its per-tick advances 1, 2, 4, 8, 16, 32, 8, ... (cumulative advances 0, 1, 3, 7, 15, 31, 39, ...).
Thus a recovered step determines an RNG residue, and the vanilla table determines whether that table entry was flipped: compare observed % 3 with vanilla[index] % 3. Flipping one low bit changes an integer by one, so the two residues cannot coincide. At a clipped boundary, keep both admissible residues instead of guessing a bit.
mz/f_008.png as the genuinely pristine old screen and mz/f_045.png as the complete new screen; mz/f_012.png is already melting. Crop the actual game rectangle (281, 30, 1638, 1048) and resample it to 320×200. For the second wipe (200.8–201.8 s), use second_melt/old_ref.png and second_melt/new_ref.png, and crop (282, 31, 1638, 1049).frame[:y] = new[:y], frame[y:] = old[:height-y]. For the first wipe, fit the per-column initial delays across multiple frames using the source's tick-advancement curve, not one screenshot. decode_first.py and fit_walk_first.py produce first_delays.npy. Its 160-column walk has no illegal jumps. Comparing the known format prefix to the vanilla table gives first-call index 144 (48/48 comparable bits); the 159 subsequent columns correspond to indices 145…255, 0…47.second_melt/measure_columns.py recovers 160 column positions; at frame 6037 their differences are exactly -8, 0, +8 screen pixels. second_melt/infer_offset.py tests all 256 first-call indices and the five feasible vertical baselines, including both clipping limits and the first % 16 call. Only first-call index 21, maximum initial position -4, has zero contradictions. Subsequent columns reveal indices 22…180; 150 yield unique bits and nine remain clipping-ambiguous. The individual residues are in second_melt/column_rng_residues.csv.src/doom/m_random.c and testing the seven-hex-digit prefix a7fb16e printed in the organizer patch. Reapply that patch to clean, unmodified repository source: the instrumented choc-patch/src/doom/m_random.c has extra debugging code and hashes to the wrong blob. The prefix is only a final two-candidate discriminator, not proof of uniqueness over an unconstrained 256-bit search space.The following script uses the extracted-column artifacts and the organizer's unchanged source and patch. It never prints or persists the recovered secret; it only verifies that one candidate matches the public hash prefix.
#!/usr/bin/env python3 """Run in the task directory after the frame-fitting scripts have generated their artifacts.""" import csv import hashlib import subprocess import tempfile from pathlib import Path import numpy as np root = Path(__file__).resolve().parent vanilla = [int(v) for v in (root / "vanilla.txt").read_text().split(",")] assert len(vanilla) == 256 bits = [-1] * 256 def record(index, value): assert value in (0, 1) if bits[index] >= 0: assert bits[index] == value, f"contradictory table index {index}" bits[index] = value # Only organizer-provided format bytes are used as known plaintext. for start, known in ((0, b"FortID{"), (248, b"}")): for n, byte in enumerate(known): for shift in range(8): record(start + 8 * n + shift, (byte >> (7 - shift)) & 1) delays = np.load(root / "first_delays.npy") assert len(delays) == 160 for column in range(1, 160): index = (144 + column) % 256 step = int(delays[column] - delays[column - 1]) assert step in (-1, 0, 1) observed_mod3 = 1 - step record(index, int(observed_mod3 != vanilla[index] % 3)) # Initial first-wipe delay 3 discriminates the two %16 values at index 144. record(144, next(b for b in (0, 1) if (vanilla[144] ^ b) % 16 == 3)) with (root / "second_melt/column_rng_residues.csv").open() as stream: for row in csv.DictReader(stream): if row["deduced_xor_bit"] != "": record(int(row["rndtable_index"]), int(row["deduced_xor_bit"])) missing = [index for index, bit in enumerate(bits) if bit == -1] assert len(missing) == 1 # Apply the organizer patch to CLEAN source files only. The resulting template # retains the organizer's redaction marker; candidates exist only in memory. with tempfile.TemporaryDirectory(dir=root, prefix="clean_patch_") as tmp: directory = Path(tmp) target = directory / "src/doom" target.mkdir(parents=True) for name in ("m_random.c", "m_random.h"): (target / name).write_bytes((root / "chocolate-doom/src/doom" / name).read_bytes()) subprocess.run(["patch", "-p1", "--batch"], cwd=directory, input=(root / "chocolate-doom.patch").read_bytes(), stdout=subprocess.DEVNULL, check=True) pristine = (target / "m_random.c").read_bytes() marker = b"FortID{<REDACTED>}" assert pristine.count(marker) == 1 matches = 0 for bit in (0, 1): candidate_bits = bits.copy() candidate_bits[missing[0]] = bit candidate = np.packbits(np.asarray(candidate_bits, dtype=np.uint8)).tobytes() assert len(candidate) == 32 source = pristine.replace(marker, candidate) blob = b"blob " + str(len(source)).encode() + b"\0" + source matches += hashlib.sha1(blob).hexdigest().startswith("a7fb16e") assert matches == 1 print("Recovered candidate uniquely matches the organizer's blob prefix; secret redacted.")
Use this technique when:
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar