CorCTF 2022

Posted on Aug 5, 2022

Table of Contents

  1. Hackermans Dungeon
    1. Reversing
    2. Obtaining the flag
  2. Bogus
    1. VPERMD
    2. VPUNPCKLDQ
    3. VPUNPCKHDQ
    4. VPCMPGTD
    5. Putting it all together
    6. Solution

Hackermans Dungeon (8 solves)

Hackerman told us he cannot be hacked. Can you hack hackerman?

It’s a normal AMD64 PE file with the purpose of checking user’s username and password.

hackermansdungeon

Loading it into IDA and messing around, we can spot some functions that look similar to this:

...
v28 = v25 + __ROL4__(v21 + (v25 & v20 | v26) + v18 - 40341101, 12);
v29 = v28 + __ROR4__(v22 + (v25 & v28 | v20 & ~v28) + v24, 15);
v30 = v29 + __ROR4__(v27 + (v29 & v28 | v25 & ~v29) + 1236535329 + v20, 10);
v31 = v30 + __ROL4__(a2[1] - 165796510 + (v30 & v28 | v29 & ~v28) + v25, 5);
v32 = v31 + __ROL4__(v28 + v83 - 1069501632 + (v30 & ~v29 | v31 & v29), 9);
v33 = v32 + __ROL4__(v84 + (v30 & v32 | v31 & ~v30) + v29 + 643717713, 14);
v34 = v33 + __ROR4__(*a2 + (v31 & v33 | v32 & ~v31) + v30 - 373897302, 12);
v35 = v34 + __ROL4__(v85 + (v34 & v32 | v33 & ~v32) + v31 - 701558691, 5);
v36 = v35 + __ROL4__(v12 + (v35 & v33 | v34 & ~v33) + v32 + 38016083, 9);
v37 = v36 + __ROL4__(v27 + (v34 & v36 | v35 & ~v34) + v33 - 660478335, 14);
v38 = v37 + __ROR4__(v88 + (v35 & v37 | v36 & ~v35) + v34 - 405537848, 12);
v39 = v38 + __ROL4__(v79 + (v38 & v36 | v37 & ~v36) + v35 + 568446438, 5);
v40 = v39 + __ROL4__(v22 + (v39 & v37 | v38 & ~v37) + v36 - 1019803690, 9);
v41 = v40 + __ROL4__(v37 + v87 + (v38 & v40 | v39 & ~v38) - 187363961, 14);
v42 = v41 + __ROR4__(v80 + (v39 & v41 | v40 & ~v39) + v38 + 1163531501, 12);
v43 = v42 + __ROL4__(v21 + (v42 & v40 | v41 & ~v40) + v39 - 1444681467, 5);
...

If you have reversed enough binaries, you would find it similar to some pieces of code that comes from a well known encryption algorithm. To test my theory, I used findcrypt-yara plugin from IDA to find if there’s any well known constant in an algorithm. And it ended up showing 3 popular ones: CRC32, SHA256 and MD5.

Having those hints, I can easily spot functions that are related to those 3 algorithms:

...
v29 = 0i64;
v30 = 1732584193;
v20 = -1i64;
v31 = -271733879;
v32 = -1732584194;
v33 = 271733878;
do
++v20;
while ( Src[v20] );
MD5_Init(&v29, Src);
MD5_Final(&v29);
*(_OWORD *)&v28[1] = v34;
do
++v9;
while ( Src[v9] );
SHA256(Src, v9);
if ( !strcmp(Buffer, "CORnwallis") && !memcmp(&unk_7FF7EEF27068, Buf2, 0x20ui64) )
{
memset(v35, 0, sizeof(v35));
CHACHA_Init(v35, Buf2, &v28[1]);
LODWORD(v35[22]) = 42;
v35[15] = 42i64;
v22 = LOBYTE(v35[13]) | ((BYTE1(v35[13]) | (WORD1(v35[13]) << 8)) << 8);
v35[8] = 64i64;
HIDWORD(v35[22]) = LOBYTE(v35[13]) | ((BYTE1(v35[13]) | (WORD1(v35[13]) << 8)) << 8);
v23 = 0i64;
v24 = 64i64;
do
{
    if ( v24 >= 0x40 )
    {
    CHACHA_key_generate(v35);
    v8 = 0i64;
    v35[8] = 0i64;
    }
    v25 = *((_BYTE *)v35 + v8++);
    byte_7FF7EEF27040[v23] ^= v25;
    v24 = v8;
    ++v23;
    v35[8] = v8;
}
while ( v23 < 0x23 );
LODWORD(v28[0]) = 0;
CRC32(v22, v21, v28);
...

There is actually one exception that findcrypt-yara can’t figure out, which is CHACHA. But we can easily identify that by finding the string expand 32-byte k in a function and google it, which results in many aritcles about CHACHA.

Eventually these valuable information will make our reversing life much more easier.

Reversing

The program is pretty simple: it will compare our username with string CORnwallis, then it generates SHA256 and MD5 from our password for key and nonce, respectively, and use them for CHACHA to decrypt the flag. Finally the file will validate the decrypted flag by using CRC32 and compare with a hardcoded value.

I made a python version of the program so that readers can visualize the flow of the binary easier:

from pwn import *
import binascii

# password != flag, find one of those two and we can get the flag

def encrypt(password):
    for i in range(len(password)):
        v19 = (i + 1) % len(password)
        password[i] += 1
        password[i] = (~((password[v19] ^ password[i]) + 0x62)) & 0xff

    return b''.join(bytes([i]) for i in password)

def chacha20_key_generate(key, iv):
    def rotate(v, c):
        return ((v << c) & 0xffffffff) | v >> (32 - c)

    def quarter_round(x, a, b, c, d):
        x[a] = (x[a] + x[b]) & 0xffffffff
        x[d] = rotate(x[d] ^ x[a], 16)
        x[c] = (x[c] + x[d]) & 0xffffffff
        x[b] = rotate(x[b] ^ x[c], 12)
        x[a] = (x[a] + x[b]) & 0xffffffff
        x[d] = rotate(x[d] ^ x[a], 8)
        x[c] = (x[c] + x[d]) & 0xffffffff
        x[b] = rotate(x[b] ^ x[c], 7)

    init_state = list(b'expand 32-byte k' + key + p32(0x2a))
    for i in range(len(iv)):
        init_state.append(iv[i])
    ctx = [0] * 16

    for i in range(16):
        ctx[i] = (init_state[i * 4 + 3] << 24) + (init_state[i * 4 + 2] << 16) + (init_state[i * 4 + 1] << 8) + (init_state[i * 4])
    x = [i for i in ctx]

    for i in range(10):
        quarter_round(x, 0, 4,  8, 12)
        quarter_round(x, 1, 5,  9, 13)
        quarter_round(x, 2, 6, 10, 14)
        quarter_round(x, 3, 7, 11, 15)
        quarter_round(x, 0, 5, 10, 15)
        quarter_round(x, 1, 6, 11, 12)
        quarter_round(x, 2, 7,  8, 13)
        quarter_round(x, 3, 4,  9, 14)

    final_state = []
    for i in range(16):
        ctx[i] = (ctx[i] + x[i]) & 0xffffffff
        final_state.append(ctx[i] & 0xff)
        final_state.append((ctx[i] >> 8) & 0xff)
        final_state.append((ctx[i] >> 16) & 0xff)
        final_state.append((ctx[i] >> 24) & 0xff)
    return final_state


def chacha20_decrypt(data, key, iv=None, position=0):
    state = chacha20_key_generate(key, iv)
    li = list(data)
    for i in range(len(data)):
        li[i] ^= state[i]
    return li

# --------- Modify this ------------
iv = bytes.fromhex('dbfe4eaab5c3a69748367be5') # md5(encrypt(password))[:12]
# ----------------------------------

key = bytes.fromhex('9c00f1ac636216e2342f64ae3b82e3c02749a69c35df8c03554d55c101869d47') # sha256(encrypt(password))
ciphertext = bytes.fromhex('3aab3581d55f56b0cee5f5164db38d2d7823d01c00c1ec07190232914ab463ccedd908')

flag = chacha20_decrypt(ciphertext, key, iv)

assert flag == b'corctf{' and binascii.crc32(flag) == 0x5E5C4A02

Probably you saw those comments. Yes, we do have SHA256 since it’s already provided in challenge’s binary, but we don’t have MD5 one. At this point we have to think of many options to crack this piece of code and get the flag.

Obtaining the flag

There’s one small note on the code above is that the author did a small change in our CHACHA algorithm, which he modified the counter value in initial key state from 0 to 0x2a (For more information about CHACHA, you can check this link), that’s the reason why I have to rewrite CHACHA’s whole implementation again. But interestingly, this small change made me spent almost a day learning this algorithm carefully because I thought that this may results to some vulnerability and we could get the flag from this.

I was wrong, but still worth a try.

I also came up with some crazy ideas like converting SHA256 to MD5 without knowing the plaintext, or maybe there’re some OTP problems…

But everything leads to one thing: It’s almost impossible to crack this code while the contest was still up.

I really frustrated at that point, but small idea came up to my mind: Maybe the password is weak!?

It’s actually a legit way of attacking because everything related to crypto at that moment was impossible to solve or crack. So probably there would be an actual chance that the only way to solve this problem was hoping the password is acutally weak.

I downloaded rockyou.txt and started bruteforcing with this script:

password = list(b'CORnwallis')

import hashlib

def encrypt(password):
    for i in range(len(password)):
        v19 = (i + 1) % len(password)
        password[i] += 1
        password[i] = (~((password[v19] ^ password[i]) + 0x62)) & 0xff

    return b''.join(bytes([i]) for i in password)

leak = open("rockyou.txt", "rb").readlines()

for i in leak:
    password = i.strip()

    print(password)

    sha256 = hashlib.sha256()
    password = encrypt(list(password))
    sha256.update(password)

    if sha256.digest() == bytes.fromhex('9c00f1ac636216e2342f64ae3b82e3c02749a69c35df8c03554d55c101869d47'):
        print('Found')
        break

And there is actually a result!! The password is canthackmehackers.

After getting the password, I reran the program with a debugger and checked the memory region that contains the flag after it decrypted:

corctf{d1d_y0u_h4ck_m3_h4ck3rm4n?}


Bogus (6 solves)

It's taking so long...

A simple flag checker challenge, but like the description said, the algorithm that checks the flag may take a while to finish, so probably the best thing to do at first is to understand the binary itself.

Although many instructions have been documented, I’ll try to explain some of it as simple as possible so we can visualize the flow of the algorithm easier.

And to make the explaination clearer, I’ll assume that all ymm register is an array with size of 8 bytes.

VPERMD

vpermd ymm0, ymm1, ymm2

It’ll swap ymm2 values base on ymm1 and put the result into ymm0:

And here’s the python implementation:

def swappos(source, pos):
    li = list(source)
    for i in range(len(pos)):
        li[i] = source[pos[i]]
    return li

VPUNPCKLDQ

vpunpckldq ymm2, ymm1, ymm0

It’ll take values in some specific positions from ymm0, ymm1, merge them and put the result in ymm2

def mergelow(source0, source1):
    li = []
    for i in range(2):
        li.append(source1[i])
        li.append(source0[i])
    for i in range(4, 6):
        li.append(source1[i])
        li.append(source0[i])
    return li

VPUNPCKHDQ

vpunpckhdq ymm2, ymm1, ymm0

Similar to vpunpckldq, but it’ll take the other positions to merge.

def mergehigh(source0, source1):
    li = []
    for i in range(2, 4):
        li.append(source1[i])
        li.append(source0[i])
    for i in range(6, 8):
        li.append(source1[i])
        li.append(source0[i])
    return li

VPCMPGTD

vpcmpgtd ymm0, ymm1, ymm2

It’ll compare ymm1 with ymm2 at each positions. If ymm1[i] > ymm2[i] then ymm0[i] = 0xf, otherwise ymm0[i] = 0

The implementation in python is a little bit different. Instead returning an array ymm0, I try to display the result as an int for further purposes.

def compare_greater(source0, source1):
    res = 0
    for i in range(len(source0)):
        if source0[i] > source1[i]:
            res += (0xf << (4 * i))
    return res

Putting it all together

Let’s look at the challenge binary in IDA:

First it’ll store an array of hardcoded values from .rodata to the stack. Then it’ll read our input, a small note is that our input is 16 bytes long.

After that it’ll check if our input is in range from A to P. If fail, it’ll jump to an infinite loop and we can’t escape from it. Else it will continue the validating process.

Next it’ll load our input into register ymm0 and ymm1, each register holds 8 bytes of our input. It’ll also load some constants shift, shift2, msk to registers.

This is the main logic of the entire binary. These codes is similar to this python script:

...
r8 += 1
r11 = old_r11
r11 = (r11 * some_const + 1) & 0xffffffffffffffff
old_r11 = r11

id = (((r11 & 0xffff) << 5) // 4)
ymm6 = li[id:id+8]
r11 >>= 0x16
id = (((r11 & 0xffff) << 5) // 4)
ymm7 = li[id:id + 8]
r11 >>= 0x16
id = (((r11 & 0xffff) << 5) // 4)
ymm10 = li[id:id + 8]
r11 >>= 0x16
id = (((r11 & 0xffff) << 5) // 4)
ymm11 = li[id:id + 8]

ymm0 = swappos(ymm0, ymm6)
ymm1 = swappos(ymm1, ymm7)

ymm2 = mergelow(ymm0, ymm1)
ymm3 = mergehigh(ymm0, ymm1)

ymm4 = swappos(ymm2, ymm10)
ymm5 = swappos(ymm3, ymm11)

ymm0 = mergelow(ymm4, ymm5)
ymm1 = mergehigh(ymm4, ymm5)

ymm6 = swappos(ymm0, ymm12)

res = compare_greater(ymm0, ymm6)

if res >= 0xfffffff0:
    ymm6 = swappos(ymm1, ymm12)
    ymm3 = swappos(ymm0, ymm14)
    ymm6[0] = ymm3[0]
    res = compare_greater(ymm1, ymm6)
    if res == 0xffffffff:
        if r8 == 0x3B9ACA00:
            print('Found')
        else:
            print('No')
        exit(0)
...

Basically it will loop a couple of times with counter r8d and swap our input’s positions.

Finally it’ll check ymm0 and ymm1 through some conditions. If it’s true, it’ll check if r8d == 0x3B9ACA00, if yes then our input is the flag.

After carefully analyzing the conditions, I know that ymm0 and ymm1 have to be [0, 1, 2, 3, 4, 5, 6, 7] and [8, 9, 10, 11, 12, 13, 14, 15] respectively to pass that check. At this point we can sum up the algorithm: We’ll input a 16-bytes string, the binary will swap our input in a loop and at 0x3B9ACA00th loop, our input has to be ABCDEFGHIJKLMNOP.

For more context, this algorithm is actually bogosort

Solution

To solve this problem, I’ll input string ABCDEFGHIJKLMNOP and see what’s the state of ymm0 and ymm1 in 0x3B9ACA00th loop, extract that state and rearrange the input to suit the condition. One of the way I do to obtain that final state is to patch the binary like this:

Before:

After:

We will attach a debugger to it, put breakpoint at 0x12ca and run the binary. We’ll obtain the final state in a short time.

Then all we have to do is just rearrange our input so that it fits the condition:

li = [int(i) for i in '0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15'.split(' ')]
print(li)

final_state = bytes.fromhex('02 00 00 00 0D 00 00 00 06 00 00 00 08 00 00 00 05 00 00 00 04 00 00 00 0C 00 00 00 0B 00 00 00 09 00 00 00 0E 00 00 00 01 00 00 00 00 00 00 00 07 00 00 00 0A 00 00 00 0F 00 00 00 03 00 00 00')
order = []
for i in range(len(final_state) // 4):
    order.append(u32(final_state[i*4:(i+1)*4]))

print(order)
new_li = [0] * 16

for i in range(len(order)):
    new_li[order[i]] = li[i]

for i in new_li:
    print(chr(i + ord('A')), end = '')

corctf{LKAPFECMDINHGBJO}