Overview
Challenge | Difficulty | Points | Category | Flag |
---|---|---|---|---|
Reverse100-1 | easy | 100 | reversing | poctf{uwsp_1n_w1n3_7h3r3_15_7ru7h} |
Reverse100-2 | easy | 100 | reversing | poctf{uwsp_d0_0r_d0_n07} |
Reverse100-3 | easy | 100 | reversing | poctf{uwsp_br3v17y_15_7h3_50u1} |
Reverse200-1 | Easy | 200 | reversing | poctf{uwsp_4_7h1n6_0f_b34u7y} |
Reverse200-2 | Easy | 200 | reversing | poctf{uwsp_7h3_n16h7_15_d4rk} |
Reverse200-3 | Easy | 200 | reversing | poctf{uwsp_1_4m_7h3_0c34n} |
Reverse300-1 | Easy | 300 | reversing | poctf{uwsp_7h3_w0rld_15_4_57463} |
Reverse300-2 | Medium | 300 | reversing | poctf{uwsp_4b4nd0n_4ll_h0p3} |
Reverse300-3 | Medium | 300 | reversing | poctf{uwsp_7h3_g4m3_15_4f007} |
Reverse400-1 | Hard | 400 | reversing | poctf{uwsp_4ll_7h47_gl1773r5} |
Reverse100-1
Highlighted techniques
- how to patch files in ghidra
- how to retype strings to make them readable in ghidra
- small gdb tutorial on how to set a breakpoint and skip the function with
jump
Learning the game
We are presented with a file called Reverse100-1
. I run the file
command against it:
Reverse100-1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=9604fd8c1b649b4686112951a38c3b7280449fc5, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
## ./Reverse100-1
Encoded flag: ΑΑ̠ȗ̠̍ʠȍȗ
The program seems to be printing out the flag after it has had some sort of transformation applied, time to put on the gloves.
Playing the game
Alright time to get a bit more serious, I’ll be showcasing two ways to solve this, by performing dynamic and static analysis.
For the sake of understanding we’ll start by looking at the program through the eyes of the dragon (ghidra
, you guessed correctly)
We saw from the file
command that this program is not stripped so ghidra should have plenty of information to work with.
decompiled “main” function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
undefined8 main(void)
{
undefined8 local_38;
undefined8 local_30;
undefined8 local_28;
undefined7 local_20;
undefined4 uStack_19;
local_38 = 0x77757b6674636f70;
local_30 = 0x31775f6e315f7073;
local_28 = 0x33723368375f336e;
local_20 = 0x7572375f35315f;
uStack_19 = 0x7d6837;
obfuscate(&local_38);
printf("Encoded flag: %s\n",&local_38);
return 0;
}
Hmm… Okay so let’s break it down:
- first of all we can see a bunch of weird variables with unrecognized types being defined, we’ll come back to it later
- after that we have a call to
obfuscate()
with a pointer tolocal_38
(one of our weird variables) being passed as an argument - then we have the following call
printf("Encoded flag: %s\n",&local_38);
So we can clearly guess that the last two lines we identified are obfuscating/encoding the flag and then printing it, also the obfuscation is performed in place since both the obfuscation function and the print function receive the same pointer.
Let’s solve the mistery of those weird variables, you may have already guessed that they are actually meant to be a single variable, a string, the flag. Why does ghidra show us a long string like this? Well because the disassembly looks like this:
As you can see there are a bunch of weird values being moved into registers, ghidra sees each of these as a new variable, but we can solve that.
How to spoon feed a string to Ghidra
- Right click the first variable generated and look for the “Retype Variable” option (or do Ctrl+L)
- Retype it to
char[n]
wheren
is the amount of characters in the desired string, to get that you can add up how many bytes each of the variables you want to merge is using, but in this case since we only have this string as a variable we can check the function prologue to know how many bytes we need (remember that values in the disassembly are in hex)
It’s not perfect but a lot more readable than before, with this we could already reconstruct the entire flag by hand.
What if I don’t want to rebuild the flag like a LEGO?
Okay then you could create a script for ghidra that gets the variable and reconstructs it… Okay let’s just see how to patch the program to make the obfuscation never happen.
- We go to the call to
obfuscate
in the disassembly and look for thePatch Instruction
option (or use Ctrl+shift+G)
- We’ll patch it so it becomes a
NOP
instruction, I don’t know if there’s aNOP
instruction that is long enough but I just added two of them
after this we can export the program and run it.
- press
O
to export the program, select “Original File”, change it’s name and then click “Ok”
- after this you can run your patched file.
Extra: How to do it all from the terminal and feel like a superhero
Lastly I’ll show you how to solve this from a linux terminal (bash) using gdb
(dynamic analysis).
- run the program with
gdb
1
gdb Reverse100-1
- set a breakpoint in the
obfuscate
function (we can do this since the program has symbols left on it)
- Run the program and let it hit the breakpoint, let them come to us.
- Now, enter the matrix by enabling the disassembly layout
layout asm
- From here we can see everything, and by everything I mean that we can see the instruction we are currently about to execute and what follows. Let’s show a bit more of our power and
jump
straight to the prologue of the function, ignoring the rest of the instructions.
BOOM!!
Got ‘em, they don’t even know what hit them ;)
Flag
poctf{uwsp_1n_w1n3_7h3r3_15_7ru7h}
Reverse100-2
Hightlighted techniques
- symbolic execution with angr
Learning the game
We are presented with a file called Reverse100-2
. I run the file
command against it:
Reverse100-2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=48d49fdc0741aa19b895d5fec898bd484d2eb49e, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
3
## ./Reverse100-2
Enter the password: asdasd
Access denied!
The program asks for a password and later displays the Access denied!
text.
Playing the game
Okay so this structure of challenge tempts me to go for an easy angr solve, but then when I looked at the output of the strings
command looking for the target text to use for stdout I see this:
1
2
3
4
5
6
7
8
9
10
...
poctf{uwH
sp_d0_0rH
p_d0_0r_H
d0_n07}
Enter the password:
%99s
Access granted!
Access denied!
...
sooo, the flag seems to already be there, this is that string fixed in a single line:
poctf{uwsp_d0_0rp_d0_0r_d0_n07}
Hm, looks kinda weird, we can probably guess the flag from here but let’s just do a simple angr script to make sure.
1
2
3
4
5
6
7
import angr
project = angr.Project("Reverse100-2", auto_load_libs=False)
simgr = project.factory.simgr()
print(simgr.explore(find=lambda state: b"granted" in state.posix.dumps(1)).found[0].posix.dumps(0))
1
b'poctf{uwsp_d0_0r_d0_n07}\x00\x00\x00\x00...\x00'
It works!! (with some extra trailing \x00
)
Flag
poctf{uwsp_d0_0r_d0_n07}
Reverse100-3
Learning the game
We are presented with a file called Reverse100-3
. I run the file
command against it:
Reverse100-3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=a372e4d793f897e5a6c3382036f11a4a12f029a2, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
3
## ./Reverse100-3
Encoded flag: Flag after reverse step 0: 8e79a99cacd5c5c7917aa58ab88dc6815583a5597bb987b851697b58bb8bcd
Decode function not added yet!Decoded flag (plaintext in hex): Flag after reverse step 0: 8e79a99cacd5c5c7917aa58ab88dc6815583a5597bb987b851697b58bb8bcd
We are also provided with the following source code
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
#include <stdio.h>
#include <string.h>
// Convert each byte of the flag to hex and print it
void print_flag_hex(unsigned char *flag, int length, int step) {
printf("Flag after reverse step %d: ", step);
for (int i = 0; i < length; i++) {
printf("%02x", flag[i]); // Print each byte in hexadecimal
}
printf("\n");
}
// Reverse the modification of the flag bytes based on the seed
void reverse_modify_flag(unsigned char *flag, unsigned int seed) {
int length = strlen((char *)flag);
for (int i = 0; i < length; i++) {
flag[i] = (flag[i] - (seed % 10)) % 256; // Reverse each byte modification
seed = seed / 10;
if (seed == 0) {
seed = 88974713; // Reset seed if it runs out
}
}
}
int main() {
unsigned char encoded_flag[] = { 0x8e, 0x79, 0xa9, 0x9c, 0xac, 0xd5, 0xc5, 0xc7, 0x91, 0x7a, 0xa5, 0x8a, 0xb8, 0x8d, 0xc6, 0x81, 0x55, 0x83, 0xa5, 0x59, 0x7b, 0xb9, 0x87, 0xb8, 0x51, 0x69, 0x7b, 0x58, 0xbb, 0x8b, 0xcd};
unsigned int seed = 88974713;
int length = sizeof(encoded_flag);
printf("Encoded flag: ");
print_flag_hex(encoded_flag, length, 0);
// Reverse the modifications 10 times (finish this!)
printf("Decode function not added yet!");
printf("Decoded flag (plaintext in hex): ");
print_flag_hex(encoded_flag, length, 0); // Print final decoded flag in hex
return 0;
}
Playing the game
Seems like we just need to modify the source code to call the reversing function in a loop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
int main() {
unsigned char encoded_flag[] = { 0x8e, 0x79, 0xa9, 0x9c, 0xac, 0xd5, 0xc5, 0xc7, 0x91, 0x7a, 0xa5, 0x8a, 0xb8, 0x8d, 0xc6, 0x81, 0x55, 0x83, 0xa5, 0x59, 0x7b, 0xb9, 0x87, 0xb8, 0x51, 0x69, 0x7b, 0x58, 0xbb, 0x8b, 0xcd};
unsigned int seed = 88974713;
int length = sizeof(encoded_flag);
printf("Encoded flag: ");
print_flag_hex(encoded_flag, length, 0);
for(int i = 0; i < 10; i++){
reverse_modify_flag(encoded_flag, seed);
printf("Decoded flag (plaintext in hex): ");
print_flag_hex(encoded_flag, length, i); // Print final decoded flag in hex
}
return 0;
}
We compile and run this…
1
2
3
##.\a.exe
Encoded flag: Flag after reverse step 0: 8e79a99cacd5c5c7917aa58ab88dc6815583a5597bb987b851697b58bb8bcd
Decoded flag (plaintext in hex): Flag after reverse step 10: 706f6374667b757773705f627233763137795f31355f3768335f353075317dcf00000079a54d050a0000008000400010ff610054ff610088124000010000008815c1008822c100fdffffff020000000000000054ff6100cd88c5750070360084ff6100f512400001000000000000000000000000000000000000000000000000000000a97bd97500703600907bd975dcff6100cbc0cd770070360081430b0d000000000000000000703600000000000000000000000000000000000000000000000000000000000000000000000000
Then copy whatever this is into cyberchef to get a string
Flag
poctf{uwsp_br3v17y_15_7h3_50u1}
Reverse200-1
Hightlighted techniques
- ghidralib for semi-automatic string extraction
Learning the game
We are presented with a file called Reverse
. I run the file
command against it:
Reverse200-1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ee89e6f8d8bc723c2eabc56f150f344af85be5f3, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
## ./Reverse200-1
Obfuscated Flag (Hex): 73 0b 22 21 1e 1c 11 22 21 73
seems to print the obfuscated flag
Playing the game
I opened the file in ghidra and modified it a bit to make it more readable
The actual flag seems to be redacted but there’s a call to a obfuscate()
function and then every byte of the string is being printed in hex
This is the code of the obfuscate()
function:
There’s also a deobfuscate()
function, but it seems to not do anything
Reading the assembly for this function we can see that it is simply building a string in a variable and then not using it for anything, that’s why the decompiler is not producing any code
Let’s first create the reversed obfuscate function and then come back to what the flag is. The obfuscate()
function seems to simply do a xor and add a constant to the result, so we just reverse that by subtracting the same constant and performing the same xor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def main():
encoded_flag = ""
decoded_flag = ""
for char_i in range(0, len(encoded_flag), 2):
try:
current_char = encoded_flag[char_i: char_i + 2]
except IndexError:
current_char = encoded_flag[char_i:]
decoded_char = chr((int(current_char, 16) - 0x03) ^ 90)
print(decoded_char)
decoded_flag += decoded_char
print(decoded_flag)
Now let’s fill the encoded_flag
variable
From the assembly we had before we can just get the full string, but there’s an important thing to take into account, not all MOVs are performed equally The second to last MOV actually overwrites some of the characters from the one done before (at 0x0040121d
)
To avoid any mistake when copying or transforming the string, it’s better to do it in an automated way, so for this I will be showcasing ghidralib.
You can go read the documentation for a more in depth tutorial, but basically I will:
- Use the emulator to emulate the desired function from
0x004011c5
to0x0040123d
- get the value from the stack
so the variable that the function builds seem to be 0x48
long but values are only loaded up to RBP - 0x40
.
We can put this result into our script anddddd…
Flag
poctf{uwsp_4_7h1n6_0f_b34u7y}
Reverse200-2
Hightlighted techniques
- symbolic execution doesn’t work
- still using angr to get data dynamically
- reversing said data
Learning the game
We are presented with a file called Reverse200-2
. I run the file
command against it:
Reverse200-2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=11b0463ea4dbbd923c4513ffb7da9e6d5bf1cfb0, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
3
## ./Reverse200-2
Enter the correct input: asdfasdf
Incorrect input. Try again.
Asks for an input and then says it’s incorrect
Playing the game
I tried going for the simple angr technique, but it is not able to solve the correct input, so I used angr but not to directly get the correct input
Looking at the file in ghidra we find that there’s a function check_input()
which does the following
- creates a variable with a static value
- gets user input into another variable
- transforms user input with a call to
transform()
- compares the transformed user input to the first variable
We quickly realize that the transformed user input has to match the static variable which we already have so the following steps are:
- reverse the
transform()
function - apply the inverse of
transform()
tolocal_28
The transform()
function is really simple, for every character, it XORs it with 0x3f
and then adds 5
I made Python script to reverse this
1
2
3
4
5
6
def solve(encoded_flag: bytes):
decoded_flag = ""
for b in encoded_flag:
decoded_b = chr((b - 5) ^ 0x3f)
decoded_flag += decoded_b
return decoded_flag
Now we need to get the static byte string from the program, for this I will show how you can do it in an interesting way using angr.
Get values from memory using angr
First we need to get the assembly of the program, for that we will use objdump
objdump -d Reverse200-2 > dump.s
In the assembly we look for the memory address where the desired string is already stored in memory. To achieve this I looked for the call to strcmp
so I can read the value from the argument
...
401236: 48 8d 45 c0 lea -0x40(%rbp),%rax
40123a: 48 89 c7 mov %rax,%rdi
40123d: e8 34 ff ff ff call 401176 <transform>
401242: 48 8d 55 e0 lea -0x20(%rbp),%rdx
401246: 48 8d 45 c0 lea -0x40(%rbp),%rax
40124a: 48 89 d6 mov %rdx,%rsi
40124d: 48 89 c7 mov %rax,%rdi
401250: e8 1b fe ff ff call 401070 <strcmp@plt>
401255: 85 c0 test %eax,%eax
...
We see that pointers to the arguments are stored in rsi
and rdi
, to know which is which we can also see a bit further up that the argument to transform()
(which is the user input) is stored in rbp - 0x40
, a pointer to this is later being moved to rax
and finally to rdi
before strcmp()
, meaning that the other argument is the static string local_28
1
2
3
4
5
6
7
8
9
10
11
12
13
import angr
def get_encoded_flag():
project = angr.Project("Reverse200-2", auto_load_libs=False)
simgr = project.factory.simgr()
result = simgr.explore(find=0x401250)
if result.found:
state: angr.SimState = result.found[0]
return state.solver.eval(state.memory.load(state.regs.get("rsi"), 29), cast_to=bytes)
I’ll explain the important lines of this script
1
2
3
4
project = angr.Project("Reverse200-2", auto_load_libs=False)
simgr = project.factory.simgr()
result = simgr.explore(find=0x401250)
The first two lines simply get the simulation manager as usual.
The next line looks for a state located in the call to strcmp()
1
2
3
if result.found:
state: angr.SimState = result.found[0]
return state.solver.eval(state.memory.load(state.regs.get("rsi"), 29), cast_to=bytes)
if a state is found, we get the value from memory, lets break up that final line a little bit
1
2
3
rsi_value = state.regs.get("rsi")
unsolved_value = state.memory.load(rsi_value, 29)
return state.solver.eval(unsolved_value, cast_to=bytes)
- the
rsi
register holds a pointer to the string we want, so by doingstate.regs.get("rsi")
we get that pointer as a memory address - we load the value at that address into a variable with
state.memory.load
which takes two arguments- a memory address (we use the pointer from
rsi
here) - an integer representing how many bytes we want to read
- a memory address (we use the pointer from
state.solver.eval
is used to solve the value, sincestate.memory.load
returns aBV
object (there doesn’t seem to be an easier way to read the value as a string even if it is solved)
Here’s the whole code
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
import angr
def get_encoded_flag():
project = angr.Project("Reverse200-2", auto_load_libs=False)
simgr = project.factory.simgr()
simgr.use_technique(angr.exploration_techniques.DFS())
result = simgr.explore(find=0x401250)
if result.found:
state: angr.SimState = result.found[0]
return state.solver.eval(state.memory.load(state.regs.get("rsi"), 29), cast_to=bytes)
def solve(encoded_flag: bytes):
decoded_flag = ""
for b in encoded_flag:
decoded_b = chr((b - 5) ^ 0x3f)
decoded_flag += decoded_b
return decoded_flag
flag = solve(get_encoded_flag())
print(flag)
Flag
poctf{uwsp_7h3_n16h7_15_d4rk}
Reverse200-3
Hightlighted techniques
Learning the game
We are presented with a file called Reverse
. I run the file
command against it:
Reverse200-3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=932c681f4a9c2be824bce27856fe7ee8212bb7f1, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
3
## ./Reverse200-3
Enter the correct input: sadfsa
Incorrect input! Try again.
Playing the game
To begin this challenge I used the strings
command and found a few interesting things
1
2
3
4
5
6
7
8
9
10
11
12
13
...
H=@@@
*REDACTEH
optc{fwuH
ps1_4__mH
_4__mh7_H
3c043}n
Correct!
Enter the correct input:
%29s
Incorrect input! Try again.
;*3$"
...
So we have what looks like the flag but a bit scrambled, this is the fixed string
optc{fwups1_4__m_4__mh7_3c043}n
it looks like the characters are switched in pairs of two, another clue of this is what we get when trying to solve this with angr
looks like the *REDACTED*
string with characters switched, so let’s try that.
I got: poctf{uwsp_1_4m_4___hm_7c340}3n
Hmmm, so it looks like the flag starts fine, but then it kinda does not make sense.
What I realize is that the last n
should switch places with the }
but it is not doing so, this gives me an idea, let’s replace half the characters starting from the left and the other half from the right
poctf{uwsp_1_4m_7h3_0c34n}
Nicee it worked, here’s the final script used.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
scrambled_flag = "optc{fwups1_4__m_4__mh7_3c043}n"
def unscramble(flag):
unscrambled_left = ""
unscrambled_right = ""
for i in range(0, len(flag) // 2, 2):
unscrambled_left += "".join(reversed(flag[i:i + 2]))
for i in range(len(flag)+1, (len(flag) // 2) + 6, -2):
unscrambled_right = flag[i:i-2:-1] + unscrambled_right
return unscrambled_left + unscrambled_right
print(unscramble(scrambled_flag))
Flag
poctf{uwsp_1_4m_7h3_0c34n}
Reverse300-1
Hightlighted techniques
- symbolic execution with angr
Learning the game
We are presented with a file called Reverse300-1
. I run the file
command against it:
Reverse300-1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cd45573f4bd7b1d2d713912994eec4d881dfb71f, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
3
## ./Reverse300-1
Enter the key to decrypt the flag: sadfasdf
Incorrect key length. Key must be 22 characters long.
Playing the game
Let’s check the functions in an objdump disassembly.
1
2
3
4
5
6
7
8
9
10
...
00000000004012e0 <decrypt_flag>:
4012e0: 55 push %rbp
4012e1: 48 89 e5 mov %rsp,%rbp
4012e4: 48 83 ec 40 sub $0x40,%rsp
4012e8: 48 89 7d c8 mov %rdi,-0x38(%rbp)
4012ec: 48 b8 14 0c 0b 12 11 movabs $0x16302911120b0c14,%rax
4012f3: 29 30 16
4012f6: 48 ba 14 05 0f 7d 50 movabs $0x212f12507d0f0514,%rdx
...
We see that there’s a decrypt_flag()
function which is probably called when the password is correct, so let’s use angr to look for a state at the start of this function and then get stdin
as a string
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import angr
def get_key():
project = angr.Project("Reverse300-1", auto_load_libs=False)
simgr = project.factory.simgr()
result = simgr.explore(find=0x4012e0)
if result.found:
state: angr.SimState = result.found[0]
return state.posix.dumps(0)
print(get_key())
This outputs: b'dchfwREaguPJ8!pV*^U&Ms'
okay then, let’s use that as our password
1
2
3
# ./Reverse300-1
Enter the key to decrypt the flag: dchfwREaguPJ8!pV*^U&Ms
The flag is: poctf{uwsp_7h3_w0rld_15_4_57463}
Flag
poctf{uwsp_7h3_w0rld_15_4_57463}
Reverse300-2
Hightlighted techniques
- procmon
- using burpsuite as proxy
Learning the game
We are presented with a file called Reverse
. I run the file
command against it:
Reverse300-2.exe: PE32+ executable (console) x86-64, for MS Windows, 10 sections
1
2
3
## ./Reverse300-2.exe
Fetching the flag from a secure source...
Success!
Playing the game
Okay so I got kinda stuck in the beginning for this one I’ll admit, I tried looking at the program in ghidra and looking for where the Success!
string was being printed, but I couldn’t find it.
Finally, I decided that the “secure source” from where the flag was being gotten had to be one of two:
- a hidden file created at runtime
- some remote server
So I looked at what the program does with procmon.
After applying a few filters to make it easier we can see that the program is clearly performing some http request, so I will use burpsuite to intercept the data for this request
Flag
poctf{uwsp_4b4nd0n_4ll_h0p3}
Reverse300-3
Hightlighted techniques
- a bit harder ghidra reveng
Learning the game
We are presented with a file called Reverse300-3
. I run the file
command against it:
Reverse300-3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=769e50127c2c2acfc39695dd52a429b3b7f510be, for GNU/Linux 3.2.0, not stripped
it seems to be an ELF file, so I’ll be running it with my docker debian container.
1
2
3
4
5
## ./Reverse
Memory initialized. Encoded flag loaded.
Decoding the flag...
octf{
Done.
Playing the game
Initial static analysis
Okay so let’s open up the file in ghidra, all the code I show here has been modified a bit for better understanding
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
undefined8 main(void)
{
char local_38 [32];
local_38[0] = '\x01';
local_38[1] = '\0';
local_38[2] = '\x05';
local_38[3] = '\x01';
local_38[4] = '\x06';
local_38[5] = '\0';
local_38[6] = '\x01';
local_38[7] = '\x01';
local_38[8] = '\x05';
local_38[9] = '\x02';
local_38[10] = '\x06';
local_38[0xb] = '\0';
local_38[0xc] = '\x01';
local_38[0xd] = '\x02';
local_38[0xe] = '\x05';
local_38[0xf] = '\x03';
local_38[0x10] = '\x06';
local_38[0x11] = '\0';
local_38[0x12] = '\x01';
local_38[0x13] = '\x03';
local_38[0x14] = '\x05';
local_38[0x15] = '\x04';
local_38[0x16] = '\x06';
local_38[0x17] = '\0';
local_38[0x18] = '\x01';
local_38[0x19] = '\x04';
local_38[0x1a] = '\x05';
local_38[0x1b] = '\x05';
local_38[0x1c] = '\x06';
local_38[0x1d] = '\0';
local_38[0x1e] = -1;
local_38[0x1f] = '\0';
initialize_memory();
execute_vm(local_38,32);
return 0;
}
So in our main function we have:
- a byte string
local_38
- a call to
initialize_memory()
without any arguments - a call with the following arguments
execute_vm(local_38,32)
let’s take a look at that initialize_memory()
function
Okay so it seems that it is just loading the encoded flag into a static region of memory, presumably to later be decoded (at least partially) by the execute_vm()
function
So now let’s see what the execute_vm()
function does
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
void execute_vm(long instructions,ulong param_2)
{
byte current_value;
ulong i;
long iplus1;
byte operand;
byte operator;
i = 0;
current_value = 0;
puts("Decoding the flag...");
while( true ) {
if (param_2 <= i) {
return;
}
iplus1 = i + 1;
operator = *(byte *)(i + instructions);
i = i + 2;
operand = *(byte *)(iplus1 + instructions);
if (6 < operator) break;
switch(operator) {
case 1:
current_value = memory[(int)(uint)operand];
break;
case 2:
memory[(int)(uint)operand] = current_value;
break;
case 3:
current_value = current_value + memory[(int)(uint)operand];
break;
case 4:
current_value = current_value - memory[(int)(uint)operand];
break;
case 5:
current_value = current_value ^ memory[(int)(uint)operand];
break;
case 6:
putchar((uint)current_value);
break;
default:
goto switchD_004011f2_caseD_6;
}
}
if (operator == 0xff) {
puts("\nDone.");
return;
}
switchD_004011f2_caseD_6:
printf("Unknown instruction: %02x\n",(ulong)operator);
return;
}
Okay so a lot is going on but if we follow the function arguments we can realize a few things to begin with:
param_2
is only used to know for how long to run the while loop- the first parameter (renamed
instructions
) is iterated in batches of two bytes - we can indeed see that the symbol
memory
(where the flag is stored) is being used in some way
After looking at it for a while we realize that we have kind of a custom instructions language. I’ll leave the realizing how it works exactly as an exercise for the reader ;)
Instruction set
Instructions come in pairs
- there is a single register which I’m calling
current_value
- everything is relative to the
memory
symbol address
Operators
We have six different possibilities for an operator
- 1 : copy value at operand offset
- 2 : set value at operand offset equal to
current_value
- 3 : add current value to the value at operand offset and set it
- 4 : sub current value to the value at operand offset and set it
- 5 : xor current value to the value at operand offset and set it
- 6 : print current value
How is the flag decoded?
Now that we have the knowledge of how this internal instruction set works, let’s figure out the flag’s encoding.
We know that the execute_vm
is getting a static set of instructions stored at local_38
in main()
, let’s analyse it and see what it does.
Here’s the full program:
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
local_38[0] = '\x01';
local_38[1] = '\0';
local_38[2] = '\x05';
local_38[3] = '\x01';
local_38[4] = '\x06';
local_38[5] = '\0';
local_38[6] = '\x01';
local_38[7] = '\x01';
local_38[8] = '\x05';
local_38[9] = '\x02';
local_38[10] = '\x06';
local_38[0xb] = '\0';
local_38[0xc] = '\x01';
local_38[0xd] = '\x02';
local_38[0xe] = '\x05';
local_38[0xf] = '\x03';
local_38[0x10] = '\x06';
local_38[0x11] = '\0';
local_38[0x12] = '\x01';
local_38[0x13] = '\x03';
local_38[0x14] = '\x05';
local_38[0x15] = '\x04';
local_38[0x16] = '\x06';
local_38[0x17] = '\0';
local_38[0x18] = '\x01';
local_38[0x19] = '\x04';
local_38[0x1a] = '\x05';
local_38[0x1b] = '\x05';
local_38[0x1c] = '\x06';
local_38[0x1d] = '\0';
local_38[0x1e] = -1;
local_38[0x1f] = '\0';
I’ll reformat it, so we can see the instructions with their operator and operand.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
copy 0
xor 1
print 0
copy 1
xor 2
print 0
copy 2
xor 3
print 0
copy 3
xor 4
print 0
copy 4
xor 5
print 0
-1 0
a bit better, now I’ll “disassemble” this, so it looks even better, keep in mind that operands are always offsets of memory
We can see a pattern here, I’ll guide you through the first few lines:
- copy value at offset 0 into the register
- xor the value at the register with the value at offset 1
- print the value at the register
then we do the same four more times increasing the offsets by two.
So the pattern is pretty simple, the encoding on the flag is a xor of the characters in pairs of two.
Get the full encoded flag and reverse it
To get the string I copied it as “Python byte string” in ghidra
Then with this script we do the reversing
1
2
3
4
5
6
7
8
9
10
11
12
def decode_key(encoded_key: bytes):
decoded_key = ""
for i in range(len(encoded_key)):
try:
decoded_key += chr(encoded_key[i] ^ encoded_key[i+1])
except:
pass
return decoded_key
encoded_key = b'\x70\x1f\x7c\x08\x6e\x15\x60\x17\x64\x14\x4b\x7c\x14\x27\x78\x1f\x2b\x46\x75\x2a\x1b\x2e\x71\x45\x23\x13\x23\x14\x69'
print(decode_key(encoded_key))
for some reason we don’t get the first char: octf{uwsp_7h3_g4m3_15_4f007}
but that’s okay, we know that after all
Flag
poctf{uwsp_7h3_g4m3_15_4f007}
Reverse400-1
Hightlighted techniques
Learning the game
We are presented with a file called Reverse
. I run the file
command against it:
Reverse400.exe: PE32+ executable (console) x86-64, for MS Windows, 6 sections
1
2
## ./Reverse400.exe
Encrypted flag: bd7e9dad4a5fe0e7911f93cb1bf5a321
From the icon we can figure out that this is a compiled Python program
Playing the game
Decompile Python
To do this there are actually two steps:
- extraction
- decompiling
Extracting the executable
To extract the .pyc files I used pyinstxtractor, but there’s a web version that lets you upload an executable and returns the extracted files if you don’t want to download stuff now.
this yielded a folder with a bunch of files, from those I chose one that caught my attention.
Decompiling Reverse400.pyc
For the decompilation I used pycdc
pycdc Reverse400.pyc > Reverse400.pyc.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Source Generated with Decompyle++
# File: Reverse400.pyc (Python 3.11)
pyobfuscate = lambda getattr: getattr.items()()
Il = chr(114) + chr(101)
lI = '[^a-zA-Z0-9]'
lIl = chr(115) + chr(117) + chr(98)
__import__('sys').setrecursionlimit(100000000)
exec(
getattr(__import__("zlib"), "decompress")
(bytes.fromhex(
'789ced5ded6e1b4b8e7d15efaf488947f0fe4de0579817308c8693f8de35e0c417892f66068b7df7952c75abbbc8734856557f48967167d2eaaa220fc94316ab25cb4df3f673fbfcf0e3ebf787abe6f3eb7ffe7a5c7d6cd65f9a9be6f6f7ebaf2fbbd1ef4fdf5eb737ba59379f9b9b4dd37c7bf9fed8349b6f2fcdc3af3fbfbdfcfdf3f5cb5eda3f5f7e3e6e2f6f6e9b9bb73bb77b2dcdea6e2b75b5bedefeb77bb5bd7fb3dacbfcbc9dbdbb79bcb3d7f2367170737be36d607dbffe7210dbdcdeddb7d748556be2e7c3f8663f6bf04abc4e87b702d7ebbbf6cee7f6e2637b71ff89c9db2ebe5b1d5e7decd67c6a2fb606edc11e7f6e13abbdd0056e819d23557efae015f46bdbf4c30b9766d3559f68e4faeabad5eb90c97d11ebd53e0049743a4afdefff5d0fef6cf368b7f0e7c38f6d7e248b062bb709960ef793ac8fcfa2ea5be21eefeda2bd5e6f3359a01e2278f9f55d1def4df9f6f2e3afe7c77f5f8b8104510f543b57d75d29190fb77776f6d4a4397373bb2d093702c711c11f4fcf699474030f843c28ebc5e9cfc7d787d7d75f4780ab6d26df716b0626f453339299a185524e6fb5b9b88b6c89c622b811b4ea5a5e508212f752dfe476fb40913fc60b3c5e787ffd561e5abafff9fcf2f5e1f937aa07ddbcffe91783e3ed3f9e5f1e5eaffb779e5e1f457d1bae1920356b7a7e0457ccd1f2e7b8d3adb44d8fe158f7e7b189ca26bae67b1435a2a2b3ca98ea47496b7a9beac7dade72e6ee5ee1d470e7fdfef44bdf7893c9b13d56fcacdc15297fada36b3ceef1432f7c7b7ef8fd7bef87e1c09f6a5bd2faf7af977ff59aeb7ccfe4b78b48623b61e2ee95a2d9f99fae5ddddd935aad885e45cb86b3321def197506599ad940fbf7c7374f85ea96a2b71dfee8b818c10f0535812c5526d7a0897b533319d36779fdf46cab517bba4f05ad7add7677e8271a779ddb9bd08a02f700334fecbaccf75443e1e9da32aa594dd2a395a5876ef6a5904863c62d24a1d65f0a5ab6e587f14a4fd822009c856537ed1f2d4af1ccec464ff5d5f1e9cdb4a52aea527d6dae2347090006cbb75f7367014558deaad1ac32ed5c969a9811f101e382fb0a291abbcb1585b96d3fa8bac22d1243d4b7d2d0f30e9775dd8a6bde1730a2aed6661bd70cea7f89cfe4d2bc1e21bf49a0a629961d2ed61f83ed40b37ffede2be3abe396b6a6aaf6e1df933785eda0c44d4b9f6468fdf487e25ae4ed5dab7f73af3363f720eaf1f9f7e310e01bb49b7a5a526574ce6ed61f2fbfdecc7ffab9eb9b953759408e0c77e2cfdd563cc2a9ba47009b016f13b547ca7732bf77cd40cf9e9b8337f66fd0ed1cc24c59753bd95675f218511cdd36ad0e2d3ff7b8572a0757e2def6ce2029fa9e8270bbb297887b73d5e1b8b4b043d2e1e2f88e5c009f02af15e76cc5616d8a62e97470bd9db58e370fa91a79417346bedb991bfff6c2bddb8b5a531660cebfe846d4df6bc91bbe5ea0a09352cc385c04da3acf9be929ae4e62f58369ef1d8262ff7806756aed1ec8557d1e5753d8ed7e7c7dd8c2ab8bae240b0aeb4d8b860895a01154590521ba62775c28455931e11d583a293e50ee16fdf0a19552678c012ae6963ae198328f7bb3b2aba3dd1078b42fe039b9370fea332de220afdc3113b11d56ce571979232f328ef9f7695b55f7edb33113a8c4ef658919d45a7cd4296485614e1de9e1cd25b6911bcdcb91c7d3f337ff2979b09f0fa5939bd9aea37621ddcbf936690e55f761598ae18b96f7b53ff390192367232767854f1ff31f306a3e7b71e90b3eaf739a2b1eb0149798b2273c53ab1fb640139f1d3a436a9ddfc77e5890b77155f7449d670f81bd6dd427d9f54e31cec3723fe5fc493fed478a7a59b9cc134995b3a3a8bea53b6faede09ce0075db41eff6dbd7946be7c8479c0a7ae4319ed83ad643e8ce0e7251fd68bd94e75ff59955d48e857be2a2679035fc1868994738401ec56b99345b5f482ea639ca97a4975c1a7e683b66561f2e422d90024bb132f3e835db5b49b20b19b349122936db3ee53fc3c70f9505cf330e1765ef1ed5aa325350e9a88d28abfd76ab446cedf37dda56ff2523dbaefccf436b83418ee41ee3c73c8cd7794470b88897607951a79b2b386777b6882e66896f8ad77b8ab3b813c3e1a2566f010fb4537d1246ce2a7d7a661c50c21b6ea5be251c8561a6cdd7ca50aba451c1b23cf61b228e13f6b44f6e666e45cadd95ebb123e4a2cecfaf1b9cb897fd56cc1cdd85e3a2ca5e7834089831fedb0505d41b29372b3d9225d066e9c44a6af6e1a2da0700a58ad0265527b9ca3f0f252fecad64f4676272a94ed0193e3c55502698f1a31f58738fb8f53e8794d9238ffc7e773b2df3bdafca4d50a1adc724a9fcc0679cc794d492291fb064b5d4937fd8bef6c927fc60ae4292b81f088ff129e2c003d39378cc1f37b1eee777f0739611b652f48e55c623aec2cc2d7a07ddf9048b72406d86e6fa1dbe8c3d77ccedb113ddadaef331513258f12dfed20dc08de3c0a0a94f778cd620bf0f57633eee2bab2c56aa96fe0ac1840f6fc22a2b1f20c885f5f062eace37effb1e71a5c895d7fba99accc7af3fd9fd5c6fe7ee57effed9b45fdd7bb87dfcea94fd8d1db211dfd11bfdd38345c637bd99d20df5d18ef6fb00b59c80a9710a706bd859d7d4512addfcee43b932a1950a8493f4f1fd38bf8d30de7bc7f3b04fdba646b20b907be44d71ce10dd573f608cf87c662efec1cc3d4be394e4abdd12effee9be466a339cbbbffa94dede5dca9bc725606cf3f6f72b902e433482a28f6d067f4043d5a52922f6a677553bdfaeae91da8d4b465f5437b81f1eeb57c1fb3f63d6d7315214b8afb1988807f14a43e2090f2903ca4deab6613e1d4625e123104c9dc82cb10848d928773749da511312d8963c8e1b7a47137b1c53ac4a4179cb269b2d34e9218edcd628c3221dda183c7c8cdd0eb27733e4912f3d7c4a01ecde688d3443f6b264caa5da42322d5ea208fd9c6988f25bbf6b0e7ab25a33855083e73d89bbdb398a585b8e44cce86dfad795f45003a5bfe94384496a08e41776e9f99213d832ae5f2f19a05ba419c9f07443a33cef9ee0610830a8a1aea085833bb191310c285dfa10efa9fa22263d9498d91eab04704cf78d7a572341df2c3d3727efae3857b3d203e98a795b4a197acfdf3eb200854d57d6290c33d0f8f6428db94430c95b1265e4054d11e74a61b80cf89e8da9fd39e16dc41076c2430bdd39149d0158bac57a5ccfb8f2173f452f2ee659b0595833aaac5c358cfa943b168a1314ebddbd306bc2ce0ce47a7a27e0cccb23a2be0223f108964627652ad696b3a403b29e673a2a646966c0598abe2f66025bc6f5ebbba27f02c9d8775cbc2789c39939ca1f8cb934e975862e4d7abf3e5c9af49e332e4dbacf4823392e4dba2ee8d2a4f7fe799f5dcae57ddc22a0a39313d832ae5fdf55062490d0d824fd79fa33ee87fc4fecf70981f71aea775ac578c46892192a973e743a678ef447354b2c0252b488f6312ac2533317780099e873ae5c0e748639b306accb674c8d250b64799c7bc86aa195eb3ca1f62f251f89bbdb398a585bcea2ce2681a4439666061c7a47d772c6cc04b68cebd70bfd214492139c7453fdf62f00c0a19380e3958c8386aea50ff1d6a72f62eabe5d536bd31e66628a8052d8c805a444e3495f981289d47953f4499cceb4d6c212e7a68c0e34ee4bf113a53901a9f32e8f6771eeb36463804b3635b7ec2c932e872963c92689de028a443dde0aad5ce709b5ac29f932124f6328e3b42e6751dd24a5bfe94384496a885431e8d2f32527b0655cbfbeab0c482069c4f6705d7300c3da0d8df0b5a9f3bc3382ad34d42d7d88f7427d11539fae529d974f62f97d05a61b796c554992ea719661d82cac0cbd3b758751cf642a72393428594cab50787f8aec88705515bf38c09e693b73f8e77d761a975ebb08e8e8e404b68cebd77795010924ecb8093aee66bc4f3a9d7833cf086f285bfad0a5956fef18c30280460a60a6293a35fbd2ca2fab95d749ed52ceda7fe0189a6d0641ec548ccab713fa9433873943d344b8e9cab680ed10b619bf92fa8d610a088abde5c641b4522516cdf0abd289a88cd2e62649d8021a173ce8b1f9728c6da40b5d9c669ff508b107999b19759648ef8b9ec09671fdfade73403186e4028692d75be9b680bb84e851da3269863923f7a9a9f3bd194585fa45f7858591d7c36dcb3a2e2e646f3734e19f5dc24122e8292ff14aeff6e5d2bf88216560214f4334b52947a53218911401a5af9107480948a1c1fe481d7339365290aa450539c876d3cb938a0928c763a738832e90800ab8a17756b456f5c903d63769f82e4a901231abfbff933467e14a40a628cbd9fd4599740e4aaceec8dfc2a0db67ebba4994d02e016f53ee9df06c1d378e9281c09c068e48bda4d4484a0602634183c1e40bced8998b881807a168d66ee8511d02d32c206ae8389a9d4249e669a74807232173bd7af1c4440a83e7726182823a14666f20ad8d28479564c3966890a7711650504a3e8a888bd5aacf2437866ba30fb9a566686230fb838140b2706a2b661981508cf0aba5c9a7653d8cb71ec3c8e6d69be0ae7ae9507c800e798b8790c3d3193025f12e7e412b20a08b6a2a0dff70856aa201c5b059b3da573006634aaa7b0bcf5096372125081a7a954954a888374f34148be9fb1e62860f42a3ba8bdecf89acaa1c270ee134b7c28a1300b3116e8e6d22edb8bd8a8648f76c467e49a43001360116374a7748fa11672e878b9c6fdbd73d6d900b8c8911ea0845b004372c30ac8fcb91ae2c09d0883849c36a1188188034a9296a36bf36fd30144560ac2965cc5209da833843f1d517a8fe51dd3887adc3af93b1f6fca314be9d04b69b702a1293031d563ac1d149b09807f8105e300b8108f46431123b259864c0746ea564b2449d6bf50262e97dae787cd7abf34998b4f9c2199ab1241e8a33710a7178a65199b5108174d447aa3a15617ee6c5a2752a4141a419ab888d1046b7470da2143b42264ea244cc72dc278a1907f5a86b813d494fe62bc12475775a0028542a638125a241828fd10041e2081aad2858eb504829b11b7158a57393c9362b046223675cfdf4c052010ac9b7b9e2fe808c24d4601e1ad374a62b97d0c04288489941666f69d01740e1cc323906b9e9e81032474846c65a414762f8a8ec62677937aa030f07b2067519be00f63ac64a1f266bac5d07c58f24e0465d31b2254409e6d168fb7f7da5700c4338beca28f16060a13e9fb7f88c6dec705985dd93f222936c2222eeee555538586fb398381af56d1ea31a8fee9afc9a15504530106406fa49728ea31ac8853a3431ec3be7157c332537572273316b7376d8b41c6e8bbbcc4501f7590b2815d920b1d1800e722bb55ef5118ce2900aa814e83cb2d74a8b18161f1cdd708e170c503ee8efcad1cdd259980c9c43519a6078cf3610d32f3e82dc47880456a42aec54acf444e65df5ab36e60adb9a910803f0158fb51c271c25ec50f1c8c5536edf0cd1642903c78889b55894a9be7bb5c88ec55216c9a8912058b5db44180b0a5666882af584e6cbba541477592c8cb1593691996bda10e562b30bc994e250aef5ab71a4aff42fc6cc286d812ca72daa7e6b63a5a496626b5099a10a20aea2dda4ad0744b202c438950b3d3e47e33e9c3075107d56cba9249b7c1951bb2ae87462d604f7110596cf5209b446ee9fb512c22e5d2c4ebb5044d5c9aa13a03e367ed63163e950a7511c2ef3b44d9247cc3b8e983a0a00f042083799ac823945b604948859fc7ea24ef9324cb3bba588b56fd7841b16355d4c88e2a159d2f00fe029ae16e29cbfe9ce382fa4a69395c5f6f776bb5ac79320f772940c04b25d133091649d893fa68ec3a8df95caa91881237ca7db653bb9097053b587190991fdfbb12adc23239dc0f60b84b59385e1477faf13af5033dc656ffa1905570611a639624cc0665a09531c2dd290c67718316ceea6165e5dcb66e83cee48e68b84b9cc9fde3a966d54406ae5a20ad8eb03734af6f7d507f286fa87680e5466ab06c86155818ad44c8040832c97e69c0eb0501a7a8f93dc9668e099760732bd2829c3189522d4184fb7775f34864d446cef892495e1d17a6555b1906f81646ce9ad69d406bb00ccea4fb600720451e424a94313ddb268843d4ff5c85479b1e886c202538d4bc5a165e0432673af07fb9ec0b7572ec7d8da4ae11886707c85fca72cc9488b71529ac2a0cf1254b9e266a64fab256bb6235dc0abf28899011416c4d60702b82f829e9b1578235be5c465eb7380a9964df315e6a56c7dc3091592185bddce3f8dfda82f67dcfd5862457ee8ab55d48435ab82212ab1c69f71c85f420998282d5bc8b382d948339cb094a499bd89e3b3d54c0b745a1a6c5502e37b8a470f44b4b67bca079680e9e71c3514da9e74bc03e4d9d3b8e9f552c512b5cc66595582e7d62991c365e1c644d5a59a7e429e5fc2bb6361bb7919980442aeebc3e5890e2a12b92546b158c216ed4a9ea5511eac54970159c82cca33575d55018a7be84bc49172443ebd1ae6782f525da5e13864f1119436d0340d8ed1edf9582aa7527ea863aa3578ee349b6ff7ea8c7b9f09bf6cadbaa576169f62a7c1dc608ccde4713b0e9c2856750efc8dab64d0cc0bdd691bc56a67cdd265a838a5589bb127ac6420709af48713902e2df066f1d781012f79363385a704a462cf12ea1ab103db67883aede23aa1eec67272282ac98a148f884d5c0143e6e3b69ccaed59249d1798c68c98dd4d5a3337436a0c370969088461a288957aee46b692bb0421b5b2dfb31fe52f73b91af3cdb16ba9f6619eb3dd6d8ca491533967a6a80f04018231b5b1c9a80c81278b78c72d711a3d5db2d1d81b4faa93247641361952ba510b1ff87b126ea72423ceee52998aad342983f526d172547a986f2eeee52ed5bc0ac5d90192ee5683ecd2c8b0270bd2a930703ca26adea14297bb51530cc69f075551da32b05e0527958e99d6bbe5518f7e2184734fa18dc7610af0c16b3d229b74854339528781814a90ed66094c971f28be409fb031c19dead5c4847fe75309558263eaa7f7fd45f6160db86654265db11a809ccccdd0ddbd0a6d07c9e2b482b3e6a0bf3450c27d7859460582aecd4e45abd998b779e73a42f5844c8d18fb0d3aa8b664e90c87d156ddde8935cbf3274d2a7659802a82812033d0abca81ff981505c148e4f2f9d493120515b651e6021392adccb56d9a582b39937edb53984310985a232c4590dbc8ca48c03400c6266019ac694885d6e8d286f3672e452c48c9022817a1c9b09dc351315036e3b19a20b41bf3f5e6d5cdcbf1f1c4bc5a823a120e1c8846326951f5421b8b58cf3453c58bf7820548dc65b135c68a0e038cf081edcd69a5b26a9c62b260c2410b8b193a3bcdc27e77658f39a18efb2d51cb2f3a098cd9bf10c717ddc25c97e085d4749516ae1a6c364d5a148396544bc2ba9555f53afff26f49564c445623e1307664c120259569dc8d0301707d93aa598e92b9d3d5e015b156ad69f378d632126126f74d51975a14a9456e3b75b5268b8cdd54871cfab058118b8158a7ba253d5a9990b4ddab4a9d048c0c033149e6a84af0dcd91b39686171bca66560c4efda7ccaa848ac878b1c05b1bf62587bfaafcc32040c8061d2078067cdba649986b34baa7346dd48674db81a47c98458526a38226c64898459e9a3e4d2be5934dbd6c5573c392ac419844d24b8b93607af1872281b01b239754a5f84c0f50e24c40438841a029dac57e6d22473eee07698e57a835512e9a92a11b3f87d83a7fd151b3b923ed6cba984c5c8164d1fbd3b1cb74a6c327b2a53e118378c086dc72bd6360b105ba621a16a40b03cfeec4ba749a7cb0864a221c56a14a136ccbd3acd8f8fc42e1a943701631b3b5c3653d9088200318befcf269993612bad8a4e98b67a7d4d328fd2356d8085285707d300874a770203eda4537ce1e02613622a9867db566161bae6253d1cc3108eafd056e6a5ca709cef494621e131f4e4d389f6af394a26ddb75c95a49163f69f87c26b7d4359df00eeadaac7c9ce04b2fffae85c511c4e388f62ab8d55f0af166ecddfaa10e91ac01e7cbb7e7d21fae07dae9804c6273577075df06f3e4ab1d40bca68221698902ac7eee4664d17430b0cf7bcaaac38bc4bf8421e6207b48f45cd11d1a9a9a08d4d9268aa120c68119db707cfa480c4fda1048a53cb308cdd1e45197b5c0c4d73b788ceaff2b0a443442400c1fa274213fe624d9b84a21f5c16d717d3292c612b11770d6616f5ef4349dacedcbf45fcb074432f4f85ccfb45093eca87b563d86ab4a3380e36c67020dc52672e9cf3956720481d051352ff599a9616626dcc17006aa905688a7a63895a1a08e7b7f2f3222fce44cbd953cc1c4b90db1df65289546aa99845d1319d1c89aa9e288a985b9b514bef50bc01601131c64afc0ee6abf04ed7ca6aec1a23833d206ba53136b604821c0805bebb37cbce70e64a8219b1917c619dc87065403fd085d6c36e522d533a4dbb9b44248bd3509bdf2e458e8f1e0827c4af0c0898decfffa4989d81d583385834db9e3c491216d84aa0278b4fac19d296247ffc210600d3197f78c46531cdfd34f4aa18191fe9108020b96da612298aa1eecd36ce11735fd4074b429d0b59451419c0d38fdb244e4ba9e540a47b8eeca914a63e15980377675dba87a80d9ecca5d3e89244c3df4bee3d5ec42ca03986073d36eb35cd6977200103dcd1d868c8717dd77e27c0f4af0ec0f21ccd62cb75088a343ec06fecc9f3e524b0655cbfbe2be22790ec0dd2457dcd1f0c7a3b12da4de46d0986e54ce12ec740f9b89941b4a0e2a04d1b820d07daa594d38bf025440a4e6e9b40d6924d123d6ab9e3c32800af293a451af5b7669eb75063c9296cf7a68a446e6a235440e255c2af3805e2b56e7b637dbfbe6e6e87dabb9fd5dd7160b5bedefe0726b6f3d7abe6f6f9e1c7d7ef0f9f51cc8d3c536cf3aecc49ebe50ff10ad917e1f40d245069ed0bc0d22d362a8e313c55add3c41ec742c4566b53c1b64d323dce320c9b8595a177a7ee30ea994c452e8706258bf9ae15edcc435d145a55c52f0eb0228f7c9ce477cd410fa52f0f171ae9421d80e539837b9eae233bd42c39df1727812de3faf55d113f81947a6182f67aff53b1c9ee493d8fde7d119df63b6add35b57672c17c4f11d09430720b2949e1a5c29448a4ce4b312ee674c038a06942d375a0715f8a9fdce4f5602fe05935eea7a38229581697c69c50b21dbb65eb26f585f02ae5085748298c6d375a8367c85e2f27ad259b247a0b283cf15c40560bad5ce70935db29f932124f6328e3b42e67110740d3561518323633e6d04100d3f99213d832ae5fdf7506a0906654d86670d43bfe9cfae90c5b6ce85afa10ef7afa22a63e9bb1c475745d860a096893948305f432ccae7774887223246a755752e2e04144c24b8366f85707e0f01c3409a0429666061c7a47d772c6cc04b68cebd77745ff049251485dbc2789c39939ece5ea3fb9af2b71ffdf970eeeddea987ddbffeda46c848cddfdad86de4c32992c81ebb4d9fd79509a220c6b96f3c15c621a959e2e2593810a433c0466c1e2e1e2abf5956f6beebfac0e078dabd5e1eaeadbcbf7c7cf578fff7e7a5ded2ed7ebd57fafaf9efeb8faf9f27af5ede1f9f9e1ebf3e3eacfc7d787d7d75faba6f9faf7d3f3ebd3cfdf4d737df5e1dbcb8fbf9e9e1f3f5c5ffdf3e5e776e9d5cbaf2b6deae63071bba4697ebc7cfffbf9b169b6ab3e7c585ffdd7edd58776e60797809f0f3f86cb5b18578fcfbf1f0f5056bba4e93b60d565d1feee6af04abe4e7fb6eebb5bb5133e7e6caf3e1d2ed6f79f8202df24b613c2ababc339a2b95f0fe7e678ea7307adbde0807ada0de0abbb7bf75c151af5dac174589b65d9bef0e6c89bf5ff03329e6814'.replace(
'\n', ''))).decode()
)
Reversing the decompiled code
The decompiled code might look a bit menacing at first, but actually all it does is:
- call the
decompress
function in thezlib
module - pipe the result into
exec
So the actual code is compressed, let’s remove the call to exec
and move the result from decompress
to another file
Reversing decompressed code from decompiled code (my head might start to hurt)
Well this is… something.
I tried to reverse this by renaming variables, but that was not a great idea, not only because it took like half an hour to refactor the name of a single variable, but also when it was finally done, the code was not executable anymore.
Oh btw, we can run this and get the same output as before
1
2
3
# python3 really_obfuscated_thing.py
Encrypted flag: bd7e9dad4a5fe0e7911f93cb1bf5a321
So my next thought is that this whole obfuscated mess must be building some code that is at least executable and then running it with exec
or something like that.
So what I tried is hooking exec
by adding the following at the start of the program
1
2
3
4
oexec = exec
def exec(command):
print(command)
oexec(command)
and the output is…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
lllllllllllllll,llllllllllllllI,lllllllllllllIl,lllllllllllllII,llllllllllllIll,llllllllllllIlI,llllllllllllIIl=bytearray,print,enumerate,chr,ord,len,bytes
from Crypto.Cipher import AES as IIIIlIIlIIllII
from Crypto.Util.Padding import pad as IIlIlIIllllIlI
def lIlIIIlllllIlllIll(IlIlIlIIlllIIlIlll):return''.join(lllllllllllllII(llllllllllllIll(A)^42)for A in IlIlIlIIlllIIlIlll)
def lllIIllllIIIIlllll(lIIllllllIlllIIIll,llIllIIlllIlIIllII):B=llIllIIlllIlIIllII;A=lIIllllllIlllIIIll;return A<<B&255|A>>8-B
def IllIlllIIlllIIIIlI(lIlIllIlllIlIlIIIl,IlllIIlIIIllIlIlII,lIIIIllIllllIIIlII):
B=lIIIIllIllllIIIlII;A=IlllIIlIIIllIlIlII;C=lllllllllllllll();E=llllllllllllIlI(A);F=llllllllllllIlI(B)
for(D,G)in lllllllllllllIl(lIlIllIlllIlIlIIIl):H=(A[D%E]+B[D%F])%8;C.append(lllIIllllIIIIlllll(G,H))
return C
def IIIllIlIIIIlIlIlIl(IIIIlllIIIIIlIIIll,llIllIlllIIIlllIll,IIllIlllIIIlIIlIlI):A=IIIIlIIlIIllII.new(llIllIlllIIIlllIll,IIIIlIIlIIllII.MODE_CBC,IIllIlllIIIlIIlIlI);return A.encrypt(IIlIlIIllllIlI(IIIIlllIIIIIlIIIll,IIIIlIIlIIllII.block_size))
def lIlIlIllIlIlIlIIll(lIIIIlIlIIIIIIIIIl):return llllllllllllIIl.fromhex(lIIIIlIlIIIIIIIIIl)
IIllIllIlIllIIIIIl='[redacted]'
IIllIIlIllIlIIlIll='fa21c9c2596099915dbc7845c941c14e81594b5c4f69177cc4059da11e782e0b'
IlllIlIlllIIIIIIll='504f43544632303234'
llIllIIlllIlIIllII='437261636b3430302d58'
IllIllllllIIIllIIl=lIlIIIlllllIlllIll(IIllIllIlIllIIIIIl)
IIllllIlIlIIIIIIll=lIlIlIllIlIlIlIIll(IlllIlIlllIIIIIIll)
if llllllllllllIlI(IIllllIlIlIIIIIIll)<16:IIllllIlIlIIIIIIll=IIllllIlIlIIIIIIll.ljust(16,b'\x00')
IlllIIlIIIllIlIlII=llIllIIlllIlIIllII[:32]if llllllllllllIlI(llIllIIlllIlIIllII)>=32 else llIllIIlllIlIIllII.ljust(32,'0')
lIIIIllIllllIIIlII=llllllllllllIIl.fromhex(IlllIIlIIIllIlIlII)
lllllIIllllIlIllll=IllIlllIIlllIIIIlI(IllIllllllIIIllIIl.encode('utf-8'),IIllllIlIlIIIIIIll,lIIIIllIllllIIIlII)
IIIlIIllllIlllIlII=IIIllIlIIIIlIlIlIl(lllllIIllllIlIllll,IIllllIlIlIIIIIIll,lIIIIllIllllIIIlII)
llllllllllllllI('Encrypted flag:',IIIlIIllllIlllIlII.hex())
__import__('sys').exit()
Reversing the deobfuscated code from the decompressed code from the decompiled code (my head 100% hurts now)
Okay this at least I can work with, after a long session of renaming and refactoring I ended up with this
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
# bytearray,print,enumerate,chr,ord,len,bytes=bytearray,print,enumerate,chr,ord,len,bytes
from Crypto.Cipher import AES as AES
from Crypto.Util.Padding import pad as pad
def xor42(data_string):
return ''.join(chr(ord(A) ^ 42) for A in data_string)
def shifts_masks_sub(data,
param2):
ret = data << param2 & 255 | data >> 8 - param2
return ret
def three_way_encode(param1, key, iv):
bytesarray = bytearray()
param2_len = len(key)
param3_len = len(iv)
for (i, data) in enumerate(param1):
weird_mod_8 = (key[i % param2_len] + iv[i % param3_len]) % 8
bytesarray.append(shifts_masks_sub(data, weird_mod_8))
return bytesarray
def encrypt_flag(encoded_flag, key, param3):
cipher = AES.new(key,
AES.MODE_CBC,
param3)
return cipher.encrypt(
pad(encoded_flag, AES.block_size))
def bytesfromhex(hex_string): return bytes.fromhex(hex_string)
redacted = '[redacted]'
IIllIIlIllIlIIlIll = 'fa21c9c2596099915dbc7845c941c14e81594b5c4f69177cc4059da11e782e0b'
key = '504f43544632303234' # POCTF2024
iv = '437261636b3430302d58' # Crack400-X
redacted_xor42 = xor42(redacted)
key_bytes = bytesfromhex(key)
if len(key_bytes) < 16:
key_bytes = key_bytes.ljust(16, b'\x00')
iv_32 = iv[:32] if len(iv) >= 32 else iv.ljust(32, '0')
iv_32_bytes = bytes.fromhex(iv_32)
encoded_flag = three_way_encode(redacted_xor42.encode('utf-8'), key_bytes, iv_32_bytes)
encrypted_flag = encrypt_flag(encoded_flag, key_bytes, iv_32_bytes)
print('Encrypted flag:', encrypted_flag.hex())
__import__('sys').exit()
Key things to notice
- the
IIllIIlIllIlIIlIll
variable is never used (probably the encrypted flag hidden in there) - There is encryption but the whole flag encryption process takes three steps
- Every character is XORed with decimal 42
- Some form of encoding done in the
three_way_encode()
function - AES encryption
The first and last steps are easy to reverse since we already have the key and iv values for the encryption.
How does the “three way encoding” work? (btw I only called it that because it takes three parameters)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def shifts_masks_sub(data,
param2):
ret = data << param2 & 255 | data >> 8 - param2
return ret
def three_way_encode(param1, key, iv):
bytesarray = bytearray()
param2_len = len(key)
param3_len = len(iv)
for (i, data) in enumerate(param1):
weird_mod_8 = (key[i % param2_len] + iv[i % param3_len]) % 8
bytesarray.append(shifts_masks_sub(data, weird_mod_8))
return bytesarray
three_way_encode
analysis:
- a for loop is initiated iterating over the flag, inside this loop:
- the variable
weird_mod_8
is constructed with data from thekey
andiv
, since it does not depend on the flag, we can reconstruct it shifts_masks_sub
is called passing it data from the current index of the encoded flag and theweird_mod_8
value- the result of the previous call is appended to
bytesarray
- the variable
bytesarray
is returned
shifts_masks_sub
analysis:
param2
is a number between 0 and 7- data is split at
param2
index - the left side is moved to the right
- the right side is moved to the left
- mixed data is returned
Finally, build a script to reverse this mess
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
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
def decrypt_flag(encrypted_flag, key, iv):
cipher = AES.new(key,
AES.MODE_CBC,
iv)
return cipher.decrypt(pad(encrypted_flag, AES.block_size))
def undo_shifts_masks_sub(ret, param2):
data = ret << (8 - param2) & 255 | ret >> param2
return data
def three_way_decode(encoded_flag, key, iv):
decoded_flag = ""
key_len = len(key)
iv_len = len(iv)
for (i, data) in enumerate(encoded_flag):
weird_mod_8 = (key[i % key_len] + iv[i % iv_len]) % 8
decoded_flag += chr(undo_shifts_masks_sub(data, weird_mod_8))
return decoded_flag
def xor42(data_string):
return ''.join(chr(ord(A) ^ 42) for A in data_string)
if __name__ == "__main__":
key = bytes.fromhex('504f43544632303234')
iv_str = '437261636b3430302d58'
if len(key) < 16:
key = key.ljust(16, b'\x00')
iv_32 = iv_str[:32] if len(iv_str) >= 32 else iv_str.ljust(32, '0')
iv = bytes.fromhex(iv_32)
encrypted_flag_hex = bytes.fromhex("fa21c9c2596099915dbc7845c941c14e81594b5c4f69177cc4059da11e782e0b")
encoded_flag = decrypt_flag(encrypted_flag_hex, key, iv)
flag_xor42 = three_way_decode(encoded_flag, key, iv)
flag = xor42(flag_xor42)
print(flag)
And the output of this is…
poctf{uwsp_4ll_7h47_gl1773r5})))ü©$¸Î¯k2ó"'
very weird, but you know, the flag is there.
Flag
poctf{uwsp_4ll_7h47_gl1773r5}