Initial look
Running soulreaper_shell normally reveals a small shell with its own prompt.

You can execute regular commands as if this were a normal shell. Commands such as mkdir, cat, and nano are passed to the system and work as long as they are available in the environment.
I didn't find any obvious commands or other attack vectors that could be used to crack the program, so I opened it in Binary Ninja.
- file
- soulreaper_shell
- format
- ELF64 / x86-64
- analysis tool
- Binary Ninja
- original crackme
- View on crackmes.one
- main
- around 0x401280
- key validator
- 0x401560
- anti-debugging check
- 0x401610
- original SHA-256
- a0ca53ad836de59c992a2d3eeff7ac76ad8539eba960c9c3405b2b419e55301e
- patched binary
- soulreaper_shell_cracked
- patched SHA-256
- 47cb9aa771aebda7dd9192bfb56f63bcaba81a0c58746c3ffe0e50fe9f4fc93f
Looking at `main`
The decompiled main function first calls sub_401610. If that function returns a non-zero value, the program prints Debugger Detected and stops processing commands. I didn't reverse this function because it isn't needed to recover the key or patch the password check.
There are also a few lines involving fsbase + 0x28. These are stack-canary checks added by the compiler. The function saves a value from that location when it starts, then compares it again before returning. If the values are different, the program assumes that something overwrote part of the stack and calls __stack_chk_fail.
That code is protection against stack corruption. It is not part of the key check.
After the anti-debugging check, the program repeatedly prints its prompt and reads a line of input. It removes the newline, splits the line into arguments, and looks at the first argument.
The relevant logic is roughly:
command = strtok(input, " ");
if (strcmp(command, "exit") == 0)
stop_shell();
if (strcmp(command, "reap") != 0) {
pid = fork();
if (pid == 0)
execvp(command, arguments);
else
waitpid(pid, NULL, 0);
} else if (key == NULL) {
puts("Usage : reap <key>");
} else if (sub_401560(key) == 0) {
puts("Access denied");
} else {
puts("Access granted");
}So reap is a custom command. Its first argument is passed to sub_401560, which is the function that checks the key.
The password check
The important part of sub_401560 is:
value = 0x34378e828a78797f;
result = 0;
if (strlen(input) == 8) {
increment = 7;
for (index = 0; index < 8; index++) {
expected = ((input[index] ^ 0x2a) + increment) & 0xff;
if (expected != stored[index])
return 0;
increment += 3;
}
return 1;
}
return 0;The first thing it does is check that the argument is exactly 8 characters long. It then transforms each input byte by XORing it with 0x2a and adding an increment that starts at 7 and increases by 3 each time.
The increments used for the eight characters are:
7, 10, 13, 16, 19, 22, 25, 28One confusing part of the Binary Ninja output is that it shows var_18 as an int64_t and correct as an int64_t*. That makes it look as if the function is comparing 64-bit values, but the logic is actually working with the eight individual bytes stored in that value. When the decompiler's types look strange, the assembly is more reliable than the generated C-like output.
Since x86-64 uses little-endian byte order, the bytes of the constant are stored as:
7f 79 78 8a 82 8e 37 34The check is:
((input_byte XOR 0x2a) + increment) == stored_byteTo reverse it, we undo the addition first and then undo the XOR:
input_byte = (stored_byte - increment) XOR 0x2aRecovering the password
I copied the inverse transformation into a short Python script:
value = 0x34378e828a78797f
expected = value.to_bytes(8, byteorder="little")
password = bytearray()
increment = 7
for stored_byte in expected:
character = ((stored_byte - increment) ^ 0x2a) & 0xff
password.append(character)
increment += 3
print("Password:", password.decode())This gives:
Password: REAPER42Using it as the argument to reap gives the success message:
soulreaper shell > reap REAPER42
Access granted
join us : https://t.me/+blTRfHi8oKJiN2E0
Bypassing the check instead
Recovering the password is one way to solve the challenge. Another way is to change the conditional branch that decides whether the validator result is accepted.
The relevant assembly in main is:
0x401390 call sub_401560
0x401395 test eax, eax
0x401397 je 0x4013f8
0x401399 lea rsi, [rel data_402061] ; "Access granted"
0x4013a0 mov edi, 0x2
0x4013a5 xor eax, eax
0x4013a7 call __printf_chkThe return value from sub_401560 is placed in eax. The test eax, eax instruction checks whether that value is zero:
eax = 0 -> zero flag is set -> invalid key
eax = 1 -> zero flag is clear -> valid keyThe original je means “jump if equal to zero”. Therefore, when the validator returns 0, execution jumps to the Access denied block at 0x4013f8. When the validator returns 1, execution falls through to the granted block at 0x401399.
I used Binary Ninja's invert-branch patch on the instruction at 0x401397, changing je to jne.
The original and patched bytes are:
address: 0x401397
original: 74 5f ; je 0x4013f8
patched: 75 5f ; jne 0x4013f8After the patch, an invalid key no longer jumps to the denied block. It falls through to the granted block instead:
eax = 0 -> jne is not taken -> Access granted
eax = 1 -> jne is taken -> Access deniedThe reap command still needs an argument, because the null-argument check happens before the validator is called. However, any key that fails the original check can now reach the success path.
