Links:

Time-Locked Authentication

Analyzing Time-Locked Authentication

Opening the challenge in Binary Ninja, we begin in the main function.

When reading assembly, you should read instructions right-to-left. For example, mov eax, 0x0 means “move 0x0 into eax.” Every function begins with a prologue, which establishes the stack frame, and ends with an epilogue, which clears the stack it.

The main function consists solely of a standard prologue, a call to sub_401311, and a corresponding epilogue. The prologue begins with endbr64 which essentially allows the function to be called through function pointers (though this is irrelevant in this case). Followed by push rbp to preserve the base pointer, and mov rbp, rsp to establish the new stack frame. The compiler then clears eax (typically achieved via xor eax, eax, but here the binary was compiled without optimization). The cleared eax prepares for the call to sub_401311, even though the callee does not take arguments. After invoking sub_401311, eax is cleared again prior to retn, ensuring that main returns 0. The stored base pointer is restored in the epilogue. Thus, main performs no logic other than calling sub_401311.

Taking a closer look at sub_401311 in Binja:

As expected, the function begins with the same prologue as before, except it subtracts 0x10 from rsp (the stack pointer). Since the stack grows downward, this reserves 16 bytes of local storage.

The function initializes a DWORD variable var_c at rbp - 0x4 with the value 0x0. It then loads the address of data_402020 into rax via lea, and moves that address into rdi. data_402020 contains the string "Enter password: ". By selecting data_402020 and pressing Y (tYpe), we can confirm it is already recognized as char const[0x11]. We will rename it to lpRequestPasswordStr by pressing N (Name). Lp stands for long pointer, which is the case here while rdi only contains the address of the string, not the actual string making it a pointer.

eax is cleared again to indicate no floating-point arguments according to the AMD64 calling convention, after which printf prints the prompt stored in rdi, which is the first argument of printf. The AMD64 calling conventions corresponding registers and argument place can be seen below:

Argument Register
1st RDI (or EDI for 32-bit)
2nd RSI
3rd RDX
4th RCX
5th R8
6th R9
More pushed on stack

Next, the local variable var_14 is initialized and its address (rbp - 0xc) is placed into rsi, making rsi a pointer to var_14. Since var_14 ranges from offset 0xc to 0x4, it is 8 bytes in size.

The address of data_402031 is moved into rdi. Inspecting data_402031 reveals the format string "%s", we’ll therefore hit Y and change the type to const char [0x3] (0x3 because of the null terminator). After clearing eax, the function calls __isoc99_scanf with the first argument set to the format string and the second argument pointing to var_14. Because this input is intended to store user input, we rename var_14 to userInput and change its type to char[0x8].

Afterward, the addresses of var_c and userInput are passed via rsi and rdi to sub_401223. var_c is then moved into eax and compared to 0x1. If equal, execution follows the green path and prints "Authentication Failed". Otherwise, execution follows the red path and calls sub_401209, which prints "Access Granted!". Since optimizations were disabled, this call is not inlined. We therefore rename sub_401209 to print_access_granted, and var_c to password. With this function analyzed, we rename it main_func, as it performs the program’s primary logic.

We now examine the invoked function sub_401223. Although unnecessary for solving the crackme, analyzing it is useful for understanding the underlying design.

The first notable feature is the presence of several large hexadecimal constants: 0x37315f6e696d6461, 0x3030383637323233, 0x3231303038363732, and 0x39383736353433. These values are written into the local QWORD variable var_148, followed by var_140 and var_136, and later passed into rdx, rsi, and rdi before a call to strcmp. This strongly suggests the constants represent string fragments rather than numbers. Converting them to strings hitting R (stRing), yields "admin_17", "32276800", "27680012", and "3456789".

Examining their stack layout:

  • "admin_17" is located on rbp-0x140
  • "32276800" is located on rbp-0x138
  • "27680012" is located on rbp-0x136
  • "3456789" is located on rbp-0x12e

The boundaries of these copied segments overlap, causing them to merge into a single string: “admin_1732276800123456789”`.

At the top of the function, 0x150 bytes are reserved on the stack. The function stores its first two arguments (rdi and rsi), previously identified as userInput and password, into var_150 and var_158. We rename them accordingly, as well as changing the type.

The function then calls time(0) and stores the result in current_time, although this value is never used. Next, a timespec structure named tp is placed on the stack. Its address is loaded into rax, edi is set to 0, and clock_gettime is invoked. This populates tp with the current seconds and nanoseconds. userInput is loaded into rdx, and a local buffer s is prepared. The program then invokes snprintf with the format string "%s_%ld%09ld", producing a string containing the user input followed by the current time values. Therefore, we rename s to formattedUserInput. Finally, formattedUserInput is compared to "admin_1732276800123456789" via strcmp. If equal, the function sets password to 0x1; otherwise, it leaves it untouched. The fact that the failure case leaves password unchanged is the key to solving the challenge. We rename this function checkPassword.

Overflowing the buffer

To solve the challenge, we return to main_func, specifically the scanf call:

char userInput[0x8];
scanf("%s", &userInput);

This construct is inherently unsafe. The "%s" specifier in scanf performs unbounded reads, making it trivial to overflow the 8-byte buffer. Any input longer than 8 characters will overwrite adjacent stack variables. The stack layout is illustrated below:

scanf writes starting at rbp - 0xc and proceeds toward higher addresses, placing the adjacent password variable at risk. Supplying more than 8 characters will overwrite it.

Since password is compared against 0x1, our objective is simply to overwrite it with the value 0x1. To do so, we provide exactly 8 filler characters followed by a byte with value 0x01. For example:

python3 -c 'print("A"*8 + "\x01")' | ./crackme

which will output:

Enter password: Access Granted!