8.7 KiB
Slot Machine
Hallo! Bereit für ein wenig Heap-Exploitation? Heute schauen wir uns Slot Machine an, eine Pwn-Challenge, die auf den ersten Blick einfach erscheint, aber eine raffinierte Schwachstelle verbirgt.
Beim ersten Ausführen des Programms wirst du vom "SecureSlot Storage Manager" begrüßt. Es ist ein einfaches Tool, mit dem man "Slots" zum Speichern von Daten initialisieren (init), lesen (read), beschreiben (write) und zerstören (destroy) kann.
Die Herausforderung: Wir haben nur ein gestripptes Binary. Kein Quellcode, keine Symbole. Das bedeutet, unser erster Schritt ist der Griff zum treuen Dekompiler – Ghidra –, um die Erkundung zu starten.
Schritt 1: Erkundung (Die "Black Box")
Da wir mit einem gestrippten Binary arbeiten, müssen wir die Programmlogik selbst kartografieren. Ein guter erster Schritt bei jeder Pwn-Challenge ist die Suche nach interessanten Zeichenketten (Strings).
Suchen wir in Ghidra nach "flag", finden wir sofort einen Treffer:
[*] Congratulations! Here is your flag: %s
Dieser String wird in einer Funktion an der Adresse 0x109d74 (FUN_00109d74) referenziert:
void FUN_00109d74(void)
{
undefined8 uVar1;
FUN_00114310("[*] Congratulations! Here is your flag: %s
",&DAT_001f41e0);
uVar1 = 0;
FUN_001137e0();
FUN_00130be0(uVar1);
return;
}
Dies ist unsere "Win"-Bedingung – wenn wir den Programmfluss auf diese Adresse umleiten können, erhalten wir das Flag!
Schauen wir uns nun an, wie der Rest des Programms funktioniert. Wenn wir der Logik vom Einstiegspunkt bis zur main-Funktion (bei 0x10a393) folgen, finden wir das Herzstück des "SecureSlot Manager": eine menügesteuerte Schleife.
// Dekompilierte Main-Schleife (FUN_0010a393)
undefined8 FUN_0010a393(void)
{
// ... Setup-Code ...
FUN_00123090("--- SecureSlot Storage Manager ---");
do {
while( true ) {
while( true ) {
while( true ) {
while( true ) {
FUN_00114310("
COMMANDS: init, read, write, destroy, quit
> ");
FUN_001144a0(&DAT_001c01c7,local_1a);
if (local_1a[0] != 'i') break;
FUN_00109dc3(); // Dies ist cmd_init
}
if (local_1a[0] != 'r') break;
FUN_00109f95(); // Dies ist cmd_read
}
if (local_1a[0] != 'w') break;
FUN_0010a0d1(); // Dies ist cmd_write
}
if (local_1a[0] != 'd') break;
FUN_0010a238(); // Dies ist cmd_destroy
}
} while (local_1a[0] != 'q');
// ... Cleanup-Code ...
}
Schritt 2: Reverse Engineering des "Slots"
Um dies auszunutzen, müssen wir verstehen, wie die "Slots" verwaltet werden. Schauen wir uns die init-Funktion an (FUN_00109dc3):
void FUN_00109dc3(void)
{
int iVar1;
undefined8 uVar2;
long in_FS_OFFSET;
int local_34;
long local_30;
long local_28;
long local_20;
local_20 = *(long *)(in_FS_OFFSET + 0x28);
FUN_00114310("Slot Index: ");
FUN_001144a0(&DAT_001c00b1,&local_34);
FUN_00114310("Element Count: ");
FUN_001144a0(&DAT_001c00c4,&local_30);
iVar1 = local_34;
if ((local_34 < 0) || (0xb < local_34)) {
FUN_00123090("Invalid index");
}
else if (*(long *)(&DAT_001f4220 + (long)local_34 * 8) == 0) {
local_28 = (local_30 + 2) * 8;
uVar2 = FUN_001308a0(local_28);
*(undefined8 *)(&DAT_001f4220 + (long)iVar1 * 8) = uVar2;
thunk_FUN_00133b80(*(undefined8 *)(&DAT_001f4220 + (long)local_34 * 8),0,local_28);
if (*(long *)(&DAT_001f4220 + (long)local_34 * 8) == 0) {
FUN_00123090("Allocation failed");
}
else {
**(long **)(&DAT_001f4220 + (long)local_34 * 8) = local_30;
*(code **)(*(long *)(&DAT_001f4220 + (long)local_34 * 8) + 8) = FUN_00109da4;
FUN_00114310("Allocated slot %d
",local_34);
}
}
else {
FUN_00123090("Slot already in use");
}
if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
FUN_001641a0();
}
return;
}
Woher kennen wir das Struct-Layout?
Indem wir beobachten, wie das Programm mit dem Speicher interagiert, den FUN_001308a0 (wir können annehmen, dass dies malloc ist) zurückgibt:
**(long **)(&DAT_001f4220 + (long)local_34 * 8) = local_30;-> Die ersten 8 Bytes des reservierten Puffers speichernlocal_30(unseren "Element Count").*(code **)(*(long *)(&DAT_001f4220 + (long)local_34 * 8) + 8) = FUN_00109da4;-> Die nächsten 8 Bytes (Offset 8) speichern einen Funktionszeiger (FUN_00109da4).- Wenn man die
read- undwrite-Funktionen analysiert, sieht man, dass sie auf Daten zugreifen, die bei einem Offset von 16 Bytes beginnen.
Dies ermöglicht es uns, die slot_t-Struktur zu rekonstruieren:
typedef struct {
uint64_t count; // Offset 0 (local_30)
void (*cleanup)(void*); // Offset 8 (FUN_00109da4)
int64_t data[]; // Offset 16 (wo Benutzer lesen/schreiben)
} slot_t;
Wenn du den "destroy"-Befehl aufrufst, ruft das Programm diesen Funktionszeiger bei Offset 8 auf. Wenn wir diesen überschreiben können, kontrollieren wir die Ausführung!
Schritt 3: Den "Jackpot" finden (Die Schwachstelle)
In FUN_00109dc3 beachte, wie die Allokationsgröße local_28 berechnet wird: local_28 = (local_30 + 2) * 8;. Wenn wir eine massiv große Zahl für local_30 angeben, kommt es zu einem Integer Overflow.
Geben wir 2305843009213693952 ein (was 2^{61} entspricht), sieht die Rechnung so aus:
(2^{61} + 2)\times8 = 2^{64} + 16.
In einem 64-Bit-System läuft 2^{64} auf 0 zurück. Das Programm ruft also tatsächlich malloc(16) auf. Der bei Offset 0 gespeicherte count ist jedoch immer noch diese riesige Zahl! Das gibt uns einen "Slot", der glaubt, Milliarden von Einträgen zu haben, aber tatsächlich nur über 16 Bytes physischen Heap-Speicher verfügt.
Schritt 4: Out-of-Bounds & ASLR-Bypass
Da count riesig ist, werden uns die Befehle read und write nicht daran hindern, auf Speicherbereiche zuzugreifen, die weit über die allokierten 16 Bytes hinausgehen. Wir haben einen Out-of-Bounds (OOB) Zugriff.
Aber es gibt einen Haken: ASLR ist aktiviert. Wir kennen die absolute Adresse von print_flag nicht. Wir benötigen ein Datenleck (Leak).
- Initialisiere Slot 0 mit unserer Overflow-Größe (
2305843009213693952). Er erhält einen winzigen 16-Byte-Block. - Initialisiere Slot 1 mit einer normalen Größe (z. B.
1). Der Heap-Allokator wird den Block für Slot 1 unmittelbar nach Slot 0 platzieren.
Da Slot 0 nur 16 Bytes reserviert bekommen hat, aber glaubt, riesig zu sein, überschneidet sich sein "data"-Bereich (beginnend bei Offset 16) nun mit dem nächsten Block im Speicher (Slot 1).
| Index (Slot 0) | Offset | Inhalt | Beschreibung |
|---|---|---|---|
| - | 0x00 | count |
Slot 0 massive Größe |
| - | 0x08 | cleanup |
Slot 0 Cleanup-Zeiger |
| Eintrag 0 | 0x10 | prev_size |
Slot 1 Heap-Metadaten |
| Eintrag 1 | 0x18 | size |
Slot 1 Heap-Metadaten |
| Eintrag 2 | 0x20 | count |
Slot 1 count-Feld |
| Eintrag 3 | 0x28 | cleanup |
Slot 1 Cleanup-Zeiger (Ziel!) |
- Die Adresse leaken: Lies
Slot 0beiEintrag Index 3.- Wie du aus der Tabelle ersehen kannst, zeigt
Eintrag 3von Slot 0 genau auf dencleanup-Funktionszeiger von Slot 1. - Durch das Lesen dieses Werts erhalten wir die absolute Adresse von
default_destroy(oderFUN_00109da4) und umgehen so ASLR!
- Wie du aus der Tabelle ersehen kannst, zeigt
Durch das Auslesen dieses Werts wissen wir nun, wo das Binary im Speicher geladen wurde.
Schritt 5: Der finale Schlag
Da wir nun die geleakte Adresse der Standard-Cleanup-Funktion (0x109da4) haben, müssen wir nur noch den Offset zu print_flag (0x109d74) berechnen.
Die Differenz beträgt 0x30. Wir nehmen also unsere geleakte Adresse, subtrahieren 0x30 und haben die exakte Adresse der Flag-Funktion.
- Die Adresse schreiben: Nutze den OOB-Schreibzugriff auf
Slot 0(Eintrag 3), um den Cleanup-Zeiger vonSlot 1mit unserer berechnetenprint_flag-Adresse zu überschreiben. - Den Sieg auslösen: Rufe den
destroy-Befehl für Slot 1 auf.
Anstatt den Speicher freizugeben, wird das Programm bereitwillig zu print_flag springen und dir den Preis überreichen!
Zusammenfassende Checkliste
- Initialisiere Slot 0: Count
2305843009213693952(setzt Größe auf 16 zurück). - Initialisiere Slot 1: Count
1. - Lies Slot 0, Eintrag 3: Dies leakt die
default_destroy-Adresse. - Berechne:
Ziel = geleakte_Adresse - 0x30. - Schreibe Slot 0, Eintrag 3: Schreibe die
Ziel-Adresse. - Zerstöre Slot 1: Erfolg!
Viel Spaß beim Hacken! Denk daran: Manchmal entstehen die größten Strukturen aus den kleinsten Allokationen.