Kalmar CTF - VM

On nous fournit un binaire : chall

Le binaire est très petit, une seule fonction start() :

"Main"

On remarque plusieurs éléments d’une VM :

  • On crée une zone mémoire avec mmap
  • on va itérer sur un blob binaire en mémoire, et à chaque itération on va prendre une valeur, qui sera utilisé pour un switch case, qui opére sur la zone mémoire alloué.

=> pas besoin de regarder plus loin : c’est une VM.

Analyse des opcodes.

Il y’a au total 15 switch cases donc 15 opcodes. Certains évidents (sys_write/sys_read/…), d’autre plus obscur.
L’analyse des VM peut parfois être souvent être complexe, alors il est judicieux d’aller à l’essentiel pour ne pas perdre du temps.

opcode = {
    0 : "INC_BASE",
    1 : "SUB_BASE",
    2 : "INC",
    3 : "SUB",
    4 : "XOR",
    5 : "AND",
    6 : "OR",
    7 : "MEM_UPDATE",
    81 : "WRITE_STDOUT",
    82 : "READ_STIN",
    9 : "MEM_UPDATE2",
    10 : "*MMAP",
    11 : "INC_R1",
    12 : "UPDATE_POS0",
    13 : "UPDATE_POS1",
    14 : "dead_opcode",
    15 : "exit",
}

Une première analyse des opcodes permet de donner ce genre d’aproximation. Je ne veux pas étudier en détaille la VM (registre interne, …) donc on va juste utiliser cette base pour le moment.
On transforme le tout en script GDB, le but étant de tracer l’activité de la VM en entière pour savoir ce qu’elle fait précisement.

import gdb
import string
import binascii
import os

gdb.execute("file chall")
gdb.execute("d")

class Logger():
    def __init__(self):
        self.ppath = "vm_log.txt"
        if os.path.exists(self.ppath):
            os.remove(self.ppath)
        self.fd = open(self.ppath, "w")

    def log(self, entry:str, silent:bool=False):
        self.fd.write(entry + "\n")
        if silent:
            print(entry)    
        self.fd.flush()

    def __del__(self):
        if self.fd:
            self.fd.close()

logger = Logger()

def to_int(gdb_v:gdb.Value):
    return int(gdb_v.cast(gdb.lookup_type('long long')))

def pad(v:str, sizepad:int) -> str:
    sz = len(v)
    return f"{v}" + " "*(sizepad-sz)

def try_str(v:int) -> str:
    if v > 0xff or v < 0:
        return v
    r = chr(v)
    if r in string.printable[0:len(string.printable)-10]:
        return f"{v}({chr(v)})"
    else:
        return v

opcode = {
    0 : "INC_BASE",
    1 : "SUB_BASE",
    2 : "INC",
    3 : "SUB",
    4 : "XOR",
    5 : "AND",
    6 : "OR",
    7 : "MEM_UPDATE",
    81 : "WRITE_STDOUT",
    82 : "READ_STIN",
    9 : "MEM_UPDATE2",
    10 : "*MMAP",
    11 : "INC_R1",
    12 : "UPDATE_POS0",
    13 : "UPDATE_POS1",
    14 : "dead_opcode",
    15 : "exit",
    
}

class VMDecoder(gdb.Breakpoint):
    
    def __init__(self, bp, opcode):
        super().__init__(bp)
        self.opcode = opcode
    def stop(self):

        opcode_str = opcode[self.opcode]
        rbx = to_int(gdb.parse_and_eval("$rbx"))
        rbp = to_int(gdb.parse_and_eval("$rbp"))
        rsi = to_int(gdb.parse_and_eval("$rsi"))
        rcx = to_int(gdb.parse_and_eval("$rcx"))
        al = to_int(gdb.parse_and_eval("(unsigned char)$al"))
        r9 = to_int(gdb.parse_and_eval("$r9"))
        rax = to_int(gdb.parse_and_eval("$rax"))
        dil = to_int(gdb.parse_and_eval("$dil"))
        cl = to_int(gdb.parse_and_eval("$cl"))
        #MAIN
        match self.opcode:    

            #case 0:
            #    addr = r9+rax
            #    v_old = to_int(gdb.parse_and_eval("(unsigned char)*($r9+$rax)"))
            #    logger.log(f"{opcode_str} {hex(addr)} = {try_str(v_old)} + {try_str(dil)} ")

            case 1:
                addr = r9+rax
                v_old = to_int(gdb.parse_and_eval("(unsigned char)*($r9+$rax)"))
                logger.log(f"{opcode_str} {hex(addr)} = {try_str(v_old)} - {try_str(dil)} ")

            case 2 | 3 | 4 | 5 | 6:
                addr = r9+rax
                v_old = to_int(gdb.parse_and_eval("(unsigned char)*($r9+$rax)"))
                logger.log(f"{opcode_str} {hex(addr)} = {try_str(v_old)} {opcode_str} {try_str(cl)} ")
                    
            case 7:
                addr = r9+rax
                logger.log(f"{opcode_str} {hex(addr)} = {try_str(cl)} ")
                
            case 82:
                logger.log(f"{opcode_str}")

            case 81:
                toWrite = to_int(gdb.parse_and_eval("(char)*($rsi)"))
                logger.log(f"{opcode_str} -> {try_str(toWrite)}")

            case 9:
                logger.log(f"{opcode_str} -> {hex(rcx)} = {try_str(al)}")

            case 10:
                logger.log(f"{opcode_str} -> {hex(rsi)} = {try_str(al)}")

            #case 12|13:
            #    logger.log(f"{opcode_str} -> POS={rbp}")

            case 14:
                logger.log("DEAD_OPCODE")

            case 15:
                logger.log("EXIT")
            case _:
                return False

        ####STOP
        return False
    
bp = VMDecoder("*0x401301", 0)
bp = VMDecoder("*0x401266", 1)
bp = VMDecoder("*0x401252", 2)
bp = VMDecoder("*0x401222", 3)
bp = VMDecoder("*0x4012C2", 4)
bp = VMDecoder("*0x401292", 5)
bp = VMDecoder("*0x4012F2", 6)
bp = VMDecoder("*0x4011F6", 7)
bp = VMDecoder("*0x4011A9", 9)
bp = VMDecoder("*0x4011C5", 81)
bp = VMDecoder("*0x401316", 82)
bp = VMDecoder("*0x40118F", 10)
bp = VMDecoder("*0x401167", 11)
bp = VMDecoder("*0x401140", 12)
bp = VMDecoder("*0x401137", 13)
bp = VMDecoder("*0x401108", 14)
bp = VMDecoder("*0x4010F0", 15)

gdb.execute(f"run <<< 'TESTFLAG'")

Une fois dans gdb, on peut lancer le script avec source solve.py Cela nous donne vm_log.txt, qui nous un aperçu de l’activité de la VM :

"Main"

Cela nous donne une trace plutot longue (~3500)

Au final, la VM sauvegarde dans la mémoire l’entrée utilisateur et procède à une série d’opérations : ADD, XOR, …
Dans une VM de CTF, l’opcode de test/cmp est souvent difficile à cacher : strmcmp, CMP VM_R1 , VM_R2, …

Les autheurs utilisent souvent un tricks pour cacher le cmp : le saut de position.
Supposons un code de VM qui se positionne dans un blob binaire. Si nous décidons d’incrémenter le pointeur du blob en cas d’un cmp réussi vers une position plus loin -> nous débloquons une partie de la VM non exporé.
Nous devons chercher quelque chose de similaire ici.

"Main"

Ici le handler 13 de la VM permet d’update la position de la VM.

Résolution

Nous pourrions suivre l’handler 13 et déterminer l’accès au bon code/mauvais code de la VM mais il y’a plus simple…

Dans la VM nous pouvons suivre les registres/mémoires utilisés (dump les registres avec le scripting GDB) => on remarque que la chaine de caractère psyduck est fortement utilisé.

On sait que le flag commence par kalmar{}
Je place plusieurs AAAAA, et que je remarque le code suivant :

SUB 0x7ffff7fdab36 = 112(p) SUB 112(p) 
SUB 0x7ffff7fdb4fa = 115(s) SUB 115(s) 
SUB 0x7ffff7fdbebe = 121(y) SUB 121(y) 
SUB 0x7ffff7fdc882 = 100(d) SUB 100(d) 
SUB 0x7ffff7fdd246 = 117(u) SUB 117(u) 
SUB 0x7ffff7fddc0a = 99(c) SUB 99(c) 
SUB 0x7ffff7fde5ce = 107(k) SUB 107(k) 
SUB 0x7ffff7fdef92 = 122(z) SUB 112(p) 

L’entrée utilisateur est transformée à plusieurs reprises, et si les octets sont bons, nous avons un SUB avec psyduck et psyduck. kalmar{ étant le début du flag, nous avons bien psyduck-psyduck OK. Cependant la suite est mauvaise. On peut donc bruteforce octect par octet pour déterminer le flag.

ce qui donne :

import gdb
import string
import binascii
import os

gdb.execute("file chall")
gdb.execute("d")

class Logger():
    def __init__(self):
        self.ppath = "vm_log.txt"
        if os.path.exists(self.ppath):
            os.remove(self.ppath)
        self.fd = open(self.ppath, "w")

    def log(self, entry:str, silent:bool=False):
        self.fd.write(entry + "\n")
        if silent:
            print(entry)    
        self.fd.flush()

    def __del__(self):
        if self.fd:
            self.fd.close()

logger = Logger()

def to_int(gdb_v:gdb.Value):
    return int(gdb_v.cast(gdb.lookup_type('long long')))

def pad(v:str, sizepad:int) -> str:
    sz = len(v)
    return f"{v}" + " "*(sizepad-sz)

def try_str(v:int) -> str:
    if v > 0xff or v < 0:
        return v
    r = chr(v)
    if r in string.printable[0:len(string.printable)-10]:
        return f"{v}({chr(v)})"
    else:
        return v

opcode = {
    0 : "INC_BASE",
    1 : "SUB_BASE",
    2 : "INC",
    3 : "SUB",
    4 : "XOR",
    5 : "AND",
    6 : "OR",
    7 : "MEM_UPDATE",
    81 : "WRITE_STDOUT",
    82 : "READ_STIN",
    9 : "MEM_UPDATE2",
    10 : "*MMAP",
    11 : "INC_R1",
    12 : "UPDATE_POS0",
    13 : "UPDATE_POS1",
    14 : "dead_opcode",
    15 : "exit",
    
}


class VMDecoder(gdb.Breakpoint):
    
    def __init__(self, bp, opcode):
        super().__init__(bp)
        self.opcode = opcode
    def stop(self):

        opcode_str = opcode[self.opcode]
        rbx = to_int(gdb.parse_and_eval("$rbx"))
        rbp = to_int(gdb.parse_and_eval("$rbp"))
        rsi = to_int(gdb.parse_and_eval("$rsi"))
        rcx = to_int(gdb.parse_and_eval("$rcx"))
        al = to_int(gdb.parse_and_eval("(unsigned char)$al"))
        r9 = to_int(gdb.parse_and_eval("$r9"))
        rax = to_int(gdb.parse_and_eval("$rax"))
        dil = to_int(gdb.parse_and_eval("$dil"))
        cl = to_int(gdb.parse_and_eval("$cl"))
        #MAIN
        match self.opcode:    

            #case 0:
            #    addr = r9+rax
            #    v_old = to_int(gdb.parse_and_eval("(unsigned char)*($r9+$rax)"))
            #    logger.log(f"{opcode_str} {hex(addr)} = {try_str(v_old)} + {try_str(dil)} ")

            case 1:
                addr = r9+rax
                v_old = to_int(gdb.parse_and_eval("(unsigned char)*($r9+$rax)"))
                logger.log(f"{opcode_str} {hex(addr)} = {try_str(v_old)} - {try_str(dil)} ")

            case 2 | 3 | 4 | 5 | 6:
                addr = r9+rax
                v_old = to_int(gdb.parse_and_eval("(unsigned char)*($r9+$rax)"))
                logger.log(f"{opcode_str} {hex(addr)} = {try_str(v_old)} {opcode_str} {try_str(cl)} ")
                if self.opcode == 3:
                    global arrayT
                    
                    arrayT.append((v_old,cl))
                    

            case 7:
                addr = r9+rax
                logger.log(f"{opcode_str} {hex(addr)} = {try_str(cl)} ")
                
            case 82:
                logger.log(f"{opcode_str}")

            case 81:
                toWrite = to_int(gdb.parse_and_eval("(char)*($rsi)"))
                logger.log(f"{opcode_str} -> {try_str(toWrite)}")

            case 9:
                logger.log(f"{opcode_str} -> {hex(rcx)} = {try_str(al)}")

            case 10:
                logger.log(f"{opcode_str} -> {hex(rsi)} = {try_str(al)}")

            #case 12|13:
            #    logger.log(f"{opcode_str} -> POS={rbp}")

            case 14:
                logger.log("DEAD_OPCODE")

            case 15:
                logger.log("EXIT")
            case _:
                return False

        ####STOP
        return False
    
#bp = VMDecoder("*0x401301", 0)
#bp = VMDecoder("*0x401266", 1)
#bp = VMDecoder("*0x401252", 2)
bp = VMDecoder("*0x401222", 3)
#bp = VMDecoder("*0x4012C2", 4)
#bp = VMDecoder("*0x401292", 5)
#bp = VMDecoder("*0x4012F2", 6)
#bp = VMDecoder("*0x4011F6", 7)
#bp = VMDecoder("*0x4011A9", 9)
#bp = VMDecoder("*0x4011C5", 81)
#bp = VMDecoder("*0x401316", 82)
#bp = VMDecoder("*0x40118F", 10)
#bp = VMDecoder("*0x401167", 11)
#bp = VMDecoder("*0x401140", 12)
#bp = VMDecoder("*0x401137", 13)
#bp = VMDecoder("*0x401108", 14)
#bp = VMDecoder("*0x4010F0", 15)


blacklist = ["'", '"']


#for i in range(7, 47):
flag = ['A']*48
for i in range(7, 47):
    toFind = i
    for char in string.printable[0:len(string.printable)]:
        if char in blacklist:
            continue

        arrayT = []
        flag[toFind] = char

        flag[0] = 'k'
        flag[1] = 'a'
        flag[2] = 'l'
        flag[3] = 'm'
        flag[4] = 'a'
        flag[5] = 'r'
        flag[6] = '{'
        flag[-1] = '}'


        flag_ = ''.join(flag)
        fd2 = open("sub.log","a")

        fd2.write(flag_+ "\n")
        print(flag_)

        gdb.execute(f"run <<< '{flag_}'")

        if arrayT[toFind][0] == arrayT[toFind][1]:
            print("FOUND char : ", char)
            flag[toFind] = char
            break

    

le flag final est : kalmar{vm_in_3d_space!_cb3992b605aafe137} Challenge très sympathique, un cas d’école pour l’étude de VM de CTF.