The ckriellideon 🔥

MidnightSunCTF 2024 07u4 (Reversing) Writeups

Performing auto static analysis using capstone and heuristic

- 21 min

07u4 - MidnightSunCTF 2024 Quals Writeup

Introduction

This weekend I participated with idek and got 6th in MSun Quals. Unfortunately I didn’t capture any flag because the team is amazing (JoshL op), but I solved the 07u4 reversing challenge while the CTF was running for myself. It was an interesting auto analysis challenge which gave different types of binaries from the instance, and you had to find the solution for them. I’ve wrote a kinda similar challenge for HTB, ARMs Race (shameless plug), and I’ve solved a few, so it was a nice challenge to tackle. Before starting, I need to mention that the author of the challenge is quend, who tragically lost her life a few weeks before the CTF. This writeup is in honor of her.

Playing around

Unfortunately the instance is down as of the time of writing this writeup, So I can’t show the interaction with it. But basically it had 2 options, a help and a play. The help option just stated that we will get 25 levels, and each binary will be sent to us gzip compressed. When connecting, we got this sort of message and prompt.

1
2
3
4
BIN 01/25: 
<gzip compressed binary in hex here>
ANSWER: 
<send answer here>

So I just wrote a simple template with pwntools for communication.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *
import gzip

def start_game():
    io.recvuntil(b'Play\n')
    io.sendline(b'2')

def get_level():
    io.recvuntil(b'BIN ')
    lvl_idx = io.recv(2).decode()
    io.recvuntil(b'25: \n')
    level = gzip.decompress(bytes.fromhex(io.recvline().rstrip().decode()))
    with open(f'lvl{lvl_idx}', 'wb') as f:
        f.write(level)
    return level

def analyze(exe):
    pass

def give_answer(ans):
    io.recvuntil(b'ANSWER: \n')
    io.sendline(f'{ans}'.encode())

def pwn():
    start_game()
    for _ in range(25):
        level = get_level()
        answer = analyze(level)
        give_answer(answer)
    io.interactive()

if __name__ == '__main__':
    ip = '07u4-1.play.hfsc.tf'
    port = 3991
    io = remote(ip, port)
    pwn()

Obviously this changed whilst moving forward, but it was a good template to start. Let’s begin by showing the first binary I got using binja.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
000011a0      if (argc s<= 1)
000011a2          rax = -1
000011a0      else
000011a9          int32_t var_1c_1 = 0
000011b0          int32_t var_18_1 = 0x5b
000011b7          int32_t var_14_1 = 0x5b
000011c3          char* rax_1 = malloc(bytes: 0x5b)
000011e2          memcpy(rax_1, "3nIIiRmSZMrp6Ko75ib8pL3EbwzdcWu7…", 0x5b)
00001203          if (strcmp(rax_1, argv[1]) == 0)
00001205              var_1c_1 = 1
0000120c          rax = var_1c_1
00001210      return rax

We can see that it’s fairly simple, it receives our solution as a command line argument, allocates a heap chunk to copy the solution that exists inside the binary, and just executes a strcmp (could have called strcmp immediately, but from looking at the other binaries later on, the malloc seems to have been given to every binary from the backend). Initially I thought this was the main idea and the next levels would play around this. So sort of increasing the difficulty every n levels you pass. But that was not the case.

Zerotistic found out that different binaries can be found on level 1 where he found another xor related one, and another bitwise operations related one. At that point I thought that difficulty is not escalated, but rather chosen at random. JoshL concluded that there were only these 3 levels. Below are 2 decompilations for the xor version and the bitwise version.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// xor version
0000115c      void* fsbase
0000115c      int64_t rax = *(fsbase + 0x28)
0000116f      int32_t rax_2
0000116f      if (argc s<= 1)
00001171          rax_2 = -1
0000116f      else
000011ad          void var_24
000011ad          for (int32_t i = 0; i u<= 9; i = i + 1)
0000119f              *(&var_24 + sx.q(i)) = argv[1][sx.q(i)]
000011af          int32_t var_2c_1 = 1
000011b6          char var_1a = 0x1e
000011ba          char var_19_1 = 0x22
000011be          char var_18_1 = 7
000011c2          char var_17_1 = 0
000011c6          char var_16_1 = 0x68
000011ca          char var_15_1 = 3
000011ce          char var_14_1 = 0x3a
000011d2          char var_13_1 = 0x35
000011d6          char var_12_1 = 0x12
000011da          char var_11_1 = 0x39
0000121c          for (int32_t i_1 = 0; i_1 s<= 9; i_1 = i_1 + 1)
0000120b              if (zx.d((&var_1a)[sx.q(i_1)] ^ *(&var_24 + sx.q(i_1)) ^ 0x50) != 0)
0000120d                  var_2c_1 = 0
0000121e          rax_2 = var_2c_1
00001225      *(fsbase + 0x28)
0000122e      if (rax == *(fsbase + 0x28))
00001236          return rax_2
00001230      __stack_chk_fail()
00001230      noreturn
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// bitwise version
000011c6      void* fsbase
000011c6      int64_t rax = *(fsbase + 0x28)
000011dc      int32_t rax_2
000011dc      if (argc s<= 1)
000011de          rax_2 = -1
000011dc      else
000011ed          char* rax_3 = malloc(bytes: 0x5c)
0000120d          memset(rax_3, 0xff, 0x5c)
00001212          int32_t var_100_1 = 0
00001269          while (true)
00001271              if (sx.q(var_100_1) u>= strlen(rax_3))
00001271                  break
00001247              rax_3[sx.q(var_100_1)] = argv[1][sx.q(var_100_1)]
0000124f              var_100_1 = var_100_1 + 1
0000127d          int32_t rax_15 = strlen(rax_3)
00001288          int32_t var_fc_1 = 1
00001292          int16_t var_e8
00001292          __builtin_memcpy(dest: &var_e8, src: "\x2e\xc2\x9c\xf1\x1e\xce\xbf\xc3\x72\xc2\xcc\xc7\x43\xed\x76\xd8\xd1\xe6\x9d\xde\x69\xc4\x4a\xdf\xc1\xd3\x33\xf5\x51\xd1\xfa\xc9\xeb\xc6\xe2\xdb\xf4\xf5\x40\xce\x8f\xf9\xe5\xf0\xd2\xd9\xb3\xe1\x27\xdf\x98\xd7\x20\xd9\xb2\xf2\xff\xcf\x95\xe9\xa3\xda\x9a\xcc\xcc\xd9\xc7\xd9\x5e\xc1\xb6\xc0\xff\xef\x72\xfd\xc2\xca\x7e\xc2\x35\xd3\x7d\xec\xaf\xf3\xed\xfb\x37\xee\x5c\xd1", n: 0x5c)
00001430          int16_t var_88 = 0x1a93
00001436          int16_t var_86_1 = 0x67e8
0000143c          int16_t var_84
0000143c          __builtin_strncpy(dest: &var_84, src: "o4\r;", n: 4)
00001448          int16_t var_80
00001448          __builtin_memcpy(dest: &var_80, src: "\xb6\x0f\x3f\x15\xb8\x45\xd0\x1a\x28\x58\x14\x29\xce\x3b\x9a\x57\x37\x44\xa3\x56\xbf\x48\x4b\x0c\x34\x32\x5a\x1d\x65\x4b\xb8\x3b\xdf\x5e", n: 0x22)
000014ae          int16_t var_5e
000014ae          __builtin_strncpy(dest: &var_5e, src: "_g=/", n: 4)
000014ba          int16_t var_5a
000014ba          __builtin_memcpy(dest: &var_5a, src: "\xf9\x43\x92\x45\xfd\x40\x9a\x4c\x08\x60\x45\x33\x05\x38\xf9\x2a\x04\x13\x3b\x1d\x10\x28\xd7\x38\x27\x1b\x73\x40\xb4\x45\x2c\x12\xcb\x0f\x86\x37\xd2\x57\x03\x36\x35\x69", n: 0x2a)
00001538          int16_t var_30_1 = 0x42ac
0000153e          int16_t var_2e_1 = 0x40ce
000015ea          for (int32_t i = 0; i s< (rax_15 + (rax_15 u>> 0x1f)) s>> 1; i = i + 1)
0000156e              int64_t rdx_4
0000156e              rdx_4.w = sx.w(rax_3[sx.q(i * 2) + 1])
000015c2              if ((&var_e8)[sx.q(i)] + ((sx.d(rax_3[sx.q(i * 2)]) << 8).w | rdx_4.w) != (&var_88)[sx.q(i)])
000015c4                  var_fc_1 = 0
000015f0          rax_2 = var_fc_1
000015fa      *(fsbase + 0x28)
00001603      if (rax == *(fsbase + 0x28))
0000160f          return rax_2
00001605      __stack_chk_fail()
00001605      noreturn

We will get into analyzing them later. My first general approach was to get the binaries, compile them, and solve them with angr. Though this would have been a good general solution, we don’t know the address of the line we want to get to. So we somehow would need to analyze the binary to get that information. But that also opens up the possibility of performing static analysis on the binary with frameworks like capstone, which I decided to use and base my solution on it. Note that capstone produces disassembly from bytes, so from now on we will be looking at the disassembly for producing our actual solution (will still use decompilation for better comprehension)

A great tool to have used would have been ther Binary Ninja API, which gets much praise from CTF players, and for good reason. I didn’t go with this because I don’t have a headless version, which lets you use the API and run scripts without using the binja GUI. Looking back at it, I probably could have solved it regardless, so skill issue. It would have been tedious to rely on the script editor, but alas it wasn’t meant for this one. Hope next time I get my hands dirty with it.

On the lookout for heuristics

A good part of automatic analysis is just observing patters that exist in a fairly large pool of samples (in our case the binaries), and trying to make your solution covers as many cases as you can. If we think of what we need to do, we conclude we need to do 2 things:

  1. Identify the case we have to solve
  2. Solve it :peeposmart:

Let’s cover the first step. Before identifying what we have to solve, we first need to reach main. To do that, we can get the address of _start from the elf header with pyelftools, analyze that function with capstone, and find the address of main. Let’s look at a _start sample with capstone.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
0x1060  endbr64
0x1064  xor     ebp, ebp
0x1066  mov     r9, rdx
0x1069  pop     rsi
0x106a  mov     rdx, rsp
0x106d  and     rsp, 0xfffffffffffffff0
0x1071  push    rax
0x1072  push    rsp
0x1073  xor     r8d, r8d
0x1076  xor     ecx, ecx
0x1078  lea     rdi, [rip + 0xca]
0x107f  call    qword ptr [rip + 0x2f53]
0x1085  hlt

Even though it isn’t resolved, we know that the function called at 0x107f is __libc_start_main, and the pointer passed to rdi is the address of main. So we just need to get the value being added to rip, and that will be the start of main.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    with open(flvl, 'rb') as f:
        elf = ELFFile(f)
        header = elf.header
        entry_point = header['e_entry']
    md = Cs(CS_ARCH_X86, CS_MODE_64)    
    for dasm in md.disasm(exe[entry_point:], entry_point, 0xd):
        if dasm.mnemonic == 'lea':
            rip_offset = int(dasm.op_str.split(' + ')[-1][:-1], 16)
            main_addr = dasm.address + rip_offset + 0x7 # the 7 for the next rip
            break

Now that we have the start of main, let’s quickly find the end of it by searching for the first ret instruction in our path

1
2
3
4
    for dasm in md.disasm(exe[main_addr:], main_addr):
        if dasm.mnemonic == 'ret':
            main_end = dasm.address + 0x1
            break

That 0x1 is used because otherwise ret wouldn’t be included in the disassembly of the entire function (doesn’t matter but I wanted it for sake of completion). After this, I decided to get the entire disassembly of main, so that I could use it to find which case we have, and later to solve them

1
2
3
    main_disasm = ''
    for dasm in md.disasm(exe[main_addr:main_end], main_addr):
        main_disasm += f'{hex(dasm.address)}\t{dasm.mnemonic}\t{dasm.op_str}\n'

As a sidenote, these are the main things you need to know about capstone to use it (they are also the only things I know about it hahaha).

Now, how can we find out which case we are dealing with? At first I thought about doing something with xor because xor is certainly used in case 2, so maybe we could search if xor eax, eax is not the only xor instruction we have in the binary, then most likely we are on case 2. However I didn’t go with this. Instead I went with some other heuristics:

  • Case 1: This case is about moving a char array pointer to rdi before a memcpy with lea, and this is a unique behaviour. The rest of the cases pass values on the stack, whereas every string literal is stored in .rodata, so to get a pointer to it lea is used. Thus we search for 'lea\trcx, [rip + 0x'
  • Case 2: We will search for the start of where the xor key will be stored in the binary (000011b6 - 000011da in the above example). From looking at different samples, the array always starts at offset 0x12. So we can search for 'mov\tbyte ptr [rbp - 0x12],'. If that wasn’t the case we might have needed to use the above xor trick.
  • Case 3: A simple else
1
2
3
4
5
6
7
8
    for dasm in md.disasm(exe[main_addr:main_end], main_addr):
        main_disasm += f'{hex(dasm.address)}\t{dasm.mnemonic}\t{dasm.op_str}\n'
    if 'lea\trcx, [rip + 0x' in main_disasm:
        solution = find_string_case(flvl)
    elif 'mov\tbyte ptr [rbp - 0x12],' in main_disasm:  
        solution = xor_case(main_disasm)
    else:
        solution = bitwise_case(main_disasm)

And that’s the end of the first step. Let’s move onto the second one.

Manually parsing assembly and feeling like a fool

I will split this section into three subsections, one for each case.

Case 1 - Find String

This is the easiest of them all. Since the solution is inside the binary as a string, we can just run strings and find it. This is why for this function we pass the name of the file as an argument. We can have a blacklist of unwanted strings, and search based on them (yoinked Josh’s function, but simple enough).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def find_string_case(fexe):
    wrong = [b'/lib64/ld-linux-x86-64.so.2', b'__cxa_finalize', b'__libc_start_main', b'__stack_chk_fail',
             b'GLIBC_2.14', b'GLIBC_2.2.5', b'GLIBC_2.34', b'_ITM_deregisterTMCloneTable', b'__gmon_start__',
             b'_ITM_registerTMCloneTable', b'GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0', b'.note.gnu.property',
             b'.note.gnu.build-id', b'.note.ABI-tag', b'.gnu.version', b'.gnu.version_r', b'.eh_frame_hdr',
             b'.init_array', b'.fini_array']
    s = run(['strings', '-n 10', fexe], capture_output=True)
    for line in s.stdout.strip().split(b'\n'):
        if line not in wrong:
            return line

Case 2 - Xor Case

Let’s look again at what’s going on in the decompilation. First essentially an array of uint8 values is created.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
000011b6          char var_1a = 0x1e
000011ba          char var_19_1 = 0x22
000011be          char var_18_1 = 7
000011c2          char var_17_1 = 0
000011c6          char var_16_1 = 0x68
000011ca          char var_15_1 = 3
000011ce          char var_14_1 = 0x3a
000011d2          char var_13_1 = 0x35
000011d6          char var_12_1 = 0x12
000011da          char var_11_1 = 0x39

And then a simple xor decryption loop.

1
2
0000121c          for (int32_t i_1 = 0; i_1 s<= 9; i_1 = i_1 + 1)
0000120b              if (zx.d((&var_1a)[sx.q(i_1)] ^ *(&var_24 + sx.q(i_1)) ^ 0x50) != 0)

From 120b we know the above chars, and the constant. So we can recover our input. Example solution for the above case.

1
2
3
>>> ct = [0x1e, 0x22, 0x7, 0x0, 0x68, 0x3, 0x3a, 0x35, 0x12, 0x39]
>>> ''.join([chr(i^0x50) for i in ct])
'NrWP8SjeBi'

There are two things we need to get from the disassembly, the single byte, and the ciphertext. We will recover the ciphertext based on the same way we identified this case, we will look for values stored on the stack at offsets 0x12-0x9. A small problem that came from this is that capstone prints the 9 as a simple integer, not in a hexadecimal format (9 not 0x9 or 0x09). So we needed to cover this small case as well.

Some small problems also occured with the single byte. Originally the disassembly I saw for it was xor al, 0x50, so I thought that the al register would be universal due to compiler optimizations. However it later emerged that we can also have the eax register used. So I accounted for that as well. Another small heuristic I had was that the number would not be 0-9, which was also a mistake. We could not take this case into consideration and leave a small chances of getting such a small xor byte, but I took care of that as well. Below is the complete function code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def xor_case(dasm):
    ct = bytearray()
    split_dasm = dasm.split('\n')[:-1]
    ctr = 0x12
    for line in split_dasm:
        dasm = line.split('\t')
        if dasm[1] == 'mov' and (f'byte ptr [rbp - {hex(ctr)}]' in dasm[2] or f'byte ptr [rbp - {ctr}]' in dasm[2]):
            ct += bytes([int(dasm[2].split(', ')[-1], 16)])
            ctr -= 1
        if dasm[1] == 'xor' and (('al, 0x' in dasm[2] or 'eax, 0x' in dasm[2]) or any(n in dasm[2] for n in [str(i) for i in range(10)])):
            key_byte = bytes([int(dasm[2].split(', ')[-1], 16)])
    return xor(ct, key_byte)

Case 3 - Bitwise Case

This was the “hardest”, though it’s also not that difficult to analyze. The decompilation doesn’t do a good job of showing the 2 continous arrays, so here’s a big disassembly dump of uint16 bytes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
0x1214    mov    word ptr [rbp - 0x90], 0xfdfc
0x121d    mov    word ptr [rbp - 0x8e], 0xed9e
0x1226    mov    word ptr [rbp - 0x8c], 0xf470
0x122f    mov    word ptr [rbp - 0x8a], 0xd780
0x1238    mov    word ptr [rbp - 0x88], 0xf09a
0x1241    mov    word ptr [rbp - 0x86], 0xe68a
0x124a    mov    word ptr [rbp - 0x84], 0xd9e4
0x1253    mov    word ptr [rbp - 0x82], 0xeefd
0x125c    mov    word ptr [rbp - 0x80], 0xc749
0x1262    mov    word ptr [rbp - 0x7e], 0xc4ac
0x1268    mov    word ptr [rbp - 0x7c], 0xdfbe
0x126e    mov    word ptr [rbp - 0x7a], 0xe8f8
0x1274    mov    word ptr [rbp - 0x78], 0xf5b7
0x127a    mov    word ptr [rbp - 0x76], 0xf9ef
0x1280    mov    word ptr [rbp - 0x74], 0xee2c
0x1286    mov    word ptr [rbp - 0x72], 0xfb67
0x128c    mov    word ptr [rbp - 0x70], 0xe207
0x1292    mov    word ptr [rbp - 0x6e], 0xed17
0x1298    mov    word ptr [rbp - 0x6c], 0xfed2
0x129e    mov    word ptr [rbp - 0x6a], 0xd3b8
0x12a4    mov    word ptr [rbp - 0x68], 0xf0da
0x12aa    mov    word ptr [rbp - 0x66], 0xcd6c
0x12b0    mov    word ptr [rbp - 0x64], 0xc45f
0x12b6    mov    word ptr [rbp - 0x62], 0xfbee
0x12bc    mov    word ptr [rbp - 0x60], 0xee62
0x12c2    mov    word ptr [rbp - 0x5e], 0xfdca
0x12c8    mov    word ptr [rbp - 0x5c], 0xdd35
0x12ce    mov    word ptr [rbp - 0x5a], 0xd79b
0x12d4    mov    word ptr [rbp - 0x58], 0xf93c
0x12da    mov    word ptr [rbp - 0x56], 0xec10
0x12e0    mov    word ptr [rbp - 0x54], 0xc35e
0x12e6    mov    word ptr [rbp - 0x50], 0x6d3e
0x12ec    mov    word ptr [rbp - 0x4e], 0x3002
0x12f2    mov    word ptr [rbp - 0x4c], 0x67e1
0x12f8    mov    word ptr [rbp - 0x4a], 0x3ef4
0x12fe    mov    word ptr [rbp - 0x48], 0x69e6
0x1304    mov    word ptr [rbp - 0x46], 0x5fd2
0x130a    mov    word ptr [rbp - 0x44], 0x514a
0x1310    mov    word ptr [rbp - 0x42], 0x5f5f
0x1316    mov    word ptr [rbp - 0x40], 0x3b91
0x131c    mov    word ptr [rbp - 0x3e], 0x3511
0x1322    mov    word ptr [rbp - 0x3c], 0x2629
0x1328    mov    word ptr [rbp - 0x3a], 0x4c3e
0x132e    mov    word ptr [rbp - 0x38], 0x3b2e
0x1334    mov    word ptr [rbp - 0x36], 0x4067
0x133a    mov    word ptr [rbp - 0x34], 0x2f80
0x1340    mov    word ptr [rbp - 0x32], 0x62c8
0x1346    mov    word ptr [rbp - 0x30], 0x277d
0x134c    mov    word ptr [rbp - 0x2e], 0x4179
0x1352    mov    word ptr [rbp - 0x2c], 0x4522
0x1358    mov    word ptr [rbp - 0x2a], 0x4c31
0x135e    mov    word ptr [rbp - 0x28], 0x5749
0x1364    mov    word ptr [rbp - 0x26], 0x40e0
0x136a    mov    word ptr [rbp - 0x24], 0xfd0
0x1370    mov    word ptr [rbp - 0x22], 0x4242
0x1376    mov    word ptr [rbp - 0x20], 0x33d7
0x137c    mov    word ptr [rbp - 0x1e], 0x740e
0x1382    mov    word ptr [rbp - 0x1c], 0x517f
0x1388    mov    word ptr [rbp - 0x1a], 0x25e3
0x138e    mov    word ptr [rbp - 0x18], 0x6c7e
0x1394    mov    word ptr [rbp - 0x16], 0x4265
0x139a    mov    word ptr [rbp - 0x14], 0x119f

The first thing I did (which in retrospect wasn’t really needed and I lost my time with, will explain later why) was get the length of the input and ciphertext from the disassembly. A nice place it’s stored is right above the malloc call, stored in rdi. So I wrote a loop to find a mov to rdi, and a call on the next line. Considering it’s the first call of the function, we will always find it correctly.

1
2
3
4
5
6
7
    split_dasm = dasm.split('\n')[:-1]
    for i in range(len(split_dasm)):
        dasm = split_dasm[i].split('\t')
        next_dasm = split_dasm[i+1].split('\t')
        if dasm[1] == 'mov' and 'edi, 0x' in dasm[2] and next_dasm[1] == 'call':
            ct_len = int(dasm[2].split(', ')[-1], 16)
            break

Before moving on, let’s solve the above case. If we look at the operations being performed from the decompilation, we can write it anew to help us

1
2
data[i] + ((input[2*i] << 8) | input[2*i+1]) == ct[i] =>
(input[2*i] << 8) | input[2*i+1] == ct[i] - data[i]

From the bytes we get that the first half of them belong to this data array, and the second half are the elements of ct. We can move the data[i] to the right half, and we get a better equation. To find our input, we can iterate over an alphabet for the left side until we get a hit on the result of the right value, or, much more cleverly, get the result of ct[i] - data[i] & 0xffff. The reason for that is that (input[2*i] << 8) | input[2*i+1] is just (input[2*i] << 8) + input[2*i+1], because the 8 bits that are shifted to the right leave room for all the bits of input[2*i+1]. Also this 2*i+1 just means we get the input bytes in groups of 2, i.e. we end up with a uint16 value.

So we get the numerical values of the bytes from the disassembly, split the values in half with the length we got (that’s why it’s unecessary, we could split in half just with len(data)), and calculate the solution. Note that we can turn the data from a number to bytes with <int>.to_bytes(2, 'little'). Example solution below.

1
2
3
4
5
6
7
8
9
>>> total_data = [65020, 60830, 62576, 55168, 61594, 59018, 55780, 61181, 51017, 50348, 57278, 59640, 62903, 63983, 60972, 64359, 57863, 60695, 65234, 54200, 61658, 52588, 50271, 64494, 61026, 64970, 56629, 55195, 63804, 60432, 50014, 27966, 12290, 26593, 16116, 27110, 24530, 20810, 24415, 15249, 13585, 9769, 19518, 15150, 16487, 12160, 25288, 10109, 16761, 17698, 19505, 22345, 16608, 4048, 16962, 13271, 29710, 20863, 9699, 27774, 16997, 4511]
>>> data = total_data[:len(total_data)//2]
>>> data
[65020, 60830, 62576, 55168, 61594, 59018, 55780, 61181, 51017, 50348, 57278, 59640, 62903, 63983, 60972, 64359, 57863, 60695, 65234, 54200, 61658, 52588, 50271, 64494, 61026, 64970, 56629, 55195, 63804, 60432, 50014]
>>> ct = total_data[len(total_data)//2:]
>>> ct
[27966, 12290, 26593, 16116, 27110, 24530, 20810, 24415, 15249, 13585, 9769, 19518, 15150, 16487, 12160, 25288, 10109, 16761, 17698, 19505, 22345, 16608, 4048, 16962, 13271, 29710, 20863, 9699, 27774, 16997, 4511]
>>> b''.join([((c - d) & 0xffff).to_bytes(2, 'little') for c, d in zip(ct, data)])
b'BodBqstgLyHyfwbpHtepkFFcwExFTAagvEbTPFyxoftsqKTFuEDvJtHNBsUVAN'

And the rest of the function.

1
2
3
4
5
6
7
8
9
    pattern = r'\b0x[0-9A-Fa-f]{3,4}\b'
    for line in split_dasm:
        dasm = line.split('\t')
        matches = re.findall(pattern, dasm[2])
        if dasm[1] == 'mov' and 'word ptr [rbp -' in dasm[2] and not any(register in dasm[2] for register in ['edi', 'rdi', 'esi', 'rsi', 'eax', 'rax']) and matches:
            important_bytes.append(int(matches[0][2:], 16))
    processing_data = important_bytes[:ct_len//2]
    ct = important_bytes[ct_len//2:]
    return b''.join([((c - d) & 0xffff).to_bytes(2, 'little') for c, d in zip(ct, data)])

Lastly, just to mention that I decided to get the data I went with a regex. However there were cases where the hex numbers were 3 bytes, so I had to do some more maneuvering to get the values. Since rbp was the only register we cared for, I blacklisted some registers that where and could be problematic.

Getting the flag

Below is the complete solver

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
from elftools.elf.elffile import ELFFile
from subprocess import run
from capstone import *
from pwn import *
import gzip
import re

def start_game():
    io.recvuntil(b'Play\n')
    io.sendline(b'2')

def get_level():
    io.recvuntil(b'BIN ')
    lvl_idx = io.recv(2).decode()
    fname = f'lvl{lvl_idx}'
    io.recvuntil(b'25: \n')
    level = gzip.decompress(bytes.fromhex(io.recvline().rstrip().decode()))
    with open(fname, 'wb') as f:
        f.write(level)
    return level, fname

def give_answer(ans):
    io.recvuntil(b'ANSWER: \n')
    io.sendline(ans)

def find_string_case(fexe):
    wrong = [b'/lib64/ld-linux-x86-64.so.2', b'__cxa_finalize', b'__libc_start_main', b'__stack_chk_fail',
             b'GLIBC_2.14', b'GLIBC_2.2.5', b'GLIBC_2.34', b'_ITM_deregisterTMCloneTable', b'__gmon_start__',
             b'_ITM_registerTMCloneTable', b'GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0', b'.note.gnu.property',
             b'.note.gnu.build-id', b'.note.ABI-tag', b'.gnu.version', b'.gnu.version_r', b'.eh_frame_hdr',
             b'.init_array', b'.fini_array']
    s = run(['strings', '-n10', fexe], capture_output=True)
    for line in s.stdout.strip().split(b'\n'):
        if line not in wrong:
            return line

def xor_case(dasm):
    ct = bytearray()
    split_dasm = dasm.split('\n')[:-1]
    ctr = 0x12
    for line in split_dasm:
        dasm = line.split('\t')
        if dasm[1] == 'mov' and (f'byte ptr [rbp - {hex(ctr)}]' in dasm[2] or f'byte ptr [rbp - {ctr}]' in dasm[2]):
            ct += bytes([int(dasm[2].split(', ')[-1], 16)])
            ctr -= 1
        if dasm[1] == 'xor' and (('al, 0x' in dasm[2] or 'eax, 0x' in dasm[2]) or any(n in dasm[2] for n in [str(i) for i in range(10)])):
            key_byte = bytes([int(dasm[2].split(', ')[-1], 16)])
    return xor(ct, key_byte)

def bitwise_case(dasm):
    important_bytes = []
    split_dasm = dasm.split('\n')[:-1]
    for i in range(len(split_dasm)):
        dasm = split_dasm[i].split('\t')
        next_dasm = split_dasm[i+1].split('\t')
        if dasm[1] == 'mov' and 'edi, 0x' in dasm[2] and next_dasm[1] == 'call':
            ct_len = int(dasm[2].split(', ')[-1], 16)
            break
    pattern = r'\b0x[0-9A-Fa-f]{3,4}\b'
    for line in split_dasm:
        dasm = line.split('\t')
        matches = re.findall(pattern, dasm[2])
        if dasm[1] == 'mov' and 'word ptr [rbp -' in dasm[2] and not any(register in dasm[2] for register in ['edi', 'rdi', 'esi', 'rsi', 'eax', 'rax']) and matches:
            important_bytes.append(int(matches[0][2:], 16))
    processing_data = important_bytes[:ct_len//2]
    ct = important_bytes[ct_len//2:]
    return b''.join([((c - d) & 0xffff).to_bytes(2, 'little') for c, d in zip(ct, data)])
    
def analyze(exe, flvl):
    with open(flvl, 'rb') as f:
        elf = ELFFile(f)
        header = elf.header
        entry_point = header['e_entry']
    md = Cs(CS_ARCH_X86, CS_MODE_64)    
    for dasm in md.disasm(exe[entry_point:], entry_point, 0xd):
        if dasm.mnemonic == 'lea':
            rip_offset = int(dasm.op_str.split(' + ')[-1][:-1], 16)
            main_addr = dasm.address + rip_offset + 0x7 # the 7 for the next rip
            break
    for dasm in md.disasm(exe[main_addr:], main_addr):
        if dasm.mnemonic == 'ret':
            main_end = dasm.address + 0x1
            break
    main_disasm = ''
    for dasm in md.disasm(exe[main_addr:main_end], main_addr):
        main_disasm += f'{hex(dasm.address)}\t{dasm.mnemonic}\t{dasm.op_str}\n'
    if 'lea\trcx, [rip + 0x' in main_disasm:
        solution = find_string_case(flvl)
    elif 'mov\tbyte ptr [rbp - 0x12],' in main_disasm:  
        solution = xor_case(main_disasm)
    else:
        solution = bitwise_case(main_disasm)
    return solution

def pwn():
    start_game()
    for l in range(25):
        level, level_fname = get_level()
        answer = analyze(level, level_fname)
        print(f'Level {l+1}/25: {answer}')
        give_answer(answer)
    io.interactive()

if __name__ == '__main__':
    ip = '07u4-1.play.hfsc.tf'
    port = 3991
    io = remote(ip, port)
    pwn()

And below is the interaction with the server

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[+] Opening connection to 07u4-1.play.hfsc.tf on port 3991: Done
Level 1/25: b'NrWP8SjeBi'
Level 2/25: b'vzIGknF3dO'
Level 3/25: b'6dr5X08rEJ'
Level 4/25: b'jxkb7XNBuk'
Level 5/25: b'Rx0INfgqGN39uR3Ujgx38F2E9aKjLeqjRhoiCYOaj5oozcXAfU9WfVUGZjinxYWn9z4AEB6'
Level 6/25: b'lS5ymzLrD4D9v3Eo8GqcCSikUf5A3OPPjf1l1zBjsrdMMkkp1ueOCHMWboOWM3sJhxMKG3h24ce6'
Level 7/25: b'vtmjCdpKVStebMRCvruRjDeQzjEluJwNRWtpZaXKhfrnUKhm'
Level 8/25: b'Gh85JJQtui'
Level 9/25: b'OvHbRWIgEo'
Level 10/25: b'OLPtHmEaMcrXMcafCOMCoTdRKSYKuHmUagPCckqTfKdUFKVHtRHOICTzzCWgGSYdkbBaDgbzlRqTWspfezwGuAAAYq'
Level 11/25: b'J6WCquMUL8'
Level 12/25: b'ukPgegERvEkZQGxWXvXPkPXxilpxsjQHZFFlCTmCRvvhGWZcxRfYYy'
Level 13/25: b'Rx0INfgqGN39uR3Ujgx38F2E9aKjLeqjRhoiCYOaj5oozcXAfU9WfVUGZjinxYWn9z4AEB6'
Level 14/25: b'ldFNsXSpvJiSwZUnqzoLFgOPmopuCKwQWlizkGZaUHuwhccdCHEbOYldDNSCOQjqRZkFnK'
Level 15/25: b'ooQIANIbxRnbLBPYBsFsOLyaKJ'
Level 16/25: b'oAUxzMpgEByOZNtMOXgXwvKszthEnetKhjOsOmVmpEmWzdHwQkYScdEctIhkNKnaYTDhBLttLaHRnVvQ'
Level 17/25: b'IuSwpCgOGVegESwCOEjrmPBolROo'
Level 18/25: b'ukPgegERvEkZQGxWXvXPkPXxilpxsjQHZFFlCTmCRvvhGWZcxRfYYy'
Level 19/25: b'HyQnJamR2E'
Level 20/25: b'FnPZFnDfTjTnjvPWdRDSTQCtygqgHqoMglAqHQOB'
Level 21/25: b'vYXIaPfZMsAjsfrcjlRoRcgssjxDhPUgcLRKtEBTinuE'
Level 22/25: b'PMCOuiwONurxpTAGaUNNpIZKZgWKmRCrxRwPbaqSHGsKwzEHZoBKKPSAzfkjuXQaQwAQsFLgOhpiDtsjRNQcTqBhrlwyeznLfv'
Level 23/25: b'keUjBcmtaRaxWpusZbrDxxFStQbRGRbNkRrrNSvSkWTMVyjClDQQjX'
Level 24/25: b'eouFJPb9iJ'
Level 25/25: b'keUjBcmtaRaxWpusZbrDxxFStQbRGRbNkRrrNSvSkWTMVyjClDQQjX'
[*] Switching to interactive mode

FLAG:
midnight{Ti_esrever_dna_ti_pilf_nwod_gnaht_ym_tup_i}

And just like that we got the flag!

Conclusion

The challenge was on the easier side, since even though it was released around the middle of the CTF, it still got around 30 solves. However it was still very enjoyable and gave room for many experimentations. Thank you quend, rest in peace.