Case Study 34-1: Reverse Engineering a Crackme
Introduction
Crackmes are small programs designed as reverse engineering practice challenges. They simulate a class of real-world software: programs with license key validation, serial number checking, or authentication routines that you must understand without access to source code. In professional security work, the same skills apply to analyzing commercial software, validating binary integrity, and understanding obfuscated malware.
This case study walks through a complete crackme analysis: loading the binary, finding the validation routine, understanding the comparison logic, and extracting the correct password — all without running the program with the correct input.
🔐 Security Note: The techniques demonstrated here are used by: security researchers auditing software for vulnerabilities, malware analysts understanding malicious software, CTF competitors solving reverse engineering challenges, and software engineers debugging their own compiled output. These skills do not authorize circumventing software protections on software you do not own or have permission to analyze.
The Target Binary
Our crackme, crackme01, is a 64-bit ELF binary. It prompts for a password, validates it, and prints either "Access granted" or "Access denied."
$ file crackme01
crackme01: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped
$ ./crackme01
Enter password: hello
Access denied.
$ ./crackme01
Enter password: test
Access denied.
The binary is stripped — no symbols. We cannot simply run objdump -t and find a function named check_password.
Phase 1: Intelligence Gathering
Start with non-execution analysis:
# What libraries does it use?
$ ldd crackme01
linux-vdso.so.1
libc.so.6
# What functions does it import?
$ objdump -T crackme01
DYNAMIC SYMBOL TABLE:
00000000 w D *UND* printf
00000000 w D *UND* fgets
00000000 w D *UND* strcmp
00000000 w D *UND* strlen
00000000 w D *UND* puts
00000000 w D *UND* __stack_chk_fail
# What interesting strings exist?
$ strings crackme01
Enter password:
Access granted. Welcome!
Access denied.
/bin/sh
The dynamic symbol table tells us a lot: strcmp means there is a direct string comparison somewhere. strlen means it checks length. fgets is used to read input (safer than gets). __stack_chk_fail means the binary was compiled with stack canaries.
The strings are also informative: "Enter password:", "Access granted", "Access denied" are the expected UI strings. But what is /bin/sh doing in a crackme? That is suspicious — we will keep it in mind.
Phase 2: Finding Main
Open in GDB and find main:
$ gdb -q crackme01
(gdb) set disassembly-flavor intel
(gdb) break *0x401080 ; entry point (from readelf -h crackme01)
(gdb) run
Breakpoint 1, 0x0000000000401080 in ?? ()
(gdb) disassemble $rip, +80
0x401080: endbr64
0x401084: xor ebp, ebp
0x401086: mov r9, rdx
0x401089: pop rsi
0x40108a: mov rdx, rsp
0x40108d: and rsp, 0xfffffffffffffff0
0x401091: push rax
0x401092: push rsp
0x401093: lea r8, [rip+0x2b5] ; __libc_csu_fini
0x40109a: lea rcx, [rip+0x24e] ; __libc_csu_init
0x4010a1: lea rdi, [rip+0x2c8] ; main — RIP at 0x4010a8, +0x2c8 = 0x401370
0x4010a8: call 0x401060 <__libc_start_main@plt>
(gdb) # main is at 0x4010a8 + 7 + (let's calculate)
(gdb) # RIP after lea rdi is 0x4010a8. 0x4010a8 + 0x2c8 = 0x401370
(gdb) break *0x401370
(gdb) continue
Breakpoint 2, 0x0000000000401370 in ?? ()
Phase 3: Static Analysis of main
(gdb) disassemble 0x401370, 0x401450
0x401370: push rbp
0x401371: mov rbp, rsp
0x401374: sub rsp, 0x50 ; 80 bytes local storage
0x401378: mov rax, [fs:0x28] ; canary — this explains __stack_chk_fail
0x401381: mov [rbp-0x8], rax ; store canary at rbp-8
0x401385: xor eax, eax
; Print "Enter password: "
0x401387: lea rdi, [rip+0xc76] ; "Enter password: "
0x40138e: mov eax, 0x0
0x401393: call printf@plt
; Read input: fgets(buf, 64, stdin)
0x401398: mov rdx, [rip+0x2cc1] ; stdin
0x40139f: lea rax, [rbp-0x48] ; buf at rbp-72 (72 bytes from rbp)
0x4013a3: mov esi, 0x40 ; 64 bytes
0x4013a8: mov rdi, rax
0x4013ab: call fgets@plt
; Strip trailing newline: buf[strlen(buf)-1] = '\0'
0x4013b0: lea rax, [rbp-0x48]
0x4013b4: mov rdi, rax
0x4013b7: call strlen@plt
0x4013bc: sub rax, 0x1
0x4013c0: mov BYTE [rbp-0x48+rax], 0x0
; Call validate function
0x4013c6: lea rax, [rbp-0x48]
0x4013ca: mov rdi, rax
0x4013cd: call 0x4011a0 ; <-- the validation function!
; Check return value
0x4013d2: test eax, eax
0x4013d4: je 0x4013e5 ; 0 = fail
0x4013d6: lea rdi, [rip+0xc83] ; "Access granted. Welcome!"
0x4013dd: call puts@plt
0x4013e2: jmp 0x4013f1
0x4013e5: lea rdi, [rip+0xc7e] ; "Access denied."
0x4013ec: call puts@plt
; Canary check
0x4013f1: mov rax, [rbp-0x8]
0x4013f5: xor rax, [fs:0x28]
0x4013fe: je 0x401405
0x401400: call __stack_chk_fail@plt
0x401405: leave
0x401406: ret
We now know:
1. Input is read into a 64-byte buffer at rbp-0x48
2. The validation function is at 0x4011a0
3. It returns non-zero for success
Phase 4: Analyzing the Validation Function
(gdb) disassemble 0x4011a0, 0x401196
0x4011a0: push rbp
0x4011a1: mov rbp, rsp
0x4011a4: sub rsp, 0x10
0x4011a8: mov [rbp-0x8], rdi ; save input pointer
; Phase 1: Check length == 8
0x4011ac: mov rdi, [rbp-0x8]
0x4011b0: call strlen@plt
0x4011b5: cmp rax, 0x8
0x4011b9: jne 0x401210 ; wrong length → fail
; Phase 2: XOR transform and check
; Load 8-byte input as a 64-bit integer
0x4011bf: mov rax, [rbp-0x8]
0x4011c3: mov rax, QWORD [rax] ; load 8 bytes of input
0x4011c6: xor rax, 0x2a2a2a2a2a2a2a2a ; XOR with 0x2a2a2a2a2a2a2a2a
0x4011d0: cmp rax, 0x534f534f534f4f53 ; compare with this constant
0x4011da: jne 0x401210 ; mismatch → fail
; Phase 3: Check last byte is 0x21 ('!')
; Wait — if length == 8 and we check 8 bytes as a word, there's no "last byte" check
; Let's re-examine... actually 0x4011d0 checks the XOR'd 8 bytes against a constant
; Success path
0x4011dc: mov eax, 0x1
0x4011e1: jmp 0x401215
; Fail path
0x401210: xor eax, eax
0x401215: leave
0x401216: ret
Phase 5: Solving the Challenge
The validation logic:
1. Input must be exactly 8 characters long
2. The 8 input bytes, loaded as a little-endian 64-bit integer and XOR'd with 0x2a2a2a2a2a2a2a2a, must equal 0x534f534f534f4f53
To find the correct input, reverse the XOR:
correct_bytes = target XOR key
correct_bytes = 0x534f534f534f4f53 XOR 0x2a2a2a2a2a2a2a2a
Compute byte by byte (little-endian order, so the first byte of input is the least significant byte):
| Byte | Target | XOR Key | Input Byte | ASCII |
|---|---|---|---|---|
| 0 | 0x53 | 0x2a | 0x79 | y |
| 1 | 0x4f | 0x2a | 0x65 | e |
| 2 | 0x4f | 0x2a | 0x65 | e |
| 3 | 0x53 | 0x2a | 0x79 | y |
| 4 | 0x4f | 0x2a | 0x65 | e |
| 5 | 0x53 | 0x2a | 0x79 | y |
| 6 | 0x4f | 0x2a | 0x65 | e |
| 7 | 0x53 | 0x2a | 0x79 | y |
The password is yeeyeyey... let us verify by checking: 0x79 XOR 0x2a = 0x53 ✓, 0x65 XOR 0x2a = 0x4f ✓.
Actually, re-reading the bytes: target is 0x534f534f534f4f53 in little-endian. In memory, the bytes are stored as 53 4f 4f 53 4f 53 4f 53. Let's redo:
Byte 0 (lowest) of the 64-bit value in little-endian: 0x53 → XOR 0x2a = 0x79 = y
Byte 1: 0x4f XOR 0x2a = 0x65 = e
The password is yeeyseys — or more precisely, whatever the 8-byte XOR inverse produces. The key insight is that the algorithm is fully reversed analytically.
Phase 6: Verification with GDB
Set a breakpoint at the comparison and check our derived input:
(gdb) break *0x4011d0
(gdb) run
Enter password: yeeyeyey
Breakpoint 3, 0x00000000004011d0 in ?? ()
(gdb) info registers rax
rax 0x534f534f534f4f53
(gdb) print/x 0x534f534f534f4f53 == 0x534f534f534f4f53
$1 = 0x1 ; true — they match!
(gdb) continue
Access granted. Welcome!
What About /bin/sh?
The string /bin/sh in the binary is suspicious for a simple crackme. Reverse engineering further reveals a dead code path — a function that is never called from main — that contains a execve("/bin/sh", ...) sequence. This is a common CTF trick: include a "free gift" in the binary that becomes the target of a more advanced challenge (a ROP chain to redirect execution to it). For now, we note it and move on.
Lessons Learned
- String cross-references guide analysis: "Enter password" found the main validation flow; the suspicious
/bin/shflagged a secondary challenge. - Dynamic symbol table reveals algorithm:
strcmpsuggests comparison; XOR against a constant is a weaker but common obfuscation. - Work backward from the comparison: find the final
cmp, understand what both sides are, reverse the transformation. - No need to run the binary to solve it: pure static analysis found the password completely.
- Canary detection: the presence of
__stack_chk_failtold us the binary was compiled with-fstack-protector.
The skills applied here — finding function boundaries, reading library calls, understanding XOR obfuscation, reversing a simple transform — apply directly to real-world license key validation, simple malware obfuscation, and CTF challenges.