Since I want to dive deeper into reverse engineering, I’ve decided to regularly solve CrackMe challenges from https://crackmes.one; I’ll begin with low-difficulty ones and gradually work my way up to harder challenges.
I chose this approach because it’s a practical way to build and demonstrate the fundamentals, reverse engineering can be overwhelming at first, so starting with simpler tasks helps establish a reliable foundation.
Today’s CrackMe is by Rodrigo Teixeira and is rated 1.1 in difficulty; you can find it here: https://crackmes.one/crackme/68a346c48fac2855fe6fb6df.
Because this is a fairly simple exercise, I’ll analyze it with radare2 instead of Ghidra, partly to avoid making the task trivial and I’ll concentrate on how to translate assembly into readable pseudo-C code step by step.
I recommend gaining a basic understanding of Assembly and the C programming language before diving in. I’ll keep this write-up as beginner-friendly as possible and have included links to resources that explain any terminology that might be unfamiliar to newcomers, if you have any questions, feel free to contact me.
If you want to dive deeper into radare2 commands, i recommend reading it’s official documentation or the following cheatsheet.
Here is a list of references for research used in this WriteUp:
– General Reverse Engineering & x86 Assembly
Intel® 64 and IA-32 Architectures Software Developer’s Manuals
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
x86 Instruction Reference
https://wiki.osdev.org/X86-64
PC Assembly Language by Paul A. Carter
https://pacman128.github.io/static/pcasm-book.pdf
– Windows Internals & PE File Format
Microsoft PE/COFF Specification
https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
– radare2
radare2 Book (official)
https://book.rada.re
r2wiki Cheat Sheet
https://r2wiki.readthedocs.io/en/latest/home/misc/cheatsheet/
– Calling Conventions & ABI
System V i386 ABI
https://refspecs.linuxfoundation.org/elf/abi386-4.pdf
Microsoft x64 Calling Convention
https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention
– Beginner-Friendly Practical Resources
Crackmes.one Platform
https://crackmes.one
0x00sec Reverse Engineering Category
https://0x00sec.org/c/reverse-engineering/
OpenSecurityTraining: Intro to x86
https://p.ost2.fyi/courses/course-v1:OpenSecurityTraining2+Arch1001_x86-64_Asm+2021_v1/about
After downloading the binary, the first thing I’ll do is launch radare2 with the -A option to run an automatic analysis pass.

Once radare2 has loaded the binary, you can type i to display detailed information about the loaded excutable.

The binary is a Windows Portable Executable (PE) file. A Portable Executable file is a format used for executable and object files in Windows operating systems, based on the Common Object File Format (COFF). It’s used for files such as .exe, .dll, .sys, and others. The structure begins with a 64-byte MS-DOS header starting with the characters “MZ” (0x5A4D) and includes an offset field (e_lfanew) that points to the actual PE header.
In PE files, it’s important to distinguish between file offsets (positions in the raw file) and virtual addresses (VA), which are used once the file is loaded into memory.
In radare2, s 0 moves to the file offset 0x0, while s baddr jumps to the binary’s base virtual address (usually 0x00400000 for Windows executables).
You can inspect the e_lfanew field with:
pv4 @ 0x3c
This gives the offset to the PE header.
If the result is 0xffffffff, it typically means the memory region isn’t mapped or is filled with placeholder bytes (0xFF).
We can also verify this by entering the following command:
s 0
# Shows the MS-DOS header ("MZ ...")
px 64 @ 0

As expected, you can see the “MZ” signature (0x4D5A) at the beginning of the header.
PE files (like all Windows binaries) use little-endian byte order, meaning the least significant byte comes first.
In radare2:
px 4 @ 0x3c ; shows raw bytes (in little-endian order)
pv4 @ 0x3c ; interprets those 4 bytes as a 32-bit integer
You can also use pf to parse structured data, for example:
pf 2s e_magic; 58x; 4u e_lfanew @ 0
This reads the e_magic signature and the e_lfanew offset in one step, making PE header inspection much clearer.
Now I’ll run afl to list the functions that radare2 discovered in this binary.
The list is extensive, but we’re specifically looking for the entry function where the executable begins execution.

The entry function is located at 0x00401a00; radare2 has already taken us there automatically, but you can jump to it manually with s 0x00401a00.
Using afl~main we can list all functions whose names include “main.”

To display the assembly code for this function, I can use the command pdf @ sym._main.

Now we can translate the given assembly into pseudocode to better visualize what the function is doing.
I’ll start by deriving the function signature (parameters and return type). Here’s the general approach:
Identify the calling convention
- In the epilogue:
- Register-based conventions:
Count the number of arguments
- For stdcall: the number of arguments =
imminret imm/ 4 - For cdecl:
- Count the
pushinstructions (ormov [esp+…]) before the call in the caller function - Example: three
pushinstructions -> three arguments
- Count the
- If the callee references its arguments directly:
[ebp+8]-> first argument[ebp+0xC]-> second argument, and so on.
Determine the return type
Check the final instructions in the function body:
- Used as an address ->
pointer - Value returned in eax (32-bit) ->
int,bool, orpointer - Value returned in edx:eax -> 64-bit integer
- Value returned via st0 (FPU) ->
floatordouble(common withfld/fstp) - (SSE returns on x86-32 are rare; on x64 they use
xmm0.)
Consider semantics:
- Only 0 or 1 -> likely
bool - Multiple values or error codes ->
int
Now, for our specific case:
Since the callee doesn’t use any parameters in its data flow, we move to its caller (the CRT startup routine).
In PE/MinGW, the startup sequence typically goes like this:mainCRTStartup -> __tmainCRTStartup -> ___main or __mingw32_init_mainargs -> _main(argc, argv, envp)
Identifiying CRT vs. User Code:
When analyzing Windows executables, you’ll often see functions like ___main, mainCRTStartup, or __tmainCRTStartup.
These belong to the C Runtime (CRT) and handle setup tasks such as initializing global variables, the floating-point environment, and calling your actual main function.
A quick rule of thumb: if the function name starts with multiple underscores or manipulates environment or FPU state (fldenv, fninit, ldmxcsr), it’s part of the CRT, not user-written code.
We can now inspect the call sites in radare2.
Looking at the initial disassembly output, we can identify the relevant line:
; CALL XREF from fcn.004011b0 @ 0x401283(x)
This shows the address of the call site.
We can jump to that address and review the instructions leading up to the call with:
s 0x401283
pd -30
pd 20

Here we can see the typical behavior of the C runtime (CRT), which retrieves the arguments from global variables and passes them to main using a push-less call convention.
Push-less Call Convention? What is that? (CLICK)
Some compilers (like GCC or MinGW) don’t use the traditional push instructions for function arguments.
Instead, they write the argument directly onto the stack with mov [esp], value and then call the function.
When the call instruction executes, it automatically pushes the return address, which shifts the argument down to [esp+4]exactly where the callee expects it according to the cdecl calling convention.
Example:
mov [esp], 0x405064 ; write argument (string address)
call printf ; CPU pushes return address -> arg at [esp+4]
This technique saves instructions and is known as a push-less call setup.
0x00401267 e8 8c28.... call ___p__environ
0x0040126c 8b 00 mov eax, dword [eax]
0x0040126e 89 44 24 08 mov [var_8h], eax
Here’s a brief explanation of the assembly code:___p__environ() returns a pointer to the global variable environ, which is of type char***.mov eax, [eax] dereferences it once, so eax now holds a char** the actual envp pointer.
This value is then stored in var_8h, effectively setting envp = environ;.
0x00401272 a1 00 70 40 00 mov eax, [0x407000]
0x00401277 89 44 24 04 mov [var_4h], eax
[0x407000] is a CRT global variable, typically representing __argc.
Therefore, var_4h receives the value of argc, effectively making it argc = __argc;.
0x0040127b a1 04 70 40 00 mov eax, [0x407004]
0x00401280 89 04 24 mov [esp], eax ; char **argv
[0x407004] is the CRT global variable for __argv, which is of type char**.mov [esp], eax writes the first function argument directly onto the top of the stack without using a push instruction.
This is a common compiler pattern known as a “push-less call setup,” meaning argv is now prepared as the first parameter for the upcoming function call.
0x00401283 e8 d8 01 00 00 call sym._main ; int main(char **argv)
The call uses exactly what’s currently stored in [esp], which is argv.
That’s why radare2 annotates the function signature as int main(char **argv)only argv is passed as an argument.
Now that we understand how the parameter passing works, we can start writing our first pseudocode.
<RETURN-TYPE> main(char **argv) {}
We still need to determine the return type for the function signature, which should be relatively easy to identify. To do this, I’ll take a look at the final instructions of our assembly function.
│ └─> 0x004014a5 b800000000 mov eax, 0
│ 0x004014aa c9 leave
└ 0x004014ab c3 ret
mov eax, 0 gives us a clear indication of the return type. To determine it, it’s useful to look at which register is being used in the mov instruction right before the leave and ret sequence.
In 32-bit code, the return value is always stored in the EAX register.
This follows the ABI (Application Binary Interface) convention, which applies to all languages that adhere to C calling conventions such as C, C++, Pascal, and stdcall.
Calling Conventions? What is that? (CLICK)
Understanding the calling convention is essential when reconstructing a function’s signature.
You can often identify it by looking at how the stack is cleaned up and how arguments are passed:
| Convention | Stack Cleanup | Argument Passing | Typical Pattern |
|---|---|---|---|
| cdecl | Caller | via stack (push / mov [esp+..]) | ret |
| stdcall | Callee | via stack | ret N (N = args × 4) |
| fastcall | Callee | first args in ecx, edx | ret N |
| thiscall | Callee | ecx = this (C++) | ret N |
If the callee doesn’t access any arguments directly, inspect the call site instead — count how many push or mov [esp+..] instructions occur before the call. That number tells you how many parameters are passed.
| Rückgabetyp (in C) | Register | Größe |
|---|---|---|
int, bool, pointer | EAX | 4 Bytes |
float | ST0 (FPU) | 4 Bytes |
double | ST0 (FPU) | 8 Bytes |
long long | EDX:EAX | 8 Bytes zusammengesetzt |
We can therefore confidently conclude that the return value is an integer, allowing us to expand our pseudocode accordingly.
int main(char **argv) {}
We can therefore confidently conclude that the return value is an integer and expand our pseudocode accordingly.
Now we can finally focus on analyzing the data flow and translating it step by step.
The prologue of the function can be ignored when writing our pseudocode, it is responsible for setting up the stack frame, aligning the stack to 16 bytes, reserving local variables, and initializing the C runtime (CRT).
Stack frame? What is that? (CLICK)
At the beginning of a function, the compiler sets up what’s called a stack frame.sub esp, 0x20 reserves 32 bytes (0x20) on the stack for local variables.
Each local variable is located at a specific offset relative to either esp or ebp.
Example:
esp+0x00 → return address (after the call)
esp+0x04 → first function argument
esp+0x1C → local variable var_1ch
So, lea eax, [var_1ch] loads the address of that local variable into eax, not its value.
At the end of the function, the leave instruction restores the previous stack frame, and ret pops the return address to resume execution at the caller.
55 push ebp
89 e5 mov ebp, esp
83 e4 f0 and esp, 0xfffffff0 ; 16-Byte-Alignment
83 ec 20 sub esp, 0x20 ; 32 Byte local variables
e8 72 05 00 00 call ___main ; CRT/MinGW-Init
The first section of code that we can meaningfully translate begins at address 0x0040146e:
esp+00 <- Here, the compiler immediately stores the first function argument (push-less).
esp+04 <- 2. Argument
...
esp+1C <- local int (radare: var_1ch)
The first section of code that we can meaningfully translate begins at address 0x0040146e:
0x0040146e c704246450.. mov dword [esp], str.Enter_password__int_: ; [0x405064:4]=0x65746e45 ; "Enter password (int): " ; const char *format
0x00401475 e80e260000 call sym._printf ; int printf(const char *format)
At address 0x0040146e, the instruction writes the 32-bit value 0x405064 the address of the C string constant "Enter password (int): " stored in the .rdata section to the top of the stack ([esp]).
This represents a push-less argument setup: instead of using push imm32, the compiler writes the first function argument directly into the stack slot. This pattern is typical for GCC and MinGW.
Immediately afterward, the function printf is called to print the string. During the call, the CPU automatically pushes the return address onto the stack, which decreases esp by 4 bytes.
We can now extend our pseudocode as follows:
int main(char **argv) {
printf("Enter password (int): ");
}
Now let’s examine the next four lines:
0x0040147a 8d44241c lea eax, [var_1ch]
0x0040147e 89442404 mov dword [var_4h], eax
0x00401482 c704247b50.. mov dword [esp], 0x40507b ; '{P@'
; [0x40507b:4]=0x59006425 ; "%d" ; const char *format0x00401489 e8ea250000 call sym._scanf ; int scanf(const char *format)
At address 0x0040147a, we can see that the function’s stack frame contains a local variable at offset 0x1C, var_1ch is radare2’s symbolic name for the stack slot at [esp+0x1c] (i.e., a local 0x1C bytes into the 0x20-byte frame reserved by sub esp, 0x20).
This variable is not initialized and, as we can see in the final instruction of this block, it is used as the destination for the scanf call to store the user input.
We can incorporate this information directly into our pseudocode:
int main(char **argv) {
printf("Enter password (int): ");
int input;
scanf("%d", &input);
}
Our code is slowly starting to take shape and gain some structure, so let’s move on and analyze the remaining instructions:
│ 0x00401492 3d90e70100 cmp eax, 0x1e790
│ ┌─< 0x00401497 750c jne 0x4014a5
│ │ 0x00401499 c704247e50.. mov dword [esp], str.You_got_it___ ; [0x40507e:4]=0x20756f59 ; "You got it ;)" ; const char *format
│ │ 0x004014a0 e8e3250000 call sym._printf ; int printf(const char *format)
...
│ └─> 0x004014a5 b800000000 mov eax, 0
│ 0x004014aa c9 leave
└ 0x004014ab c3 ret
0x004014ac 6690 nop
cmp eax, 0x1e790 compares the value in EAX (previously loaded from [var_1ch], i.e. the user-input) with the 32-bit constant 0x001E790 (decimal 124816), which is important for our CrackMe Challenge, this is effectively the flag; the CPU updates the status flags (including the Zero Flag, ZF) as a result.
Translating Comparisons and Flags into if Statements (CLICK)
The cmp instruction sets CPU status flags based on the result of a subtraction (A - B), and conditional jumps like je, jne, or jg use those flags to control flow.
| Instruction | Condition (Flag) | High-Level Equivalent |
|---|---|---|
je / jz | ZF = 1 | if (A == B) |
jne / jnz | ZF = 0 | if (A != B) |
jg / jnle | ZF=0 & SF=OF | if (A > B) |
jl / jnge | SF≠OF | if (A < B) |
Example:
cmp eax, 0x1e790
jne 0x4014a5
translates to
if (input != 124816) goto 0x4014a5;
Tip: You can quickly convert hexadecimal to decimal in radare2 with
? 0x1e790
0x00401497 jne 0x4014a5 is “jump if not equal”: it branches only when ZF == 0 (values unequal); if the input != 124816 execution jumps to 0x4014a5, skipping the subsequent print.
If EAX == 0x1E790 (ZF == 1), execution falls through to the next block: mov dword [esp], 0x40507e writes the address of the C string "You got it ;)" (located in .rdata at 0x40507e) into [esp] as the first function argument, a push-less argument setup (instead of push imm32).
call sym._printf invokes printf(const char *format); the call pushes the return address so that printf finds its first argument at [esp+4], consistent with the cdecl/varargs calling convention.
Finally, mov eax, 0 loads the immediate value 0 into EAX (overwriting any previous content); under the 32-bit cdecl ABI EAX is the standard return register, so this corresponds to return 0;. leave restores the stack frame (mov esp, ebp; pop ebp), and ret returns control to the caller.
Identifying and understanding is a Keyskill for writing Pseudocode, so
int main(char **argv) {
printf("Enter password (int): ");
int input;
scanf("%d", &input);
if (input == 124816) {
printf("You got it ;)");
}
return 0
}
With this, we now have both the flag (124816) and the pseudocode for the challenge; I hope you were able to follow along and take something useful from my write-up. If you have feedback or questions, feel free to comment or contact me.
I also recommend reviewing all linked resources, as they can be very helpful if you want to dive deeper into reverse engineering 🙂