This page looks best with JavaScript enabled

HTB Cyber Apocalypse

Cyber Apocalypse 2023: The Cursed Mission 🪐

 ·  ☕ 14 min read  ·  👻 Ahmed Raof
Hello and welcome to my reverse engineering and machine learning write-up. This was my first time participating in Cyber Apocalypse 2023 - The Cursed Mission, and it was an incredibly fun experience. I had the pleasure of working with IDEK team, and I'm happy to say that we managed to achieve 🥇 **1st** place in the competition.

1️⃣

CHALLEGNE NAME: Shattered Tablet
Difficulty: Very easy
Deep in an ancient tomb, you’ve discovered a stone tablet with secret information on the locations of other relics. However, while dodging a poison dart, it slipped from your hands and shattered into hundreds of pieces. Can you reassemble it and read the clues?

We started with the first challenge by loading it into IDA. It’s consisting of a single if condition checking each character of the input and comparing it with the flag. Rather than manually analyzing each condition and sorting it out, we opted to use symbolic execution to get the flag quickly and easily. As a result, we were able to retrieve the flag without any significant effort.

OR

BYTE4(),BYTE3(),BYTE2(), and BYTE1() are macros that extract the fourth, third, second, and first byte of a 32-bit integer, respectively. So we can concatenate all these bytes and we will have the Flag s = HTB{br0k, v5 = 3n_4p4rt, v6 = ,n3ver_t, v7 = **0_b3_r3p**, and v8 = **41r3d}**

 
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import angr
# load the binary
project = angr.Project('tablet', load_options={"auto_load_libs": False})
# store the entry path
pathgroup = project.factory.simgr()
# Create a simulation engine with symbolic values
sm = project.factory.simulation_manager()
print( sm.explore( find=lambda s:b"Yes! That's right!" in s.posix.dumps(1) ) )
print(sm.found[0].posix.dumps(0))
# flag = str(pathgroup.active[0].posix.dumps(0))[2:-1]
flag = str(sm.found[0].posix.dumps(0))
print(flag)
🚩 HTB{br0k3n_4p4rt,n3ver_t0_b3_r3p41r3d}

2️⃣

CHALLEGNE NAME: She Shells C Shells
Difficulty: Very easy
You’ve arrived in the Galactic Archive, sure that a critical clue is hidden here. You wait anxiously for a terminal to boot up, hiding in the shadows from the guards hunting for you. Unfortunately, it looks like you’ll need a password to get what you need without setting off the alarms…
Moving on to the second challenge, we loaded it into IDA and looking for strings. We found the string "Flag: %s\n", and let’s investigate the function responsible for it.”

 

  
 

The function takes user input then loops through 74 characters, which is the length of the flag, and XORs each character with ‘m1’. The result is then compared with ’t’ if it’s correct we will get the flag.
So it’s simply and XOR challenge

 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
from pwn import xor

t = [
    0x2C, 0x4A, 0xB7, 0x99, 0xA3, 0xE5, 0x70, 0x78, 0x93, 0x6E, 
  0x97, 0xD9, 0x47, 0x6D, 0x38, 0xBD, 0xFF, 0xBB, 0x85, 0x99, 
  0x6F, 0xE1, 0x4A, 0xAB, 0x74, 0xC3, 0x7B, 0xA8, 0xB2, 0x9F, 
  0xD7, 0xEC, 0xEB, 0xCD, 0x63, 0xB2, 0x39, 0x23, 0xE1, 0x84, 
  0x92, 0x96, 0x09, 0xC6, 0x99, 0xF2, 0x58, 0xFA, 0xCB, 0x6F, 
  0x6F, 0x5E, 0x1F, 0xBE, 0x2B, 0x13, 0x8E, 0xA5, 0xA9, 0x99, 
  0x93, 0xAB, 0x8F, 0x70, 0x1C, 0xC0, 0xC4, 0x3E, 0xA6, 0xFE, 
  0x93, 0x35, 0x90, 0xC3, 0xC9, 0x10, 0xE9
]
m1 = [
    0x6E, 0x3F, 0xC3, 0xB9, 0xD7, 0x8D, 0x15, 0x58, 0xE5, 0x0F, 
  0xFB, 0xAC, 0x22, 0x4D, 0x57, 0xDB, 0xDF, 0xCF, 0xED, 0xFC, 
  0x1C, 0x84, 0x6A, 0xD8, 0x1C, 0xA6, 0x17, 0xC4, 0xC1, 0xBF, 
  0xA0, 0x85, 0x87, 0xA1, 0x43, 0xD4, 0x58, 0x4F, 0x8D, 0xA8, 
  0xB2, 0xF2, 0x7C, 0xA3, 0xB9, 0x86, 0x37, 0xDA, 0xBF, 0x07, 
  0x0A, 0x7E, 0x73, 0xDF, 0x5C, 0x60, 0xAE, 0xCA, 0xCF, 0xB9, 
  0xE0, 0xDE, 0xFF, 0x00, 0x70, 0xB9, 0xE4, 0x5F, 0xC8, 0x9A, 
  0xB3, 0x51, 0xF5, 0xAE, 0xA8, 0x7E, 0x8D
]
password = xor(t, m1)
print(password)
# output: 
b'But the value of these shells will fall, due to the laws of supply and demand'
🚩 HTB{cr4ck1ng_0p3n_sh3ll5_by_th3_s34_sh0r3}

3️⃣

CHALLEGNE NAME: Hunting License
Difficulty: Easy
STOP! Adventurer, have you got an up to date relic hunting license? If you don’t, you’ll need to take the exam again before you’ll be allowed passage into the spacelanes!

As we move on to the next challenge, we found “0wTdr0wss4P” in strings on IDA. This string is located within the exam() function. It’s possible that this string could be a password, so we let’s see the function to see what else we can find.

 
 

challenge, requiring us to obtain three passwords!

  • First password is very obvious: PasswordNumeroUno
  • As for the second password, we have already located 0wTdr0wss4P, but we need to reverse it! 🙂. The second password is now revealed to be P4ssw0rdTw0
  • The third and final one is just XORing the value in address &t2 with 19 after going inside the function and checking what parameters are used for, and the result is ThirdAndFinal!!!
     
 
  
 
🚩 HTB{l1c3ns3_4cquir3d-hunt1ng_t1m3!}

4️⃣

CHALLEGNE NAME: Cave System
Difficulty: Easy
Deep inside a cave system, 500 feet below the surface, you find yourself stranded with supplies running low. Ahead of you sprawls a network of tunnels, branching off and looping back on themselves. You don’t have time to explore them all - you’ll need to program your cave-crawling robot to find the way out…

The next challenge involve an single if condition that check the input and performing operations such as multiplying the first and second values and comparing the result with a fixed value. Although it can be solved using the Z3 solver, it may take some time to manually copy all the values and replace the BYTE_i() function with the appropriate text index. As an alternative, solving this challenge using symbolic execution would be much easier and efficient.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import angr
# load the binary
project = angr.Project('cave', load_options={"auto_load_libs": False})
# store the entry path
pathgroup = project.factory.simgr()
# Create a simulation engine with symbolic values
sm = project.factory.simulation_manager()
print( sm.explore( find=lambda s:b"Freedom at last!" in s.posix.dumps(1) ) )
print(sm.found[0].posix.dumps(0))
# flag = str(pathgroup.active[0].posix.dumps(0))[2:-1]
flag = str(sm.found[0].posix.dumps(0))
print(flag)
  
🚩 HTB{H0p3_u_d1dn’t_g3t_th15_by_h4nd,1t5_4_pr3tty_l0ng_fl4g!!!}

5️⃣

CHALLEGNE NAME: Alien Saboteaur
Difficulty: Medium
You finally manage to make it into the main computer of the vessel, it’s time to get this over with. You try to shutdown the vessel, however a couple of access codes unknown to you are needed. You try to figure them out, but the computer start speaking some weird language, it seems like gibberish…

Walkthrough

Now let’s move on to the serious stuff. The VM obfuscation challenge. We have two files, vm and bin, and we can execute them by typing

1
$ ./vm bin
  

The bin file contains bytecode that is compiled using the vm file. Therefore, let’s load the vm file into IDA. The program first opens the bin file and retrieves the size of the file and a pointer to its contents.

The vm_create() function takes two parameters: “bin_ptr” and “bin_size”. The function first allocates 168 bytes of memory for a variable called “v3”, which is used to store the state of the virtual machine.

The function then initializes various fields of the “v3” variable. It also allocates memory for two additional variables: a 64KB block of memory and a 800-byte block of memory, which are maybe used for the virtual machine’s memory and stack, respectively.

The function then copies the contents of the “bin” file, starting from the fourth byte (i.e., skipping the first three bytes, which are 55 77 55), into the 64KB block of memory allocated.

 
 

The vm_run() function is responsible for executing the bytecode contained in the virtual machine’s memory. The function takes a single parameter, “a1”, which is a pointer to the virtual machine state.

The function works by repeatedly calling the vm_step() function.

 
 

The vm_step() function is given the virtual machine code and it decompiles each bytecode instruction using a vtable of instruction functions called original_ops, which are accessed based on the current bytecode index.

The vm_step() function fetches the current bytecode using the expression *( *(a1 + 18) + *a1 ) and calls the corresponding function using the expression (original_ops[v2])(a1). If the bytecode index is greater than 0x19, the maximum number of instructions defined in the original_ops array, the program exits with an error message “dead”.

Writing The Decompiler

When writing a Decompiler, it’s IMP to understand how the vm_<inst> processes the parameters. For example, if you have a series of hex values such as [0x10, 0x5b, 0x00, 0x00, 0x00, 0x00], this indicate that the instruction is pushing the value 0x5b onto the stack. If you see a series of hex values like ['0c', '1e', 'a0', '0f'], this means that the instruction is moving the value 0xfa0 into the register at offset 0x1e. By analyzing the sequence of instructions and their corresponding parameters, you can understand of how the program is work.

 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
# bc: byte_code :)
vm_bc = open("bin", "rb").read()[3:] # escape the first 3 bytes

# NOTE THAT SOME INSTRUCTIONS AREN'T USED, SO I WILL NOT WRITE <IF STATEMENTS> FOR THEM
inst = [
    'add', 'addi', 'sub', 'subi',
    'mul', 'muli', 'div', 'cmp', 
    'jmp', 'inv', 'push', 'pop', 
    'mov', 'nop', 'exit', 'print',
    'putc', 'je', 'jne', 'jle',
    'jge', 'xor', 'store', 'load', 'input'
]

# if you went to any vm_<inst> for example vm_putc
    # you will find that it add 6 to the pointer a1
        # *a1 += 6;
with open("output.txt", "w") as f:
    for i in range(0, len(vm_bc), 6): 
        bc = [i for i in vm_bc[i:i+6] if i != 0] # byte_code
        final_res = ""

        if len(bc) == 0:
            f.write(f"finish ?????????????????????\n")
            continue

        if bc[0] >= 0x19: # DEAD
            f.write("dead ?????????????????\n")
            continue

        i_instr = inst[bc[0]] # instruction

        ####################################
        # NOTE THAT SOME INSTRUCTIONS AREN'T USED, SO I WILL NOT WRITE <IF STATEMENTS> FOR THEM
        ####################################

        if i_instr == 'add' or  i_instr == 'xor':
            final_res = f"{i_instr} reg[{bc[1]}], reg[{bc[2]}], reg[{bc[3]}]"

        elif i_instr == 'addi' or i_instr == 'muli':
            final_res = f"{i_instr} reg[{bc[1]}], reg[{bc[2]}], {bc[3]}"

        elif i_instr == 'inv':
            final_res = i_instr, bc[1:]

        elif i_instr == 'push' or i_instr == 'pop':
            final_res = f"{i_instr} reg[{bc[1]}]"

        elif i_instr == 'mov':
            temp = 0 if len(bc) <= 2 else int( "".join(f"%.2x" % i for i in bc[2:][::-1]) , 16)
            if len(bc) <= 1:
                temp = 0
            final_res = f"{i_instr} reg[{bc[1]}], {temp}"

        elif i_instr == 'exit':
            final_res = f"{i_instr}()"

        elif i_instr == 'putc':
            p = "" # to_print
            if bc[1] == 10: p = '\\n'
            elif bc[1] == 32: p = 'SPACE'
            else: p = chr(bc[1])
            final_res = i_instr + " " + p
            
        elif i_instr == 'je':
            final_res = f"{i_instr} reg[{bc[1]}], reg[{bc[2]}] ? {bc[3] * 6} : {i + 6}"

        elif i_instr == 'jle':
            final_res = f"{i_instr} reg[{bc[1]}], reg[{bc[2]}] ? {bc[3] * 6} : {i + 6}"

        elif i_instr == 'store':
            final_res = f"{i_instr} mem[reg[{bc[1]}]], reg[{bc[2]}]"

        elif i_instr == 'load':
            final_res = f"{i_instr} reg[{bc[1]}], mem[reg[{bc[2]}]]"

        elif i_instr == 'input':
            final_res = f"{i_instr} store in reg[{bc[1]}]"
        
        else:
            final_res = f"{i_instr} NOT FOUND ???????????? " + str(bc)

        f.write(f"{i}: {final_res}\n")

  

putc "[Main Vessel Terminal]\n
      < Enter keycode \n
      < "

252: mov reg[30], 4000
258: mov reg[28], 0
264: mov reg[29], 17

270: input store in reg[25]
276: store mem[reg[30]], reg[25]
282: addi reg[30], reg[30], 1
288: addi reg[28], reg[28], 1

294: jle reg[28], reg[29] ? 270 : 300

300: mov reg[30], 4100
306: mov reg[31], 4000
312: mov reg[28], 0
318: mov reg[29], 10
324: mov reg[27], 169
330: mov reg[23], 0

336: load reg[25], mem[reg[30]]
342: load reg[24], mem[reg[31]]
348: xor reg[25], reg[25], reg[27]

354: je reg[25], reg[24] ? 468 : 360

360: putc "Unknown keycode!\n"
462: exit()

468: addi reg[30], reg[30], 1
474: addi reg[31], reg[31], 1
480: addi reg[28], reg[28], 1

486: jle reg[28], reg[29] ? 336 : 492

492: mov reg[15], 0
498: push reg[15]
504: push reg[15]
510: push reg[15]
516: ('inv', [101, 3])
522: mov reg[16], 0

528: je reg[31], reg[16] ? 648 : 534

534: putc "Terminal blocked!\n"
642: exit()

648: mov reg[30], 119
654: muli reg[30], reg[30], 6
660: mov reg[28], 0
666: mov reg[29], 1500
672: mov reg[27], 69
678: load reg[25], mem[reg[30]]
684: xor reg[25], reg[25], reg[27]
690: store mem[reg[30]], reg[25]
696: addi reg[30], reg[30], 1
702: addi reg[28], reg[28], 1

708: jle reg[28], reg[29] ? 678 : 714

dead ?????????????????
dead ?????????????????
dead ?????????????????

 

After decompiling the byte code we got the following:

  • It first ask for the keycode
  • Loop through to check if the user input greater than or equal 17 and it store the user input in address 4000 in memory
  • Load the values in memory address 4100 and store it in reg[25] (it seems that it’s the key) and XOR it with the value in reg[27] which is 169 and compare it with our input so let’s get the value in memory 4100
     
1
2
3
reg_24 = "CA 99 CD 9A F6 DB 9A CD F6 9C C1 DC DD CD 99 DE C7".split(" ")
flag = "".join( [chr( 169 ^ int(i, 16) ) for i in reg_24] )
	print(flag) # c0d3_r3d_5hutd0wn
648: mov reg[30], 119
654: muli reg[30], reg[30], 6
660: mov reg[28], 0
666: mov reg[29], 1500
672: mov reg[27], 69

678: load reg[25], mem[reg[30]]

684: xor reg[25], reg[25], reg[27]

690: store mem[reg[30]], reg[25]
696: addi reg[30], reg[30], 1
702: addi reg[28], reg[28], 1

708: jle reg[28], reg[29] ? 678 : 714

dead ?????????????????
dead ?????????????????
dead ?????????????????
EDIT THIS PART IN THE SCRIPT
1
2
if bc[0] >= 0x19: # DEAD
    bc = [(i ^ 0x45) for i in bc if i != 0]
  
Let’s continue our analysis for the byte code. reg[30] = 119 then reg[30] *= 6 which is 714 and load this to reg[25]

It XOR with reg[27] which is 69 and store the byte code in address 1500. That’s why we got a dead ?????? code

RESULT

1020: mov reg[30], 4400
1026: mov reg[31], 4600
1032: mov reg[26], 0
1038: mov reg[27], 35
1044: load reg[20], mem[reg[30]]
1050: push reg[31]
1056: pop reg[15]
1062: add reg[15], reg[15], reg[28]
1068: load reg[16], mem[reg[15]]

1074: xor reg[20], reg[20], reg[16]

1080: store mem[reg[30]], reg[20]
1086: addi reg[26], reg[26], 1
1092: addi reg[30], reg[30], 1

1098: jle reg[26], reg[27] ? 1044 : 1104 **; loop from 0 -> 35**

1104: addi reg[28], reg[28], 1

1110: jle reg[28], reg[29] ? 918 : 1116

1116: mov reg[30], 4400
1122: mov reg[31], 4700
1128: mov reg[26], 0
1134: mov reg[27], 35
1140: load reg[15], mem[reg[30]]
1146: load reg[16], mem[reg[31]]

1152: je reg[15], reg[16] ? 1206 : 1158 **; loop from 0 -> 35**

1158: putc Wrong!\n
1200: exit()

1206: addi reg[26], reg[26], 1
1212: addi reg[30], reg[30], 1
1218: addi reg[31], reg[31], 1
1224: jle reg[26], reg[27] ? 1140 : 1230

1230: putc Acess granted, shutting down!\n
1416: exit()

finish ?????????????????????

 

1020: mov reg[30], 4400
1026: mov reg[31], 4600
1032: mov reg[26], 0
1038: mov reg[27], 35
1044: load reg[20], mem[reg[30]]
1050: push reg[31]
1056: pop reg[15]
1062: add reg[15], reg[15], reg[28]
1068: load reg[16], mem[reg[15]]

1074: xor reg[20], reg[20], reg[16]

1080: store mem[reg[30]], reg[20]
1086: addi reg[26], reg[26], 1
1092: addi reg[30], reg[30], 1

1098: jle reg[26], reg[27] ? 1044 : 1104 ; loop from 0 -> 35

1104: addi reg[28], reg[28], 1

1110: jle reg[28], reg[29] ? 918 : 1116

1116: mov reg[30], 4400
1122: mov reg[31], 4700
1128: mov reg[26], 0
1134: mov reg[27], 35
1140: load reg[15], mem[reg[30]]
1146: load reg[16], mem[reg[31]]

1152: je reg[15], reg[16] ? 1206 : 1158 ; loop from 0 -> 35

1158: putc Wrong!\n
1200: exit()

1206: addi reg[26], reg[26], 1
1212: addi reg[30], reg[30], 1
1218: addi reg[31], reg[31], 1
1224: jle reg[26], reg[27] ? 1140 : 1230

1230: putc Acess granted, shutting down!\n
1416: exit()

finish ?????????????????????
  
  • Again it check for the user input if it’s greater than or equal 36 it will continue else it will go back for the input [flag length is 36]
  • Store these values
    • reg[20] = mem[4400] → load the value in offset 4400 to reg[20] (which is user input address)
    • reg[21] = mem[4500] → load the value in offset 4500 to reg[21] (???)
  • It seems that it store the input of the user and then go to the next for loop (nested for loop) and swap the value of the user_input with user_input[???]
1
2
3
for i in user_input:
   for j in range(0, 36):
       swap(user_input[i], user_input[???[j]])
  • reg[31] = mem[4600] → load the value in offset 4600 to reg[20] (???)
  • It then XOR with the value in the offset 4600 and compare it with the value in offset 4700
1
2
3
4
5
for i in range(0,36):
	for j in range(0,36):
	    swap(user_input[j], user_input[sbox[j]])
	for j in range(0,36):
	    user_input[j] = user_input[j] ^ xor_val[i]

 
we can decrypt it easily and get the flag

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
f = open("bin", "rb").read()[3:] # escape the first three bytes: UwU

sbox = bytearray(f[4500:][:36])
xor  = bytearray(f[4600:][:36])
enc  = bytearray(f[4700:][:36])

for i in range(35,-1,-1):
    for j in range(36):
        enc[j] ^= xor[i]
    for j in range(35,-1,-1):
        enc[j], enc[sbox[j]] = enc[sbox[j]], enc[j]

print(enc)
🚩 HTB{5w1rl_4r0und_7h3_4l13n_l4ngu4g3}
Share on

Ahmed Raof. AKA 50r4.
WRITTEN BY
Ahmed Raof
📚Learner🤓Nerd🔫reverse engineering👾malware analysis🔒cryptography