Crackme 17: Silva97’s login-cipher
This crackme from 2019 is hosted on crackmes.one. You can also download the binary here.
Let’s start with the basics: the program mentions that no patching is allowed, and then asks for a single password.
$ ./login
Don't patch it!
Insert your password: hello
Wrong!
strings
tells us this was compiled with GCC on Ubuntu, and a few weird values stand out:
Gtu.}'uj{fq!p{$
Lszl{{%.
vx{!whvt|twg?%
%64[^
fhz4yhx|~g=5
Ftyynjy*
Zwvup(
The output of strings
often includes some irrelevant garbage, but these look different.
strace
doesn’t reveal any ptrace
calls, so at least we won’t have to deal with this kind of protection. The ELF header also seems untouched, which means that GDB can open and run the binary without issues.
Let’s start with Hopper and find these strings. We quickly notice that several are referenced in sub_12a1
, each one loaded into rdi
before a call to sub_1348
, which seems like it might be a decryption function:
00000000000012bd lea rdi, qword [aGtuujfqp] ; "Gtu.}'uj{fq!p{$"
00000000000012c4 call sub_1348 ; sub_1348
00000000000012c9 mov esi, 0x0
00000000000012ce lea rdi, qword [aGtuujfqp+16] ; "Lszl{{%"
00000000000012d5 call sub_1348 ; sub_1348
00000000000012da lea rax, qword [rbp+var_50]
00000000000012de mov rsi, rax
00000000000012e1 lea rdi, qword [a64n] ; "__format" for scanf: "%64[^\\n]"
00000000000012e8 mov eax, 0x0
00000000000012ed call j___isoc99_scanf ; __isoc99_scanf
00000000000012f2 lea rax, qword [rbp+var_50]
00000000000012f6 lea rsi, qword [aFhz4yhxg5] ; "fhz4yhx|~g=5"
00000000000012fd mov rdi, rax
0000000000001300 call sub_13e3 ; not sure what this one does yet
0000000000001305 test eax, eax
0000000000001307 jne loc_131c
0000000000001309 mov esi, 0x1
000000000000130e lea rdi, qword [aFtyynjy] ; "Ftyynjy*"
0000000000001315 call sub_1348 ; sub_1348
000000000000131a jmp loc_132d
loc_131c:
000000000000131c mov esi, 0x1 ; CODE XREF=sub_12a1+102
0000000000001321 lea rdi, qword [aZwvup] ; "Zwvup("
0000000000001328 call sub_1348 ; sub_1348
The next logical step would be to look at sub_1348
. The function is pretty short: it simply copies its argument into a buffer using strcpy
(this tells us that the input strings are null-terminated), and then calls sub_1218
with this buffer as its single argument. After sub_1218
returns, its output is passed to j_puts
, either with the string as a single parameter or with the string and a reference to stdout
– think puts(const char *)
vs fputs(const char *, FILE *)
.
We can also see it save its return address and check it after the call to j_puts
, which I think is a stack overflow canary added by the compiler and not logic that was part of the program’s source code. This was likely added when the compiler detected a strcpy
writing into a fixed-sized buffer.
sub_1348:
0000000000001348 push rbp
0000000000001349 mov rbp, rsp
000000000000134c sub rsp, 0x120
0000000000001353 mov qword [rbp+var_118], rdi ; copy argument into temp var
000000000000135a mov eax, esi
000000000000135c mov byte [rbp+var_11C], al
0000000000001362 mov rax, qword [fs:0x28] ; stack overflow canary
000000000000136b mov qword [rbp+var_8], rax ; stored into a local variable
000000000000136f xor eax, eax
0000000000001371 mov rdx, qword [rbp+var_118] ; move argument to rdx
0000000000001378 lea rax, qword [rbp+var_110] ; point rax to local target buffer
000000000000137f mov rsi, rdx ; "src" for j_strcpy
0000000000001382 mov rdi, rax ; "dst" for j_strcpy
0000000000001385 call j_strcpy ; call to strcpy
000000000000138a lea rax, qword [rbp+var_110]
0000000000001391 mov rdi, rax
0000000000001394 call sub_1218 ; call this other procedure (unknown so far)
0000000000001399 cmp byte [rbp+var_11C], 0x0 ; if the second arg is zero
00000000000013a0 je loc_13b3 ; jump over the following block ――――╮
; │
00000000000013a2 lea rax, qword [rbp+var_110] ; │
00000000000013a9 mov rdi, rax ; argument "__s" for method j_puts │
00000000000013ac call j_puts ; call puts with just a string │
00000000000013b1 jmp loc_13cc ; jump over the following block ――――┼――╮
; │ │
loc_13b3: ; <――――――――――――――――――――――――――――╯ │
00000000000013b3 mov rdx, qword [stdout] ; make rdx point to stdout │
00000000000013ba lea rax, qword [rbp+var_110] ; │
00000000000013c1 mov rsi, rdx ; "__stream" for method j_fputs │
00000000000013c4 mov rdi, rax ; "__s" for method j_fputs │
00000000000013c7 call j_fputs ; call puts with a string and stdout │
; │
loc_13cc: ; <―――――――――――――――――――――――――――――――╯
00000000000013cc nop
00000000000013cd mov rax, qword [rbp+var_8] ; read back the local variable
00000000000013d1 xor rax, qword [fs:0x28] ; xor with the return address
00000000000013da je loc_13e1 ; if they match, jump below
00000000000013dc call j___stack_chk_fail ; otherwise, report stack check failure
Time to look at sub_1218
. It consists of a loop going over its argument in rdi
, with 37 instructions in the body of the loop doing mostly mov
, add
, sub
, imul
, sar
and shr
. This seems to be written by hand, since I don’t think a compiler would generate such long-winded and repetitive code. There isn’t much point going over the details since they are pretty tedious, but we don’t have to explore it in more detail since it’s clear that this function has no other purpose than decrypting strings.
Next, we can figure out the actual base address used for the program by starting with a breakpoint on strcpy
:
(gdb) b strcpy
Breakpoint 1 at 0x1030
(gdb) r
We end up in strcpy
itself, so can just use ret
to get back to the caller:
(gdb) bt
#0 __strcpy_sse2_unaligned () at ../sysdeps/x86_64/multiarch/strcpy-sse2-unaligned.S:47
#1 0x000055555555538a in ?? ()
#2 0x00005555555552c9 in ?? ()
#3 0x00007ffff7e2c09b in __libc_start_main (main=0x5555555552a1, argc=0x1, argv=0x7fffffffed68, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffed58) at ../csu/libc-start.c:308
#4 0x00005555555550ba in ?? ()
(gdb) ret
#0 0x000055555555538a in ?? ()
(gdb) x/4i $pc
=> 0x55555555538a: lea rax,[rbp-0x110]
0x555555555391: mov rdi,rax
0x555555555394: call 0x555555555218
0x555555555399: cmp BYTE PTR [rbp-0x11c],0x0
We recognize the instructions that follow the call to strcpy
: the lea
was originally found at 0x000000000000138a
and we now see it at 0x55555555538a
, so we can change the base address to 0x555555554000
for them to match in Hopper and GDB.
After the two strings are printed, we call sscanf
to read the password. This input is passed to sub_13e3
along with the constant string "fhz4yhx|~g=5"
. This function starts by taking its second argument (what looks like an encrypted string again) and calls sub_1175
on it before comparing the result to our password input:
00005555555553ef mov qword [rbp+var_20], rsi ; the encrypted string saved in var_20
00005555555553f3 mov rax, qword [rbp+var_20] ; then moved to rax
00005555555553f7 mov rdi, rax ; and then rdi
00005555555553fa call sub_1175 ; before calling sub_1175
...
0000555555555422 mov rax, qword [rbp+var_18] ; load var_18
0000555555555426 lea rdx, qword [rax+1] ; make rdx point to the byte that follows
000055555555542a mov qword [rbp+var_18], rdx ; and save back into var_18 (this is a counter)
000055555555542e movzx eax, byte [rax] ; read the current byte
0000555555555431 movsx eax, al ; trim to keep just 8 bits
0000555555555434 cmp dword [rbp+var_4], eax ; compare to var_4
0000555555555437 je loc_555555555404 ; loop back up if they match
This seems to be comparing our input with the decrypted string produced by sub_1175("fhz4yhx|~g=5")
.
Looking at sub_1175
, it is very similar to the earlier decryption function sub_1218
: it has the same constant 0x7b1
at the start, the same multiplication by 0x66666667
, the same bit shifts… it seems like we could try to assume that they do the same thing and see where this goes.
If we look at how the return value of sub_13e3
is handled, there are two cases:
0000555555555300 call sub_13e3 ; the decryption + comparison function
0000555555555305 test eax, eax ; if it returned 0, jump below
0000555555555307 jne loc_55555555531c ; ――――――――――――――――――――――――╮
0000555555555309 mov esi, 0x1 ; │
000055555555530e lea rdi, qword [aFtyynjy] ; "Ftyynjy*" │
0000555555555315 call sub_1348 ; sub_1348 │
000055555555531a jmp loc_55555555532d ; jump all the way down │
; │
000055555555531c mov esi, 0x1 ; <―――――――――――――――――――――――╯
0000555555555321 lea rdi, qword [aZwvup] ; "Zwvup("
0000555555555328 call sub_1348 ; sub_1348
Both of these strings are sent to sub_1348
for decrypting and printing, so we can try taking a look ourselves by just calling the function by hand. We start in GDB with a breakpoint in sub_12a1
just before a call to sub_1348
, and change rdi
to point to the string we want to investigate:
(gdb) b *0x00005555555552c4
Breakpoint 1 at 0x5555555552c4
(gdb) r
Starting program: ./login
Breakpoint 1, 0x00005555555552c4 in ?? ()
(gdb) set $rdi = 0x0000555555556040
(gdb) printf "%s", $rdi
Ftyynjy*
(gdb) c
Continuing.
Correct!
Insert your password:
Perfect! "Ftyynjy*"
decodes to "Correct!"
, so indeed the decryption code is the same. We repeat the same process for "Zwvup("
:
(gdb) set $rdi = 0x0000555555556049
(gdb) printf "%s", $rdi
Zwvup(
(gdb) c
Continuing.
Wrong!
So now let’s see what sub_1348
would do with the second argument to sub_1175
:
(gdb) set $rdi = 0x0000555555556033
(gdb) printf "%s", $rdi
fhz4yhx|~g=5
(gdb) c
Continuing.
ccs-passwd44
Let’s try it:
$ ./login
Don't patch it!
Insert your password: ccs-passwd44
Correct!
This wasn’t the most challenging crackme we’ve seen so far. Its complexity was limited to the decryption function, but the presence in the strings
output of the encrypted password and a general lack of obfuscation or reverse-engineering countermeasures made it relatively simple to break.
With this password now revealed, a Google search reveals the actual source code for the crackme. You’ll recognize the puts
vs fputs
logic, the calls to strcpy
and scanf
, and a pre-processor macro wrapping each string so that only their encrypted values are included in the binary.
Edit:
A day after publishing this walk-through, I tried opening the login
binary with
Ghidra
and was amazed by how much it managed to simplify the decryption function. Its disassembler produced the following code (I only renamed the variables):
char * FUN_00101218(char *input) {
unsigned int temp;
char *cur_char;
temp = 0x7b1;
cur_char = input;
while (*cur_char != '\0') {
temp = temp * 7 & 0xffff;
*cur_char = *cur_char + ((char)(temp / 10) * '\n' - (char)temp);
cur_char = cur_char + 1;
}
return input;
}
This is so much more concise than the mess of mov
and bit shifts that we see in the raw disassembly, with most of the complexity now erased by its successive rewriting rules.
Note that \n
is also 10, so the expression (temp ÷ 10) × 10 - temp
can be reduced further to - (temp % 10)
. We can therefore rewrite it as:
def decode(input):
temp = 0x7b1
output = []
for i in range(len(input)):
temp = (temp * 7) & 0xffff
output.append(chr(ord(input[i]) - (temp % 10)))
return ''.join(output)
And test it:
>>> for input in ["Gtu.}'uj{fq!p{$", "fhz4yhx|~g=5", "Ftyynjy*", "Zwvup("]:
... print(decode(input))
...
Don't patch it!
ccs-passwd44
Correct!
Wrong!
With this kind of result, it seems like I need to start using Ghidra more regularly.