Featured image of post MidnightCTF 2024 - Deck Decoder

MidnightCTF 2024 - Deck Decoder

# 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.

b20080966e6c0197d4244b86f314e433.png

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.

abbb7f77e9e26af81fdd6282896cd1a1.png

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

4d9c69bb0a0403416312eafc6ffa2083.png

Have a look at the sub_e5ef() and sub_e369() calls before I explain.

f0218c92d7cc3e24f3fc7d6b860c3776.png

2b06c4be118449f60e7f3dbddf060ec0.png

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.

6641a8c51342eb4a1273d1e3eb803f72.png

The result of this fy_shuffle function is then used in sub_e3c1.

468fcbcb78d12c0bbbf2e2adf7982f8a.png

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.

6e500c07ca5ac683be8a0ec25ef49146.png 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.

3637970e6316132308cd3312920c3bf3.png

Let’s have a look at this function.

d158017871ad330dc16c0841a194cde7.png

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.

110706afe840e0c3f583b3ae54c309e4.png

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:

e3abfdb7ed2ad55a1d0e2a462797262c.png

MCTF{ShUff13_th3_sh3LLc0de!!}.

Built with coffee holding the wheels and nicotine working the pedals. And Hugo.
Theme Stack designed by Jimmy.