2020: rgbctf: lych king
This commit is contained in:
parent
9c73ebae99
commit
ea86d2a87e
Binary file not shown.
|
@ -0,0 +1,140 @@
|
||||||
|
# THE LYCH KING
|
||||||
|
|
||||||
|
writeup by [haskal](https://awoo.systems) for [BLÅHAJ](https://blahaj.awoo.systems)
|
||||||
|
|
||||||
|
**Pwn/Rev**
|
||||||
|
**498 points**
|
||||||
|
**7 solves**
|
||||||
|
|
||||||
|
>and the bad seeds
|
||||||
|
>
|
||||||
|
>The binary was changed *ever so slightly* after the cipher text was generated
|
||||||
|
|
||||||
|
provided file: <LYCH_KING.zip>
|
||||||
|
|
||||||
|
## writeup
|
||||||
|
|
||||||
|
we immediately find out this is a Haskell binary (strings, or run it with no input to get a Haskell
|
||||||
|
error print). yikes, i've never done Haskell reversing before. however, there is one main resource
|
||||||
|
on this i found, a haskell decompiler with a writeup from the author on reversing for a previous
|
||||||
|
CTF's Haskell challenge
|
||||||
|
|
||||||
|
<https://github.com/gereeter/hsdecomp>
|
||||||
|
<https://sctf.ehsandev.com/reversing/lambda1.html>
|
||||||
|
|
||||||
|
note that the writeup is for 32-bit. this is a 64-bit binary so the ABI is slightly different than
|
||||||
|
what's in the writeup, but it is handled by hsdecomp. however, running hsdecomp (even with error
|
||||||
|
code patched out) results in a partial decompilation with several error points
|
||||||
|
|
||||||
|
```
|
||||||
|
Main_main_closure = >>= $fMonadIO
|
||||||
|
getArgs
|
||||||
|
(\s3mc_info_arg_0 ->
|
||||||
|
$
|
||||||
|
putStrLn
|
||||||
|
((\Main_a_info_arg_0 ->
|
||||||
|
case == r3jo_info Main_a_info_arg_0 [] of
|
||||||
|
False -> zipWith (on !!ERROR!! ord)
|
||||||
|
Main_a_info_arg_0
|
||||||
|
((\Main_g_info_arg_0 Main_g_info_arg_1 ->
|
||||||
|
case == r3jo_info Main_g_info_arg_0 [] of
|
||||||
|
False -> take
|
||||||
|
(length $fFoldable[] Main_g_info_arg_0)
|
||||||
|
(intercalate
|
||||||
|
[]
|
||||||
|
(map
|
||||||
|
!!ERROR!!
|
||||||
|
((\Main_v_info_arg_0 Main_v_info_arg_1 Main_v_info_arg_2 ->
|
||||||
|
case == $fEqInteger Main_v_info_arg_0 (Main_r_info Main_v_info_arg_0) of
|
||||||
|
False -> case >= $fOrdInteger Main_v_info_arg_2 (toInteger $fIntegralInt Main_v_info_arg_1) of
|
||||||
|
False -> : Main_v_info_arg_0 !!ERROR!!,
|
||||||
|
False -> [],
|
||||||
|
False -> []
|
||||||
|
)
|
||||||
|
Main_g_info_arg_1
|
||||||
|
(length $fFoldable[] Main_g_info_arg_0)
|
||||||
|
(S# 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
False -> []
|
||||||
|
)
|
||||||
|
Main_a_info_arg_0
|
||||||
|
(S# 1997)
|
||||||
|
),
|
||||||
|
False -> []
|
||||||
|
)
|
||||||
|
(head s3mc_info_arg_0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r3jo_info = $fEq[] $fEqChar
|
||||||
|
|
||||||
|
Main_r_info = \Main_r_info_arg_0 ->
|
||||||
|
case == $fEqInteger Main_r_info_arg_0 (S# 0) of
|
||||||
|
False -> + $fNumInteger (* $fNumInteger (mod $fIntegralInteger Main_r_info_arg_0 (S# 10)) (^ $fNumInteger $fIntegralInteger (S# 10) (- $fNumInteger (Main_mag_info Main_r_info_arg_0) (S# 1)))) (Main_r_info (div $fIntegralInteger Main_r_info_arg_0 (S# 10))),
|
||||||
|
False -> S# 0
|
||||||
|
|
||||||
|
Main_mag_info = \Main_mag_info_arg_0 ->
|
||||||
|
case == $fEqInteger Main_mag_info_arg_0 (S# 0) of
|
||||||
|
False -> case > $fOrdInteger Main_mag_info_arg_0 (S# 0) of
|
||||||
|
False -> case < $fOrdInteger Main_mag_info_arg_0 (S# 0) of
|
||||||
|
False -> patError 4871050,
|
||||||
|
False -> negate $fNumInteger (S# 1),
|
||||||
|
False -> + $fNumInteger (S# 1) (Main_mag_info (div $fIntegralInteger Main_mag_info_arg_0 (S# 10))),
|
||||||
|
False -> S# 0
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
by manually reversing i found the error point in zipwith is just something along the lines of
|
||||||
|
composing xor and integer bits, so this operation is computing the XOR of the input. the error point
|
||||||
|
in map is just calling Show on each integer, so the integers getting generated by Main_v_info are
|
||||||
|
converted into strings for XOR. now the issue is the last error point which is generating the
|
||||||
|
integers. we can see it stops when Main_r_info on the first arg is equal to the arg. examining
|
||||||
|
Main_r_info shows that it reverses the digits of a given integer. thus, integer generation stops
|
||||||
|
when there's a palindrome, and otherwise it generates a list of integers transformed by some
|
||||||
|
erroring closure. the initial integer is 1997. i spent a lot of time trying to reverse the last
|
||||||
|
error point and i didn't end up figuring out what it's doing besides something with Main_mag_info
|
||||||
|
(which computes log10 of its argument).
|
||||||
|
|
||||||
|
you can dump the integer stream it's using by calling the binary with a known string and then
|
||||||
|
computing the XOR of the output with the input to recover the key. by default we see it starts with
|
||||||
|
1997 and then a stream of numbers i was ultimately unable to reverse engineer.
|
||||||
|
|
||||||
|
so i gave up and switched to fuzzing. the flavor text says the binary was slightly changed, so i
|
||||||
|
guessed the initial argument of 1997 was changed to something else. by cribdragging the ciphertext
|
||||||
|
(looking for any points in the stream where XOR with any digits 0-9 can produce `rgbctf{`) i found
|
||||||
|
exactly one such offset -- 152. then i created a script to patch the binary for 1997, the exact
|
||||||
|
instruction that loads it can be found in `s3m8_info` at address `0x407c57`. i found this by simply
|
||||||
|
searching the memory in ghidra for 1997. this corresponds to a file offset of `0x7c5b:0x7c5f`. then
|
||||||
|
i tried numbers in order until the pad contained the right numbers to produce `rgbctf{` at offset
|
||||||
|
152.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run_patch(i):
|
||||||
|
with open("lich", "rb") as f:
|
||||||
|
d = bytearray(f.read())
|
||||||
|
d[0x7c5b:0x7c5f] = struct.pack("<I", i)
|
||||||
|
with open("/tmp/lich", "wb") as f2:
|
||||||
|
f2.write(d)
|
||||||
|
|
||||||
|
return try_inp("a"*len(cipher))
|
||||||
|
|
||||||
|
for seed in range(100000):
|
||||||
|
res = run_patch(seed)
|
||||||
|
if len(res) >= len(cipher) and res[152:152+7] == b"9289134":
|
||||||
|
print(seed)
|
||||||
|
print(xor(res, cipher))
|
||||||
|
```
|
||||||
|
|
||||||
|
this actually completes in seconds, and found the correct decryption. there are multiple seeds that
|
||||||
|
work, but the "correct" one is intended to be `1585` as far as i can tell. the result is:
|
||||||
|
|
||||||
|
```
|
||||||
|
Kil'jaeden created the Lich King ages ago from the spirit of the orc shaman Ner'zhul to raise an undead army to conquer Azeroth for the Burning Legion. rgbctf{the flag is just rgb lol} Initially trapped within the Frozen Throne with Frostmourne, the Lich King eventually betrayed Kil'jaeden and merged with the human Arthas Menethil. When Frostmourne was destroyed and Arthas perished, Bolvar Fordragon became the new Lich King, imprisoning the master of the Scourge within the Frozen Throne once more in order to protect the world from future threats.:
|
||||||
|
```
|
||||||
|
|
||||||
|
## addendum
|
||||||
|
|
||||||
|
i'm sad i never got to completely figure out how haskell reversing works, and in retrospect should
|
||||||
|
have tried fuzzing _before_ i sunk like 4 hours total into kinda fruitless RE. if someone could fix
|
||||||
|
up hsdecomp so it works on this i would be very happy.
|
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
def xor(a, b):
|
||||||
|
c = bytearray()
|
||||||
|
for i in range(min(len(a), len(b))):
|
||||||
|
c.append(a[i]^b[i])
|
||||||
|
return bytes(c)
|
||||||
|
|
||||||
|
with open("cipher", "rb") as f:
|
||||||
|
cipher = f.read()
|
||||||
|
|
||||||
|
def run_lych(inp):
|
||||||
|
p = subprocess.Popen(["/tmp/lich", inp], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
so, se = p.communicate()
|
||||||
|
return so.strip()
|
||||||
|
|
||||||
|
def try_inp(inp):
|
||||||
|
return xor(inp.encode(), run_lych(inp))
|
||||||
|
|
||||||
|
def run_patch(i):
|
||||||
|
with open("lich", "rb") as f:
|
||||||
|
d = bytearray(f.read())
|
||||||
|
d[0x7c5b:0x7c5f] = struct.pack("<I", i)
|
||||||
|
with open("/tmp/lich", "wb") as f2:
|
||||||
|
f2.write(d)
|
||||||
|
|
||||||
|
return try_inp("a"*len(cipher))
|
||||||
|
|
||||||
|
print(run_patch(1997))
|
||||||
|
|
||||||
|
crib = xor(cipher[152:152+7], b"rgbctf{")
|
||||||
|
print(crib)
|
||||||
|
|
||||||
|
import time
|
||||||
|
s = time.time()
|
||||||
|
for seed in range(100000):
|
||||||
|
if time.time() - s > 5:
|
||||||
|
s = time.time()
|
||||||
|
print(seed)
|
||||||
|
res = run_patch(seed)
|
||||||
|
if len(res) >= len(cipher) and res[152:152+7] == b"9289134":
|
||||||
|
print(seed)
|
||||||
|
print(xor(res, cipher))
|
Loading…
Reference in New Issue