monoid
- Category: rev
“There’s this really cool fractal I like, but someone encrypted its parameters, even worse they wrote it in haskell, find the parameters to get the flag; glhf”
No binary ships with this one. The handout is a GHC -ddump-simpl / -ddump-asm pair plus the program’s own output, so the reversing target is Core — Haskell with the sugar stripped out and every name fully qualified, which is far more readable than the “even worse they wrote it in haskell” framing suggests.
Solution:
1. Three modules, and only one matters
$ grep -n "Compiling" Main.dump-simpl
1:[1 of 4] Compiling Decode ( Decode.hs, Decode.o )
242:[2 of 4] Compiling Fractal ( Fractal.hs, Fractal.o )
489:[3 of 4] Compiling Main ( Main.hs, Main.o )
Fractal is just an ASCII Julia renderer over the palette " .-:=+*#%@"; out.txt is its output. The parameters come out of Decode, driven from Main.
2. Decode is a repeating-key XOR with a shift
Three definitions, verbose but mechanical once the qualified names are dropped:
xorChar a b = chr (ord a `xor` ord b)
unsubsChar a = chr ((ord a - 67) `mod` 128)
decipher ct key
| null key = error "boop"
| otherwise = zipWith (\c k -> unsubsChar (xorChar k c)) ct (cycle key)
fromHex is the obvious pairwise readHex, with error "thats not how to fill a burito !" on odd length. So plain[i] = ((ct[i] XOR key[i mod len]) - 67) mod 128 — no key recovery needed, the key is a literal.
3. The flag is the argument Main throws away
main deciphers a blob, words it, and destructures the result:
main = writeFile "out.txt" $
(\(a,b,c,d,e) -> renderJulia a b c d e) $
case words (decipher (fromHex "2d5509...0f56") "#!s3kur1ty") of
(_ : real : imag : max_iter : width : height : []) ->
(read real, read imag, read max_iter, read width, read height)
_ -> error ""
Core spells the discard out explicitly — the head binder is marked dead and never referenced again:
case words (decipher ...) of {
[] -> jump fail_d2nY GHC.Internal.Types.(##);
: _ [Occ=Dead] ds_d2nQ -> -- <- word 0 dropped here
Six words go in, five are used. The one that is pattern-matched into _ [Occ=Dead] is the flag; the encrypted blob was never about hiding the parameters at all.
4. Decrypt
pt = "".join(chr(((c ^ KEY[i % len(KEY)]) - 67) % 128) for i, c in enumerate(CT))
flag, real, imag, max_iter, width, height = pt.split()
$ ./solve.py
FLAG: K17{a_M0NaD_1s_4_M0n0Id_1n_th3_c4t3gORy_0f_3Nd0FuNC70r5}
params: c = -0.745643887 + 0.113825904i, maxIter=180, 96x32
out.txt reproduced exactly: True
The last line is the check that the plaintext is really the program’s input and not just something flag-shaped: out.txt is 32 lines of 96 characters, matching width/height, and re-running renderJulia with the recovered constants reproduces it byte for byte.
-- Fractal.renderJulia, reassembled from Core
zx = -1.8 + 3.6 * fromIntegral x / fromIntegral (width - 1)
zy = (-1.0 + 2.0 * fromIntegral y / fromIntegral (height - 1)) * 0.5
go n z | magnitude z > 2.0 = n
| n >= maxIter = n
| otherwise = go (n+1) (z*z + c)
palette !! min (length palette - 1) (go 0 (zx :+ zy) * length palette `div` (maxIter + 1))
Full script: solve.py
Flag: K17{a_M0NaD_1s_4_M0n0Id_1n_th3_c4t3gORy_0f_3Nd0FuNC70r5}
Takeaways
- A GHC Core dump is a better handout than a stripped binary. Every top-level name survives, string literals appear verbatim as
unpackCString# "..."#, and the-ddump-asmfile is never needed. [Occ=Dead]on a pattern binder is the Core equivalent of a dead store, and it is worth grepping for directly. Here it marks the one field of the decrypted record that the program computes and then deliberately ignores — which is exactly where the flag was put.