Back to posts

Soulreaper_shell crackme

Taking apart a small x86-64 shell crackme in Binary Ninja, recovering REAPER42 with Python, then inverting the branch that enforces the key check.

On this pageInitial lookLooking at `main`The password checkRecovering the passwordBypassing the check instead

Initial look

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

The soulreaper shell showing its prompt and accepting regular shell commands.

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:

snippet.c
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:

snippet.c
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:

snippet.txt
7, 10, 13, 16, 19, 22, 25, 28

One 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:

snippet.txt
7f 79 78 8a 82 8e 37 34

The check is:

snippet.txt
((input_byte XOR 0x2a) + increment) == stored_byte

To reverse it, we undo the addition first and then undo the XOR:

snippet.txt
input_byte = (stored_byte - increment) XOR 0x2a

Recovering the password

I copied the inverse transformation into a short Python script:

snippet.python
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:

snippet.txt
Password: REAPER42

Using it as the argument to reap gives the success message:

snippet.txt
soulreaper shell > reap REAPER42
Access granted
join us : https://t.me/+blTRfHi8oKJiN2E0
The soulreaper shell accepting REAPER42 and printing Access granted.

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:

snippet.asm
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_chk

The return value from sub_401560 is placed in eax. The test eax, eax instruction checks whether that value is zero:

snippet.txt
eax = 0  -> zero flag is set -> invalid key
eax = 1  -> zero flag is clear -> valid key

The 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:

snippet.txt
address:       0x401397
original:      74 5f       ; je  0x4013f8
patched:       75 5f       ; jne 0x4013f8

After the patch, an invalid key no longer jumps to the denied block. It falls through to the granted block instead:

snippet.txt
eax = 0  -> jne is not taken -> Access granted
eax = 1  -> jne is taken     -> Access denied

The 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.

The patched soulreaper shell accepting an invalid key and printing Access granted.