An extensive step by step reverse engineering analysis of a Linux CTF binary
...or, in other words, when failing to reverse a CTF binary makes you lose that job.
During a past job interview, I was tasked to reverse four linux binaries of increasing difficulties as proof of my ability into the reverse engineering field. I solved the first two in a matter of an hour, the third one required me an entire day of work but sadly, I was not able to solve the last one. I don't know if I wasn't selected because of this fail, but it proved me one sure thing: I wasn't prepared enough or, at least, as much as I wanted. Flash forward, I successfully ended up with another job, but that challenge kept staying there, like a small needle, in my head. During the following months, I studied and practiced a lot, mainly into firmware reversing field and, every now and then, I've tried to solve that sneaky challenge.
This is my extensive and detailed description of my fails and success.
Important note
Please note that as this analysis started some months ago and this post was reviewed a huge number of times, you won't find same memory addresses or function names across the screenshots and code snippets.
Running the binary
With what are we dealing?
root@kali:/opt/ctf# file original
original: ELF 32-bit LSB pie executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=d0d5b9a34a4fe4c52a3939c75bd71cfa0dc23825, stripped
root@kali:/opt/ctf# checksec -f ./original
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH No Symbols No 0 2 ./original
A standard, stripped, Linux 32bit binary with no fancy protection active. We're not aiming to exploit it but only to find the flag. A picture is worth a thousand words, they say:
root@kali:/opt/ctf# ./original
[-] No vm please ;)
root@kali:/opt/ctf# ./original AAAA
[-] No vm please ;)
root@kali:/opt/ctf# ./original -h
[-] No vm please ;)
root@kali:/opt/ctf# ./original AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[-] No vm please ;)
root@kali:/opt/ctf#
It doesn't run inside a virtual machine and I definitely don't want to build a physical linux box. Would you tell me some of your internals, please?
root@kali:/opt/ctf# strace ./original
execve("./original", ["./original"], 0x7fff2a7dc4f0 /* 48 vars */) = 0
strace: [ Process PID=121645 runs in 32 bit mode. ]
brk(NULL) = 0x572fb000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xf7f05000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=133840, ...}) = 0
mmap2(NULL, 133840, PROT_READ, MAP_PRIVATE, 3, 0) = 0xf7ee4000
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/i386-linux-gnu/libc.so.6", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 3
read(3, "\177ELF\1\1\1\3\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\300\254\1\0004\0\0\0"..., 512) = 512
fstat64(3, {st_mode=S_IFREG|0755, st_size=1947056, ...}) = 0
mmap2(NULL, 1955712, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xf7d06000
mprotect(0xf7d1f000, 1830912, PROT_NONE) = 0
mmap2(0xf7d1f000, 1368064, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x19000) = 0xf7d1f000
mmap2(0xf7e6d000, 458752, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x167000) = 0xf7e6d000
mmap2(0xf7ede000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1d7000) = 0xf7ede000
mmap2(0xf7ee1000, 10112, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xf7ee1000
close(3) = 0
set_thread_area({entry_number=-1, base_addr=0xf7f060c0, limit=0x0fffff, seg_32bit=1, contents=0, read_exec_only=0, limit_in_pages=1, seg_not_present=0, useable=1}) = 0 (entry_number=12)
mprotect(0xf7ede000, 8192, PROT_READ) = 0
mprotect(0x565b8000, 4096, PROT_READ) = 0
mprotect(0xf7f34000, 4096, PROT_READ) = 0
munmap(0xf7ee4000, 133840) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0xf7f06128) = 121646
waitpid(121646, [{WIFEXITED(s) && WEXITSTATUS(s) == 1}], 0) = 121646
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=121646, si_uid=0, si_status=1, si_utime=0, si_stime=0} ---
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x1), ...}) = 0
brk(NULL) = 0x572fb000
brk(0x5731c000) = 0x5731c000
brk(0x5731d000) = 0x5731d000
write(1, "**[-] You fool, nobody debugs me!!**"..., 34[-] You fool, nobody debugs me!!!
) = 34
write(1, "1\n", 21
) = 2
exit_group(-1) = ?
+++ exited with 255 +++
"You fool, nobody debugs me!!!"
Great, after a few couples of runs, we know that there are some anti-VM and anti-debug code in place. Let's look inside.
First thing, I searched and found the strings pretty quickly, and I noticed also two other interesting strings: one for a fail, one for a success.
Digging a little more, we can find where are placed the strings and from where they're used for.
It's clear that the subroutine placed at address 0x566429DC
has something to do with them and with the anti-VM/anti-debug tricks.
Analyzing the anti-debug and anti-vm routine
Once I have identified where are the strings involved in this anti-debug and anti-vm tricks, it's easy to find them and visualize the blocks in IDA. Please note that sub_566429DC
was here renamed in AntiDebugAntiVM
.
This is the graph of the AntiDebugAntiVM functions. In the first block of code, we can see the standard function call convention that setup the stack frame. After that, a bunch of NOPS
and a call to fork()
. Let's understand the fork call, what's its purpose?
fork() creates a new process by duplicating the calling process. The new process, referred to as the child, is an exact duplicate of the calling process, referred to as the parent. On success, the PID of the child process is returned in the parent, and 0 is returned in the child. On failure, -1 is returned in the parent, no child process is created, and errno is set appropriately. (ref)
Basically, right after the fork call, its return value is saved into the EAX
register and then moved into a local variable that is compared with the zero value. The first branch is important: if the JNZ
is true, we're into the parent process so we're going into the right path. Vice versa, if the instruction is false we're heading to the left or into the child process.
Into the child process
If EAX
is zero, or in other terms, we're into the child process, we can see a call to getppid()
function that returns the process ID of the parent of the calling process. But the important call is the next one, the call to the ptrace()
function. The standard definition of this function is:
The ptrace() system call provides a means by which one process (the"tracer") may observe and control the execution of another process(the "tracee"), and examine and change the tracee's memory and registers. It is primarily used to implement breakpoint debugging and system call tracing.
And is defined as:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
In assembly, the call is built with these lines of code:
push 0 ; *data
push 0 ; *addr
push [ebp+var_1C] ; Parent PID
push 10h ; _ptrace_request
call _ptrace
Basically, the child retrieve its PPID and tries to attach a debugger [1], if it fails, it's the evidence that it is being debugged so sleep 5 seconds, detach and returns [2] . Otherwise returns anyway [3]. Going up a level, if the fork()
return -1
so returns with the status code 1
[5]
Into the parent process
If EAX
is not zero, we're in the right path, so in the parent process. As you can remember, we have the PID of the child into the EAX
register. After the check with -1
into the block [1], it goes into the block [2]. Here, the parent performs a call to waitpid()
:
push 0 ; options
lea eax, [ebp+stat_loc]
push eax ; stat_loc
push [ebp+pid] ; child PID
call _waitpid
The waitpid()
system call is used to wait for state changes in a child of the calling process, and obtain information about the child whose state has changed. A state change is considered to be: the child terminated; the child was stopped by a signal; or the child was resumed by a signal. In the case of a terminated child, performing a wait allows the system to release the resources associated with the child; if a wait is not performed, then the terminated child remains in a "zombie" state. (ref)
On success, waitpid()
returns the process ID of the child whose state has changed; On error, -1
is returned. In the next blocks 2, 3, 4 and 5 what happens is described in this answer I got on ReverseEngineering. There's no need to add anything more.
Anti-VM code
This is where things become fun and interesting. We can observe a bunch of mov
instructions into the stack, a loop and inside of it an interesting xor
instruction: xor eax, 75h
. It seems to be a loop that cycle 0x32
times (50
in decimal) and starting from [ebp+command]
it xors one byte at a time to a fixed value equal to \x75
. Pretty standard XOR decryption routine, right? We can try to replicate this routine in python:
#!/usr/bin/python
hexdata = "19061605005509551207100555523D0C051007031C061A07525509550107555811555255525509551600015558114F55581347"
binary = hexdata.decode("hex")
def xor_strings(data):
return "".join(chr(ord(data[i]) ^ 0x75) for i in range(len(data)))
xored = xor_strings(binary)
print "Your decrypted string is: " + xored
root@kali:/opt/ctf# ./script.py
Your decrypted string is: lscpu | grep 'Hypervisor' | tr -d ' ' | cut -d: -f2
Basically, it decrypts in memory a shell command and execute it via the next popen
syscall that verifies, using the lscpu
command, if the CPU name contains a string Hypervisor
. This syscall looks pretty interesting:
Thepopen()
function opens a process by creating a pipe, forking, and invoking the shell. Since a pipe is by definition unidirectional, the type argument may specify only reading or writing, not both; the resulting stream is correspondingly read-only or write-only. The command argument is a pointer to a null-terminated string containing a shell command line. This command is passed to /bin/sh using the -c flag; interpretation, if any, is performed by the shell. The type argument is a pointer to a null-terminated string which must contain either the letter 'r' for reading or the letter 'w' for writing.popen()
: on success, returns a pointer to an open stream that can be used to read or write to the pipe; if the fork(2) or pipe(2) calls fail, or if the function cannot allocate memory,NULL
is returned.
After the stream is opened, another syscall fgetc()
is executed.
fgetc()
reads the next character from stream and returns it as an unsigned char cast to an int, or EOF on end of file or error.
What happens is simple: it opens a stream, in read-only mode, and executes the command 'lscpu | grep 'Hypervisor' | tr -d ' ' | cut -d: -f2'
. If it returns something, so the grep
returns something, we're in a virtual machine, prints the string: [-] No vm please ;)
and exit. If the stream fails or it does not return anything, it closes the stream via the fclose()
syscall and returns.
Everything becomes clear if we look now into the pseudo-code, with important variables renamed as their role.
int AntiDebugAntiVM()
{
char command; // [esp+4h] [ebp-54h]
char v2; // [esp+5h] [ebp-53h]
char v3; // [esp+6h] [ebp-52h]
char v4; // [esp+7h] [ebp-51h]
char v5; // [esp+8h] [ebp-50h]
char v6; // [esp+9h] [ebp-4Fh]
char v7; // [esp+Ah] [ebp-4Eh]
char v8; // [esp+Bh] [ebp-4Dh]
char v9; // [esp+Ch] [ebp-4Ch]
char v10; // [esp+Dh] [ebp-4Bh]
char v11; // [esp+Eh] [ebp-4Ah]
char v12; // [esp+Fh] [ebp-49h]
char v13; // [esp+10h] [ebp-48h]
char v14; // [esp+11h] [ebp-47h]
char v15; // [esp+12h] [ebp-46h]
char v16; // [esp+13h] [ebp-45h]
char v17; // [esp+14h] [ebp-44h]
char v18; // [esp+15h] [ebp-43h]
char v19; // [esp+16h] [ebp-42h]
char v20; // [esp+17h] [ebp-41h]
char v21; // [esp+18h] [ebp-40h]
char v22; // [esp+19h] [ebp-3Fh]
char v23; // [esp+1Ah] [ebp-3Eh]
char v24; // [esp+1Bh] [ebp-3Dh]
char v25; // [esp+1Ch] [ebp-3Ch]
char v26; // [esp+1Dh] [ebp-3Bh]
char v27; // [esp+1Eh] [ebp-3Ah]
char v28; // [esp+1Fh] [ebp-39h]
char v29; // [esp+20h] [ebp-38h]
char v30; // [esp+21h] [ebp-37h]
char v31; // [esp+22h] [ebp-36h]
char v32; // [esp+23h] [ebp-35h]
char v33; // [esp+24h] [ebp-34h]
char v34; // [esp+25h] [ebp-33h]
char v35; // [esp+26h] [ebp-32h]
char v36; // [esp+27h] [ebp-31h]
char v37; // [esp+28h] [ebp-30h]
char v38; // [esp+29h] [ebp-2Fh]
char v39; // [esp+2Ah] [ebp-2Eh]
char v40; // [esp+2Bh] [ebp-2Dh]
char v41; // [esp+2Ch] [ebp-2Ch]
char v42; // [esp+2Dh] [ebp-2Bh]
char v43; // [esp+2Eh] [ebp-2Ah]
char v44; // [esp+2Fh] [ebp-29h]
char v45; // [esp+30h] [ebp-28h]
char v46; // [esp+31h] [ebp-27h]
char v47; // [esp+32h] [ebp-26h]
char v48; // [esp+33h] [ebp-25h]
char v49; // [esp+34h] [ebp-24h]
char v50; // [esp+35h] [ebp-23h]
char v51; // [esp+36h] [ebp-22h]
char v52; // [esp+37h] [ebp-21h]
int stat_loc; // [esp+38h] [ebp-20h]
__pid_t ParentPID; // [esp+3Ch] [ebp-1Ch]
FILE *stream; // [esp+40h] [ebp-18h]
__pid_t ChangedStateChildPID; // [esp+44h] [ebp-14h]
__pid_t ChildPID; // [esp+48h] [ebp-10h]
unsigned int i; // [esp+4Ch] [ebp-Ch]
ChildPID = fork();
if ( !ChildPID )
{
ParentPID = getppid();
if ( ptrace(PTRACE_ATTACH, ParentPID, 0, 0) )
{
stat_loc = 1;
exit(1);
}
sleep(5u);
ptrace(PTRACE_DETACH, ParentPID, 0, 0);
exit(0);
}
if ( ChildPID == -1 )
exit(1);
do
ChangedStateChildPID = waitpid(ChildPID, &stat_loc, 0);
while ( ChangedStateChildPID == -1 && *__errno_location() == 4 );
if ( BYTE1(stat_loc) )
{
printf("[-] You fool, nobody debugs me!!!\n%d\n", BYTE1(stat_loc));
exit(-1);
}
command = 0x19;
v2 = 6;
v3 = 0x16;
v4 = 5;
v5 = 0;
v6 = 0x55;
v7 = 9;
v8 = 0x55;
v9 = 0x12;
v10 = 7;
v11 = 0x10;
v12 = 5;
v13 = 0x55;
v14 = 0x52;
v15 = 0x3D;
v16 = 0xC;
v17 = 5;
v18 = 0x10;
v19 = 7;
v20 = 3;
v21 = 0x1C;
v22 = 6;
v23 = 0x1A;
v24 = 7;
v25 = 0x52;
v26 = 0x55;
v27 = 9;
v28 = 0x55;
v29 = 1;
v30 = 7;
v31 = 0x55;
v32 = 0x58;
v33 = 0x11;
v34 = 0x55;
v35 = 0x52;
v36 = 0x55;
v37 = 0x52;
v38 = 0x55;
v39 = 9;
v40 = 0x55;
v41 = 0x16;
v42 = 0;
v43 = 1;
v44 = 0x55;
v45 = 0x58;
v46 = 0x11;
v47 = 0x4F;
v48 = 0x55;
v49 = 0x58;
v50 = 0x13;
v51 = 0x47;
v52 = 0;
for ( i = 0; i <= 50; ++i )
*(&command + i) ^= 0x75u;
stream = popen(&command, "r");
if ( stream && fgetc(stream) != -1 )
{
puts("[-] No vm please ;)");
exit(-1);
}
return fclose(stream);
}
First round of conclusions
Right now it may seem pretty easy, but for me at that time, this was impossible to understand and represented the first big fail: I was not prepared with interpreting assembly XOR instruction, decryption loops and Linux syscalls. I spent almost an entire weekend on this and failed so hard. Because of the time constraints of the job selection, I sent my results without this last exercise and maybe this influenced my performance into the selection. How to bypass all these checks? We need to find from where this function is called and maybe we could modify the code flow to avoid this calling.
Jumping away
With the IDA basic functionalities, we can find where this function is called and, luckily for us, it's called from a single location:
The instruction that calls the function is located inside this sub_E00
and, in particular, IDA shows that's the instruction: call ds:(off_2EF0-3000h) [ebx+edi*4]
. Looking around this code we can patch the jz short loc_E55
into a jmp
, so we would be able to circumvent all of the above protections.
Cheating with the shell
If you don't want to patch the binary, there's another way to fool this VM check, but not the anti-debug. If you notice, the command passed as an argument to the popen
syscall is a normal shell command but with a relative path. So quick and dirty trick would be to create a fake lscpu
like this:
#!/bin/bash
echo "I will run you anyway in this VM"
Be sure to export the directory inside the PATH variable and, basically, you're done: when the binary will try to execute the lscpu
command, it will run the fake one, it won't return anything containing Hypervisor string, the grep
would return nothing and the fgetc
consequently will read nothing. Basically, all checks are positive. Easy as it seems.
Analyzing the self decrypting and injecting routine
We can take advantages of the debugging capabilities of IDA and playing with breakpoints. Single stepping into the program flow, after the above routines, we land into this interesting piece of code:
I spent a lot of days trying to understand this routine: but it was worth it because I learned a lot: I learned about linux syscalls like mprotect
, calloc
and also memcpy
. I learned about how the code could auto-decrypt and auto-inject inside the binary itself. Moreover, how can be possible to change memory protections back and forth. Indeed, it was very helpful to look around this code, side by side, with its decompiled version:
int sub_CB5()
{
char v0; // si
size_t v1; // eax
char s; // [esp+8h] [ebp-30h]
char v4; // [esp+9h] [ebp-2Fh]
char v5; // [esp+Ah] [ebp-2Eh]
char v6; // [esp+Bh] [ebp-2Dh]
char v7; // [esp+Ch] [ebp-2Ch]
char v8; // [esp+Dh] [ebp-2Bh]
char v9; // [esp+Eh] [ebp-2Ah]
char v10; // [esp+Fh] [ebp-29h]
char v11; // [esp+10h] [ebp-28h]
char v12; // [esp+11h] [ebp-27h]
char v13; // [esp+12h] [ebp-26h]
char v14; // [esp+13h] [ebp-25h]
char v15; // [esp+14h] [ebp-24h]
char v16; // [esp+15h] [ebp-23h]
char v17; // [esp+16h] [ebp-22h]
char v18; // [esp+17h] [ebp-21h]
char v19; // [esp+18h] [ebp-20h]
char v20; // [esp+19h] [ebp-1Fh]
char v21; // [esp+1Ah] [ebp-1Eh]
char v22; // [esp+1Bh] [ebp-1Dh]
void *src; // [esp+1Ch] [ebp-1Ch]
_BYTE *v24; // [esp+20h] [ebp-18h]
void *addr; // [esp+24h] [ebp-14h]
size_t n; // [esp+28h] [ebp-10h]
size_t i; // [esp+2Ch] [ebp-Ch]
n = 320;
addr = 0;
v24 = &unk_E78;
mprotect(0, (size_t)((char *)&unk_E78 - 0xFFFFD000 - 12288), 6);
s = 0xF9u;
v4 = 0xFCu;
v5 = 0xFFu;
v6 = 0xE6u;
v7 = 0xF5u;
v8 = 0xE0u;
v9 = 0xF1u;
v10 = 0xF3u;
v11 = 0xFBu;
v12 = 0xF9u;
v13 = 0xFEu;
v14 = 0xF7u;
v15 = 0xFDu;
v16 = 0xE9u;
v17 = 0xF3u;
v18 = 0xFFu;
v19 = 0xF4u;
v20 = 0xF5u;
v21 = 0;
src = calloc(0x141u, 1u);
for ( i = 0; i < n; ++i )
{
v22 = *((_BYTE *)sub_89B + i);
v0 = v22 ^ 0x90;
v1 = strlen(&s);
*((_BYTE *)src + i) = *(&s + i % v1) ^ v0;
}
memcpy(sub_89B, src, n);
return mprotect(addr, v24 - (_BYTE *)addr, 4);
}
TL;DR
Before we go deep into the details of the single blocks of code, giving a general overview of what its final purpose is, may help its comprehension. First thing, the code changes via mprotect
function the memory protections, adding the write permission, of a specific part of its .text
section. After that, it copies, into the stack, some bytes that will be revealed as a key for an afterward decryption. Before entering into the main loop, it allocates an array of bytes into the heap via calloc
. Specifically, the length of the array is 0x140
bytes; this value is saved into a local variable placed into the stack at [ebp+n]
offset. The main loop is somehow complicated because it xors byte per byte some of its code, placed at sub_89B+i
offset, with a fixed constant 0x90
and after, it xors it again with the aforementioned key on the stack. After that, it overwrites the code placed at sub_89B
offset, with these new values via the memcpy
call and returns after changing again the memory protections of that code section back to read-execute
. Let's break in line by line, considering only the useful ones.
Here, it setups the length of the future array in the variable placed on the stack at [ebp+n]
with the size of 0x140
or 320
elements of 1
byte. After that, it prepares the arguments of the next call to mprotect
, that will change the protection, enabling write permission, on the the address 0x5657D000
. Looking up the stack:
Having ESP
pointing at 0xFFC344F0
, the calling convention dictate that the arguments of a function must be pushed into the stack in reverse order. The mprotect
call is defined as: int mprotect(void *addr, size_t len, int prot);
with
prot
=6
len
=0xE78
*addr
=0x5657D000
In other words: change the permission of the memory area of 3704
bytes starting from address 0x5657D000
, granting the writability via the PROT_WRITE
constant. More info of this syscall here. But what's inside this address? We're inside the ELF header, basically the start of the entire binary.
Going further, we can see the moving into the stack of some bytes, a call to calloc
to allocate an array of 320+1
null bytes into the heap and the setup of a loop counter variable, placed at [ebp+var_C]
, with the same size of the array. We're setting up a loop that will scan, byte per byte, a specific area of the binary located at 0x5657D89B
- that is a fixed value - and xor every byte, first with 0x90
and after with those bytes that were moved into the stack. For better understand this loop, I suggest to read the answer I got here. When this decryption loop ends, we have the decrypted code inside the heap, into the allocated array. Code can now be replaced with the decrypted one via the memcpy
syscall. Finally, write permission can now be disabled and the routine can finish and return.
Second round of conclusions
Many days and months passed staring at me failing so hard into the understanding of this routine. But the feeling was still the same: I wanted to have that "[+] Good job! ;)" string and I've always had the Try Harder approach. Understanding this loop wasn't easy, not even close. I asked for help and, luckily, I got plenty. This is what I got: don't be afraid to ask for help but don't blindly ask for a solution. Work on that, demonstrate that you studied that thing and failed; People, eventually, will get that and will help you.
Towards the victory
After executing the decryption function we land into the code below. First it verifies that the user submitted a password of the length of exactly 0x27
, that is a fixed value coming from this instruction: mov eax, (dword_56561058 - 56561000h) [ebx]
.
Only if the password is exactly 39
characters, it moves on into the DecryptedFunction
, passing the user's password as the argument. The previous experience helped a lot to understand this function and the pseudo code generated by IDA is pretty nice.
int __cdecl DecryptedFunction(int UserSubmittedPassword)
{
int result; // eax
char v2; // [esp+0h] [ebp-38h]
char v3; // [esp+1h] [ebp-37h]
char v4; // [esp+2h] [ebp-36h]
char v5; // [esp+3h] [ebp-35h]
char v6; // [esp+4h] [ebp-34h]
char v7; // [esp+5h] [ebp-33h]
char v8; // [esp+6h] [ebp-32h]
char v9; // [esp+7h] [ebp-31h]
char v10; // [esp+8h] [ebp-30h]
char v11; // [esp+9h] [ebp-2Fh]
char v12; // [esp+Ah] [ebp-2Eh]
char v13; // [esp+Bh] [ebp-2Dh]
char v14; // [esp+Ch] [ebp-2Ch]
char v15; // [esp+Dh] [ebp-2Bh]
char v16; // [esp+Eh] [ebp-2Ah]
char v17; // [esp+Fh] [ebp-29h]
char v18; // [esp+10h] [ebp-28h]
char v19; // [esp+11h] [ebp-27h]
char v20; // [esp+12h] [ebp-26h]
char v21; // [esp+13h] [ebp-25h]
char v22; // [esp+14h] [ebp-24h]
char v23; // [esp+15h] [ebp-23h]
char v24; // [esp+16h] [ebp-22h]
char v25; // [esp+17h] [ebp-21h]
char v26; // [esp+18h] [ebp-20h]
char v27; // [esp+19h] [ebp-1Fh]
char v28; // [esp+1Ah] [ebp-1Eh]
char v29; // [esp+1Bh] [ebp-1Dh]
char v30; // [esp+1Ch] [ebp-1Ch]
char v31; // [esp+1Dh] [ebp-1Bh]
char v32; // [esp+1Eh] [ebp-1Ah]
char v33; // [esp+1Fh] [ebp-19h]
char v34; // [esp+20h] [ebp-18h]
char v35; // [esp+21h] [ebp-17h]
char v36; // [esp+22h] [ebp-16h]
char v37; // [esp+23h] [ebp-15h]
char v38; // [esp+24h] [ebp-14h]
char v39; // [esp+25h] [ebp-13h]
char v40; // [esp+26h] [ebp-12h]
unsigned __int8 v41; // [esp+27h] [ebp-11h]
int counter; // [esp+28h] [ebp-10h]
int v43; // [esp+2Ch] [ebp-Ch]
v43 = 0;
v2 = 0x93u;
v3 = 0x5E;
v4 = 0xB0u;
v5 = 0xB8u;
v6 = 0xC5u;
v7 = 0xD7u;
v8 = 0xACu;
v9 = 0x23;
v10 = 0xC3u;
v11 = 0xF0u;
v12 = 6;
v13 = 0x72;
v14 = 0xF4u;
v15 = 0x74;
v16 = 0x93u;
v17 = 0x52;
v18 = 0x74;
v19 = 0x72;
v20 = 0x30;
v21 = 0xEDu;
v22 = 0x8Bu;
v23 = 0x3D;
v24 = 4;
v25 = 0x58;
v26 = 0xD8u;
v27 = 0xE5u;
v28 = 0xA2u;
v29 = 0xCFu;
v30 = 0x8Au;
v31 = 0xEDu;
v32 = 0x8Bu;
v33 = 0x5C;
v34 = 0x5E;
v35 = 0x61;
v36 = 0xDCu;
v37 = 0x31;
v38 = 0xCFu;
v39 = 0x91u;
v40 = 0x82u;
for ( counter = 0; counter < PasswordLength; ++counter )
{
v41 = *((_BYTE *)AntiAnalysisFunction + counter + 0xC7);
if ( (v41 ^ *(_BYTE *)(counter + UserSubmittedPassword)) != *(&v2 + counter) )
{
v43 = 1;
break;
}
}
if ( v43 )
result = puts("[-] Nope!");
else
result = puts("[+] Good job! ;)");
return result;
}
It scans the user's password, character by character, xoring it with a string retrieved from the binary itself. If every character matches it goes on and continue in the loop, otherwise it breaks. In the end, if everything is correct, it prints the beloved success string. How can we retrieve the correct flag? If we dump the 39
bytes from the binary, from the correct addresses, and xor them with the hardcoded string, we can take advantage of the xor bidirectional nature. Although you can find more details here, we're basically telling this:
A xor B = C
A xor C = B
B xor C = A
My first approach was to bruteforce the routine: if the string submitted is, eventually, \x41\x41\x41\x41\x41\x41\x41...
we can step by step into the code and go into the final cmp
instruction, retrieve the byte that it compares to and change the ZERO flag to force the loop to continue and not to stop. Otherwise we can dump the contents of the memory and xor with the hardcoded string, as result we get the flag that needs to be submitted to the binary.
We know that we need to get 39
bytes from address *((_BYTE *)AntiAnalysisFunction + 0 + 0xC7)
to *((_BYTE *)AntiAnalysisFunction + 0x27 + 0xC7)
. Or from (0x5662A9DC + 0 + 0xC7) = 0x5662AAA3
to 0x5662AACA = (0x5662A9DC + 0x27 + 0xC7)
. We can apply the xor operation with the known string and we're able retrieve the flag, finally.
Hardcoded: 93 5E B0 B8 C5 D7 AC 23 C3 F0 06 72 F4 74 93 52 74 72 30 ED 8B 3D 04 58 D8 E5 A2 CF 8A ED 8B 5C 5E 61 DC 31 CF 91 82
Memory dump: E8 18 FC FF FF 83 C4 10 85 C0 74 11 C7 45 E0 01 00 00 00 83 EC 0C 6A 01 E8 90 FB FF FF 83 EC 0C 6A 05 E8 46 FB FF FF
Flag hex: 7B 46 4C 47 3A 54 68 33 46 30 72 63 33 31 73 53 74 72 30 6E 67 31 6E 59 30 75 59 30 75 6E 67 50 34 64 34 77 34 6E 7D
Flag ascii: { F L G : T h 3 F 0 r c 3 1 s S t r 0 n g 1 n Y 0 u Y 0 u n g P 4 d 4 w 4 n }
Conclusions
This was a long journey that required a lot of effort and countless sleepless nights. It was worth it? Every single minute, without any doubt. I hope this post will help you in your studies and if you spot any errors or want to help me in my journey into the reverse engineering world please leave a comment, tweet or e-mail.
As always, Try Harder.