2026-02-02
8~ minutes
Note: I am not a English speaker, and I used DeepL Write to “fix” my bad english. Expect maybe weird english. Also, I am noob at reverse engineering. This is my second challenge.
We have a program called SimpleKeyGen that validates serial
keys passed as argument. Therefore, we need to analyze the software in
order to generate valid keys for it.
First, we need to understand the software we are dealing with:
> file SimpleKeyGen
SimpleKeyGen: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=4b657ffd4f43a704861b200c9f2235f8fe395e27, not stripped
This is an ELF (Executable and Linkable Format) binary for Linux x86-64, compiled with PIE (Position Independent Executable). This means that the program’s base memory position is random. Additionally, it is dynamically linked, meaning functions from libraries are not included in the program.
We can also see that the binary is not stripped, allowing us to view the symbol table:
> objdump -t SimpleKeyGen | grep " F "
00000000000010a0 l F .text 0000000000000000 deregister_tm_clones
00000000000010d0 l F .text 0000000000000000 register_tm_clones
0000000000001110 l F .text 0000000000000000 __do_global_dtors_aux
0000000000001160 l F .text 0000000000000000 frame_dummy
0000000000001300 g F .text 0000000000000005 __libc_csu_fini
0000000000000000 F *UND* 0000000000000000 puts@@GLIBC_2.2.5
0000000000001308 g F .fini 0000000000000000 .hidden _fini
0000000000000000 F *UND* 0000000000000000 strlen@@GLIBC_2.2.5
0000000000000000 F *UND* 0000000000000000 printf@@GLIBC_2.2.5
0000000000000000 F *UND* 0000000000000000 __libc_start_main@@GLIBC_2.2.5
0000000000001197 g F .text 000000000000008f checkSerial
0000000000001290 g F .text 0000000000000065 __libc_csu_init
0000000000001070 g F .text 000000000000002f _start
0000000000001226 g F .text 0000000000000061 main
0000000000001169 g F .text 000000000000002e usage
0000000000000000 F *UND* 0000000000000000 exit@@GLIBC_2.2.5
0000000000000000 w F *UND* 0000000000000000 __cxa_finalize@@GLIBC_2.2.5
0000000000001000 g F .init 0000000000000000 .hidden _init
We can see the use of some glibc functions, such as
printf and puts to print strings and
strlen to determine the length of a string. There is no
function for getting input from the user because the key is passed as a
parameter to the program.
Then, we have the main function, the usage function that
shows how to use the program, and one interesting function:
checkSerial.
We start by analyzing the main function to understand the program’s flow.
We can see the disassembly with
objdump --disassemble=main -M intel SimpleKeyGen:
0000000000001226 <main>:
1226: 55 push rbp
1227: 48 89 e5 mov rbp,rsp
122a: 48 83 ec 10 sub rsp,0x10
122e: 89 7d fc mov DWORD PTR [rbp-0x4],edi
1231: 48 89 75 f0 mov QWORD PTR [rbp-0x10],rsi
1235: 83 7d fc 02 cmp DWORD PTR [rbp-0x4],0x2
1239: 74 0f je 124a <main+0x24>
123b: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
123f: 48 8b 00 mov rax,QWORD PTR [rax]
1242: 48 89 c7 mov rdi,rax
1245: e8 1f ff ff ff call 1169 <usage>
124a: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
124e: 48 83 c0 08 add rax,0x8
1252: 48 8b 00 mov rax,QWORD PTR [rax]
1255: 48 89 c7 mov rdi,rax
1258: e8 3a ff ff ff call 1197 <checkSerial>
125d: 85 c0 test eax,eax
125f: 75 13 jne 1274 <main+0x4e>
1261: 48 8d 3d a9 0d 00 00 lea rdi,[rip+0xda9] # 2011 <_IO_stdin_used+0x11>
1268: e8 c3 fd ff ff call 1030 <puts@plt>
126d: b8 00 00 00 00 mov eax,0x0
1272: eb 11 jmp 1285 <main+0x5f>
1274: 48 8d 3d a2 0d 00 00 lea rdi,[rip+0xda2] # 201d <_IO_stdin_used+0x1d>
127b: e8 b0 fd ff ff call 1030 <puts@plt>
1280: b8 ff ff ff ff mov eax,0xffffffff
1285: c9 leave
1286: c3 ret
First, we see the creation of the stack frame and the main arguments being moved to the stack. Then, we see the check to ensure that the program has exactly two arguments. The check is for two because a program always has its own name as the first argument.
The relevant part of the main function is as follows:
124a: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
124e: 48 83 c0 08 add rax,0x8
1252: 48 8b 00 mov rax,QWORD PTR [rax]
1255: 48 89 c7 mov rdi,rax
1258: e8 3a ff ff ff call 1197 <checkSerial>
We can see that the second argument of the program is passed to the
checkSerial function. We know is the second one because
rbp-0x10 contains the pointer to the beginning of the vector
argument, and then 8 bytes is added to the register (64 bits = 8 bytes).
So, we know now checkSerial checks the parameter we passed to the program. Knowing that, we can focus in the checkSerial function.
We can use again objdump to see the assembly code:
0000000000001197 <checkSerial>:
1197: 55 push rbp
1198: 48 89 e5 mov rbp,rsp
119b: 53 push rbx
119c: 48 83 ec 28 sub rsp,0x28
11a0: 48 89 7d d8 mov QWORD PTR [rbp-0x28],rdi
11a4: 48 8b 45 d8 mov rax,QWORD PTR [rbp-0x28]
11a8: 48 89 c7 mov rdi,rax
11ab: e8 90 fe ff ff call 1040 <strlen@plt>
11b0: 48 83 f8 10 cmp rax,0x10
11b4: 74 07 je 11bd <checkSerial+0x26>
11b6: b8 ff ff ff ff mov eax,0xffffffff
11bb: eb 62 jmp 121f <checkSerial+0x88>
11bd: c7 45 ec 00 00 00 00 mov DWORD PTR [rbp-0x14],0x0
11c4: eb 3d jmp 1203 <checkSerial+0x6c>
11c6: 8b 45 ec mov eax,DWORD PTR [rbp-0x14]
11c9: 48 63 d0 movsxd rdx,eax
11cc: 48 8b 45 d8 mov rax,QWORD PTR [rbp-0x28]
11d0: 48 01 d0 add rax,rdx
11d3: 0f b6 00 movzx eax,BYTE PTR [rax]
11d6: 0f be d0 movsx edx,al
11d9: 8b 45 ec mov eax,DWORD PTR [rbp-0x14]
11dc: 48 98 cdqe
11de: 48 8d 48 01 lea rcx,[rax+0x1]
11e2: 48 8b 45 d8 mov rax,QWORD PTR [rbp-0x28]
11e6: 48 01 c8 add rax,rcx
11e9: 0f b6 00 movzx eax,BYTE PTR [rax]
11ec: 0f be c0 movsx eax,al
11ef: 29 c2 sub edx,eax
11f1: 89 d0 mov eax,edx
11f3: 83 f8 ff cmp eax,0xffffffff
11f6: 74 07 je 11ff <checkSerial+0x68>
11f8: b8 ff ff ff ff mov eax,0xffffffff
11fd: eb 20 jmp 121f <checkSerial+0x88>
11ff: 83 45 ec 02 add DWORD PTR [rbp-0x14],0x2
1203: 8b 45 ec mov eax,DWORD PTR [rbp-0x14]
1206: 48 63 d8 movsxd rbx,eax
1209: 48 8b 45 d8 mov rax,QWORD PTR [rbp-0x28]
120d: 48 89 c7 mov rdi,rax
1210: e8 2b fe ff ff call 1040 <strlen@plt>
1215: 48 39 c3 cmp rbx,rax
1218: 72 ac jb 11c6 <checkSerial+0x2f>
121a: b8 00 00 00 00 mov eax,0x0
121f: 48 83 c4 28 add rsp,0x28
1223: 5b pop rbx
1224: 5d pop rbp
1225: c3 ret
Okay, we could analyze the assembly code for this function, but with all the jumps and register movement, it would be easier to open this in Ghidra and decompile it.
undefined8 checkSerial(char *param_1)
{
size_t sVar1;
undefined8 uVar2;
int local_1c;
sVar1 = strlen(param_1);
if (sVar1 == 0x10) {
for (local_1c = 0;
sVar1 = strlen(param_1), (ulong)(long)local_1c < sVar1; local_1c = local_1c + 2) {
if ((int)param_1[local_1c] - (int)param_1[(long)local_1c + 1] != -1) {
return 0xffffffff;
}
}
uVar2 = 0;
} else {
uVar2 = 0xffffffff;
}
return uVar2;
}
It’s time to rename some variables.
param1 is the string that is passed as a parameter, so we
can call it simply string.
sVar1 is the length of the string, so it can be called
string_len.
local_1c is used as the index of the for loop, so we can
call it simply i.
uVar1 is our return code, so we can call it just
return_code.
The code now looks like this:
undefined8 checkSerial(char *string)
{
size_t string_len;
undefined8 return_code;
int i;
string_len = strlen(string);
if (string_len == 16) {
for (i = 0; string_len = strlen(string), (ulong)(long)i < string_len; i = i + 2) {
if ((int)string[i] - (int)string[(long)i + 1] != -1) {
return 0xffffffff;
}
}
return_code = 0;
}
else {
return_code = 0xffffffff;
}
return return_code;
}
First, if the string’s length isn’t 16, the function returns -1 (although it shows 0xffffffff, we need to think of this as a two-complement value).
The function traverses the string by two-step increments and checks if the subtraction of the current character and the next character is different from -1; if so, the function returns -1. Otherwise, the function continues.
For example, if we have the two characters ‘A’ (0x41) and ‘B’
(0x42), subtracting them in that order yields -1. Therefore,
a valid key is a string of 16 ascending characters, such as
“abcdefghijklmnop”.
Time to test it!
> ./SimpleKeyGen abcdefghijklmnop
Good Serial
We now need to create a small program that generates all possible valid keys.
#include <stdio.h>
#define KEY_LEN 17
int main(void)
{
unsigned char key[KEY_LEN];
unsigned char last_char = 0x7e;
unsigned char start = 0x20;
unsigned char end = start + 0x10;
while (end != last_char) {
for (int i = 0; i < KEY_LEN - 1; i++)
key[i] = start + i;
key[KEY_LEN - 1] = '\0';
printf("Key: %s\n", key);
start++;
end++;
}
return 0;
}
Now, we can generate all the valid keys that use only printable characters. Here are the first five generated keys:
Key: !"#$%&'()*+,-./
Key: !"#$%&'()*+,-./0
Key: "#$%&'()*+,-./01
Key: #$%&'()*+,-./012
Key: $%&'()*+,-./0123
We can test to see if the keys are valid. (We need to escape certain characters to avoid having the shell interpret them).
> ./SimpleKeyGen " \!\"#$%&'()*+,-./"
Good Serial
> ./SimpleKeyGen "#$%&'()*+,-./012"
Good Serial
Although it may be a simple program with a simple algorithm, we learned how to analyze a binary to understand how it works in detail. We focus on the important parts of the program and write a program that generates valid keys using the knowledge we gain from analyzing how the software validates keys. We can apply this knowledge to any software to analyze it and see how it works and what it does. Perhaps we will discover that our favorite software is performing suspicious activities.