C:\CTF\K17\EVILGRAM.EXE _□X

EVILGRAM

rev

Challenge

A page renders a Plotly 3D animation — a voxel cellular automaton stepping through 128 frames inside a 4×4×4 world. The flag isn't drawn anywhere; it's encoded in which update rule the automaton is running.

Extracting the animation data

Plotly embeds the whole animation as a literal Plotly.addFrames(...) call in the page's inline script. Regex out that JSON array directly from the HTML rather than trying to drive a browser:

m = re.search(r"Plotly\.addFrames\('[^']*',\s*(\[.*\])\);\s*$", data.strip())
frames = json.loads(m.group(1))

Recovering the automaton's ruleset

Each frame's scatter points give the active voxel coordinates, so every frame can be rasterized into a 4×4×4 occupancy grid. A cellular automaton like this typically updates every cell from an 8-neighbor "block" (a 2×2×2 corner cube, Margolus-style), so each observed (before, after) frame pair yields many samples of blockState → newState for whichever offset parity that step used:

def calcBlockState(m, x, y, z):
    return int(
        m[z][y][x] + m[z][y][x+1]*2 + m[z][y+1][x]*4 + m[z][y+1][x+1]*8 +
        m[z+1][y][x]*16 + m[z+1][y][x+1]*32 + m[z+1][y+1][x]*64 + m[z+1][y+1][x+1]*128
    )  # (mod-wrapped indices omitted for brevity)

Walking all 128 frame transitions across every block-parity offset fills in all 256 possible blockState → newState entries with zero conflicts — confirming the automaton's ruleset really is a full lookup table, and (crucially) that the 256 output values form a permutation of 0..255.

The ruleset is the message

A ruleset that's specifically a permutation of 256 values is exactly what you get from applying a Fisher–Yates-style shuffle to range(256) seeded by a single large number — i.e. encoding an arbitrary integer as a permutation via its factorial-base ("factoradic") digits. That's invertible: replay the shuffle's removals to recover each step's chosen index, reassemble the factoradic digits, and recombine them back into the original integer:

l = list(range(256))
factoradic_rev = []
for j in range(256):
    idx = l.index(ruleset[j])
    factoradic_rev.append(idx)
    l.pop(idx)

F = list(reversed(factoradic_rev))
acc = 0
for i in range(255, -1, -1):
    acc = acc * (i + 1) + F[i]

msg_bytes = acc.to_bytes((acc.bit_length() + 7) // 8, 'big')

Decoding those bytes as UTF-8 recovers the flag — hidden not in the animation's visuals, but in the exact permutation its update rule turned out to be.