Så här programmerade jag en egen virtuell maskin i Python

Thursday, January 7, 2021

ProgrammeringPythonCTF

Christoffer Strömblad

Entusiastisk Jedi inom Cybersäkerhet

Som en del av arbetet med att tillföra flaggor till CORS CTF ville jag kunna erbjuda flaggor inom området Exploitation. Men samtidigt ville jag inte, likt många andra övningar, använda mig utav en dåligt skriven C-applikation.

Så naturligtvis bestämde jag mig för att istället bygga en egen virtuell maskin med ett påhittat programspråk. Inget mästerligt avancerat men ändå något som kan göra lite grundläggande saker som att skriva ut till skärmen, skriva och läsa från minne och en stack. Och självklart exekvera instruktioner enligt det påhittade program-/hårdvaruspråket.

Det ska tilläggas att jag aldrig gjort något liknande förut och jag är tveklöst ute på ganska så djupt vatten. Även om jag tidigare studerat flera av dessa områden, saknade jag den praktiska erfarenheten att på det djupet utforska ämnet av virtuella maskiner och påhittade programspråk.

Innan du läser

Observera att det helt klart är fördelaktigt med grundläggande bekantskap med assembler-språk. I någon utsträckning härmar jag x86-assembler (det enda jag programmerat i tidigare). Du bör även vara något bekväm i minneshantering. Det blir en väldigt enkel sådan med en linjär (istället för en segmenterad) minnesmodell och endast 32 768 tillgängliga (15 bitar, 1 bit för sign).

Jag har också valt att implementera denna VM i Python3 av den enkla anledning att jag känner mig relativt bekväm med språket.

Hur började jag?

All over the place. Typ. Nja, jag hade en idé. Jag ville kunna simulera ett program som exekverar, och det skulle vara sårbart för en buffer overflow. Jag tänker inte göra någon djupgående förklaring om vad en buffer overflow är för något, det finns tillräckligt många förklaringar på nätet. Men låt mig säga så här. Någonstans finns det en instruktionspekare. Denna instruerar processorn om vilken instruktion som ska tolkas och exekveras härnäst.

Målet med i princip allt utnyttjande av sårbarheter är att exekvera godtycklig kod. I sammanahang av en buffer overflow uppstår detta när du skriver över instruktionspekaren. Att det överhuvudtaget är möjligt är ett samarbete mellan minnesstacken, och hur ett huvudprogram anropar funktioner och sedan återvänder till “nästa” instruktion efter funktionsanropet.

Hur som helst. Jag ville ge flaggletaren en möjlighet att lära sig om buffer overflows och hur dessa utnyttjas för kodexekvering. Jag började med att föreställa mig vilka delar som skulle vara nödvändiga att ha; en virtuell maskin som innehåller en CPU och lite RAM. Vad mer behöver jag?

Hitta på ett språk

Uppenbart kanske, men jag behöver hitta på ett språk som en simulerad processor kan exekvera. Vilka funktioner behöver man för ett sådant? Jag behöver kunna skriva till minne, jag behöver kunna anropa metoder, återvända från metodanrop, jag behöver kunna stoppa programmet.

Jag konstruerade följande:

self.opcodes = {
    0: {"name": "halt",
        "func": self.halt,
        "size": 0,
        "reversed": False},
    4: {"name": "m_word",
        "func": self.ram.write_word,
        "size": 4},
    7: {"name": "pop",
        "func": self.cpu.pop_reg,
        "size": 1},
    }

Nu blir det lite python-specifika grejer här. Men i korthet så ser varje instruktion ut enligt ovan, detta är mitt opcode-lookup table. Jag kommer återvända till dessa opcodes (operation code) när jag förklarar exekveringsmodellen.

Varje opcode är sedan kopplad till en specifik metod som finns definierad i antingen en RAM-instans eller en CPU-instans, vilka i sin tur är kopplade samman i en VirtualMachine (en annan python-klass). Mer om den senare också.

Konstruera en virtuell maskin

Den virtuella maskinen definieras enligt följande, och massor av andra attribut.

self.ram = RandomAccessMemory(memory_size)
self.cpu = CentralProcessingUnit(self.ram)

En CPU-klass beskrev jag enligt följande:


class CentralProcessingUnit:

    def __init__(self, ram):
        
        self.ram = ram

        self._reg01 = uint16_t(0)

        self._ip = uint16_t(0x0000)
        self._sp = uint16_t(0x7fff)
        self._bp = uint16_t(0x7fff)

        self.registers = {
            0: {"name": "Instruction Pointer",
                "value": self.ip},
            1: {"name": "Stack Pointer",
                "value": self.sp},
            2: {"name": "Base Pointer",
                "value": self.bp},
            3: {"name": "Register 01",
                "value": self.reg01},
            }

CPU:n får tillgång till RAM-instansen när jag skapar en CPU så att CPU kan skriva till RAM.

Och RAM-minnet definierade jag enligt följande:

class RandomAccessMemory:

    def __init__(self, size: int = 0x8000):

        self._memory_size = size
        self._memory: bytearray = bytearray(size)

I klassen för RAM har jag flertalet metoder som jag använder för att skriva och läsa till minnet, exempelvis en read_byte() eller write_word(). Och 0x8000 innebär en adresserbar minnesrymd på 32KB. Den skulle kunna vara större, men… varför?

Jag ansåg det också nödvändigt att skriva ihop ett antal stödklasser uint8_t och uint16_t. Det grundade sig i att jag ville på ett enkelt sätt kunna skriva ett word (16 bitar) till två bytes (8 bitar). Det var då det enklaste sättet jag kunde komma på.

Exempelvis om jag vill läsa ett word från minnet, då ser metoden ut så här:

    def read_word(self, addr: uint16_t):

        val = ((self.memory[addr.uint16]) << 8) + (self.memory[addr.uint16 + 1])
        return uint16_t(val)

Och nej, jag gör ingen overflow-control här. Här kan du teoretiskt läsa utanför den tillgängliga minnesrymden vilket skulle resultera i ett Exception. Meh. YOLO.

Exevkera instruktioner

När vi exekverar instruktioner finns det ett antal sätt vi kan sätta samman dessa opcodes samt deras tillhörande argument, RISC/CISC. Jag är inte superinsatt, men ungefär så här hänger det ihop. Antingen designar vi språket så att varje opcode är bestämd i längd, exempelvis 2 byte för opcode, 4 byte för src, och 4 byte för dst. Varje instruktion skulle då bli 10 bytes.

Vi skulle då vid varje exekvering av en instruktion hämta 10 bytes från minnet, och veta att oavsett instruktion är detta längden vi behöver hämta. Det kostar dock minne.

I CISC, om jag förstått allting rätt, så har vi t.ex. Intel x86. Där beror argumenten och således hur många bytes vi behöver hämta på vilken instruktion som exekveras. Variable argument size. Eller vad det nu kan heta.

Detta innebär att först behöver vi hämta en instruktion, säg 1 byte för att få reda på vilken opcode. (Så här gör jag!). Vi kollar vilken opcode det är och sedan vilken “size” det är, säg 5 bytes för instruktionen. Vi läser ut 5 bytes från minnet. Vid exekvering av instruktionen skickar vi med 5 bytes som sedan tolkas enligt instruktionens “design”.

elif isize == 4:
    # Two arguments (2 words)

    word_1 = self.ram.read_word(instruction_pointer)
    word_2 = self.ram.read_word(instruction_pointer + 2)

    return (word_1, word_2)

Så här ser det exempelvis ut när jag hämtar ut argumenten för en instruktion med två word argument (16 bitar vardera). Denna kod är en del av min decode_instruction()

Okej. Så här fungerar min “exekverings-loop” för en instruktion. Först läser jag ut från minnet var min CPU.IP pekar (instruktionspekaren). Jag valde att designa mina opcodes till max 1 byte vilket ger mig max 256 instruktioner, det känns tillräckligt för mina behov.

Efter att jag har hämtat vilken opcode som ska exekveras kontrollerar jag mot min lookup-table, vilket jag nämnde ovan, hur många bytes som ska läsas ut från minnet som argument till instruktionen. Dessa returneras sedan för att därefter gå till själva exekveringsmetoden. Den ser ut så här (delar av)

while not self.should_halt():
    opcode = self.fetch_instruction()
    args = self.decode_instruction()
    
    ...
    
    self.cpu.ip.uint16 += self.opcodes[opcode]['size'] + 1
    self.opcodes[opcode]['func'](args)
    
    ...

Det är själva kärnan i körningen. Först hämtar vi opcode, sedan argumenten till instruktionen. Därefter ökar vi instruktionspekaren med opcode-storleken (1) och argument-storleken (ex. 4 bytes).

Och så loopar vi igenom det där tills vi har kört programmet.

Och hur ser ett program ut?

Allting sammantaget gör att vi nu kan exekvera ett program i vårt påhittade språk. Det ser ut enligt följande:


b'\x08\x13\x37\x03\x09\x03\x00'

Detta program skulle avkodas enligt följande:

mov 0x1337, 0x3 (reg01)
call 0x3
halt

Detta förutsätter givetvis att det finns en metod att exekvera på minnesplats 0x1337 annars kommer programmet försöka tolka det som finns på den platsen som kod och det kommer troligen inte gå så bra.

Avslutning

I den här högst enkla virtuella maskinen saknas egentligen massor av säkerhet. Det finns ytterst få minneskontroller, glöm icke-exekverbara minnesutrymmen, behörigheter på vilket minne som tillhör vilken process. Det finns ingen dynamisk minnesallokering och du kan inte heller skriva en metod som allokerar minne på stacken.

Men, det kanske ändå ger en idé om hur det hänger ihop. Och jag har funderat på att köra en live-presentation om det skulle vara av intresse.

ProgrammeringPythonCTF

Christoffer Strömblad

Entusiastisk Jedi inom Cybersäkerhet

Hur väljer och prioriterar jag lämpliga säkerhetsåtgärder?

Intrångsvektorn: Supply Chain när underleverantörer angrips