# Introduction
Deck Decoder was a challenge from the 2024 edition of the Midnight Flag CTF. This particular challenge had something to do with your archives getting disorganized and you having to organize them again… Let’s dive in !
# The binary
Seems to be nothing special, at first glance we’re dealing with a stripped ELF64 binary, one of the sections seems to have high entropy but nothing too suspicious.
On launch, the code demands a PIN code. After the PIN code it asks for a string. Giving it just any random string will make it print either :(
or Uh oh ! stoping here...
.
Let’s have a look at the main
function in the disassembler.
At first we see the PIN code asked through the command-line (atoi(argv[1])
) cannot be bigger than 2000
. It is also used to initialize srand()
! Which means that any random functions will have their results pre-determined by our PIN code.
Let’s have a look at the sub_e681(&dta_14128, 0x8e7)
call. I’ve renamed some functions for better readability
Have a look at the sub_e5ef()
and sub_e369()
calls before I explain.
sub_e369()
is a swap_two_ints
in a given array function and sub_e5ef()
is a random_swap
which chooses a random index to swap the buffer in.
This sub_e5ef
function is the Fisher-Yates algorithm, which is a fancy way of saying it picks a random index from the given buffer
and uses it to constitute buffer2
, a shuffled array. Here it is with vars renamed.
The result of this fy_shuffle
function is then used in sub_e3c1
.
Simply put, this function encrypts a buffer using RC4 with the key in data_14100
.
What’s really interesting is the next call. to sub_e8ef
, which takes this encrypted buffer (rax_13
) as argument.
Which then sets RWX
permissions on this decrypted section in memory !
# Summary so far
Our PIN code is used to seed srand
. rand
is then used in the fy_shuffle
function. The resulting buffer is used in rc4_encrypt
, which uses an RC4 key which we know to encrypt the buffer. Said buffer is then set to Read-Write-Execute
.
The program then asks the user for a 30 character string (probably our flag). And the input is then passed to the function I’ve named validate_string
in the screenshot.
Let’s have a look at this function.
We see decrypted_fn
will act as a sort of validator. We can see that the loop will iterate 30 times, if we recode the small loop that forms the 6 characters, we can start to see a pattern.
def build_var28s(input_str):
all_vars = []
for i in range(input_len - 1):
var_28 = 0
for j in range(0, 7, 2):
var_28 |= (ord(input_str[i]) << (j * 8)) | (ord(input_str[i + 1]) << ((j + 1) * 8))
all_vars.append(hex(var_28))
return all_vars
print(build_var28s("abcdefghijklmn"))
$ python3 solve.py
['0x6261626162616261', '0x6362636263626362', '0x6463646364636463', '0x6564656465646564', '0x6665666566656665', '0x6766676667666766', '0x6867686768676867', '0x6968696869686968', '0x6a696a696a696a69', '0x6b6a6b6a6b6a6b6a', '0x6c6b6c6b6c6b6c6b', '0x6d6c6d6c6d6c6d6c', '0x6e6d6e6d6e6d6e6d']
As we can see, the 6 characters strings follow the pattern babababa
, then cbcbcbcb
and so on… This will be important later.
But first, we need to find the correct PIN to obtain decrypted_fn
.
# Solve - Find the PIN
However, before diving into the string validation function, we have to find the correct PIN code, which will correctly decrypt the function.
First we start by patching usleep()
to with nops
, as it would hinder any bruteforcing efforts.
Here there were two ways to find this PIN, I first tried recoding the fy_shuffle
and rc4_encrypt
, and then measured the entropy of the decrypted blobs, however nothing stood out to me. The challenge creator, prince2lu
, pointed out in his writeup (posted on the CTF’s Discord Channel) that this was indeed the expected solution.
I made a bash
script that tested each key one by one. If we printed out “:(”, it meant we had a valid function that was used in validate_string
. If there was a SIGSEGV
or a Uh oh, stoping here...
. We didn’t have valid opcodes.
#!/bin/bash
for i in $(seq 0 2000); do
echo "arg: $i"
echo "abcdefghijklmnopqrstuvwxyz012" | timeout 2s ./deck_decoder_patched $i 2>&1
done
Using this, we get 12 potentially valid opcodes, and after a little bit of dynamic verification, we find that 1203
is the correct PIN.
# Solve - Find the Flag
On the disassembler’s debugger, we find that decrypted_fn
is quite beautiful.
This function took one int64 and did a series of xor|add|sub
on it and returned it.
I’m lazy and this was late at a night so I’ve decided to take the emulation route. I simply selected the function from top to bottom in the file’s Hex View and downloaded it as decrypted_func.sc
.
Remember how the 6 character strings inputted into this function are only made of 2 repeating characters ? This is good news for us. It means that for each validation call, there is only 95² (95 printable characters) 9025. Since it is done 30 times, there should only be 9025 * 30 = 270750
possibilities.
This means that by emulating the shellcode and bruteforcing the 6 characters string, and then checking it agains the data_14020
array, we should be able to get the flag.
I got to work on this miasm script.
from argparse import ArgumentParser
from itertools import product
from pdb import pm
from miasm.jitter.csts import PAGE_READ, PAGE_WRITE, PAGE_EXEC
from miasm.analysis.machine import Machine
from miasm.core.locationdb import LocationDB
def build_var_28(two_chars):
repeated_input = two_chars * 4
hex_val = int(repeated_input.encode('utf-8').hex(), 16)
return hex_val
data = [
0x751e22f6afdc1d1a, 0x25e2d8737e1ee778,
0x5fa5062a2a463e93, 0x31e402f96e8bdf97,
0x835be6989055c8f7, 0x5d96e686bbbb53fa,
0xece6ff58cdd9d31a, 0xc21a2cbc37dbc25f,
0x504e496158267321, 0x1e7f1d0186f1ec8c,
0x177a0713ee72684c, 0x701183528f5ac6cf,
0xb732a1afdc2f5679, 0x76c06994ce3a61bc,
0xf5f06ab9884de54f, 0x701183528f5ac6cf,
0x5a37adf157a8c201, 0xc094d5287d091c4f,
0xf5f06ab9884de54f, 0x3daa050c3b50450e,
0xa93c0af6a0e9c41c, 0xf5d4cbab3b2c88a3,
0xa27a01b298ec17a8, 0x5fcefddd776a26f0,
0xdebde5dbb1c1c4b3, 0x0e08c572f24190ab,
0x0d512d32829e4869, 0xffbd122f4a434777
]
def check_result(jitter):
jitter.running = False
jitter.pc = 0
if jitter.cpu.RAX in data:
return True
else:
return False
if __name__ == "__main__":
parser = ArgumentParser(description="x86 64 basic Jitter")
parser.add_argument("filename", help="x86 64 shellcode filename")
parser.add_argument("-j", "--jitter",
help="Jitter engine (default is 'gcc')",
default="gcc")
parser.add_argument("--verbose", "-v", action="store_true",
help="Verbose mode")
args = parser.parse_args()
loc_db = LocationDB()
jitter = Machine("x86_64").jitter(loc_db, args.jitter)
jitter.init_stack()
with open(args.filename, 'rb') as f:
sc = f.read()
run_addr = 0x40000000
jitter.vm.add_memory_page(run_addr, PAGE_READ | PAGE_WRITE, sc)
if args.verbose:
jitter.set_trace_log()
jitter.add_breakpoint(0x4000237F, check_result)
charset = ''.join(chr(i) for i in range(32, 127)) # space) to tilde, printable
matches = {d: "" for d in data}
for first, second in product(charset, repeat=2):
var_28 = build_var_28(first + second)
jitter.cpu.RDI = int(var_28)
jitter.run(run_addr)
if jitter.cpu.RAX in data:
matches[jitter.cpu.RAX] = second+first
print(f"Match found for input '{first + second}': {hex(jitter.cpu.RAX)}")
flag = ''.join(matches[d] for d in data if matches[d])
print(flag)
The script takes around 5 seconds to run and we obtain the flag:
MCTF{ShUff13_th3_sh3LLc0de!!}
.