Deutschlands Bester Hacker - PACeMaker CR-7 (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 494
Binary ELF 64-bit, ARM aarch64, static-pie, stripped, ARMv8.3 PACPointer AuthenticationARM-Hardwarefunktion, die Zeiger kryptografisch signiert, um manipulierte Sprungziele zu erkennen.
Angriffsklasse Signier-Orakel + TOCTOURace ConditionFehler, bei dem das Ergebnis von der zeitlichen Reihenfolge paralleler Vorgänge abhängt.-Race auf einen PAC-geschützten Zeiger
Flag DBH{pacem_XXX}

Die Challenge

Es gibt eine Sorte Challenge, die einen mit einer Behauptung empfängt statt mit einer Aufgabe. Diese hier kam als Produktprospekt:

SecureTherapy™ — signed execution, end to end. Every stimulation routine on the CR-7 carries a hardware Pointer Authentication Code. Unsigned or tampered routines fault and are rejected — they can never execute. Keys are provisioned per-device and are not extractable.

Dazu eine E-Mail von einem Medizintechniker: ein Implantat habe einen Schock abgegeben, den es nicht abgeben durfte. Die Routine, die gefeuert hat, stand nicht im Herstellerkatalog. Entweder ist die Signatur kaputt — oder jemand hat das Gerät dazu gebracht, für ihn zu signieren.

Spoiler: es war Letzteres. Der Weg dahin hatte aber mehr Sackgassen als Geradeaus, und die interessanteren Lehren stecken in den Sackgassen. Deshalb erzähle ich sie mit.

Aufklärung

pacemaker: ELF 64-bit LSB pie executable, ARM aarch64, static-pie linked, stripped

Ein statisch gelinktes aarch64-Binary, gestrippt, mit echtem ARMv8.3 Pointer AuthenticationAuthenticationÜberprüfung der behaupteten Identität eines Benutzers oder Systems.. Dazu ein TLSTransport Layer SecuritySchützt Netzwerkverbindungen durch Verschlüsselung, Authentifizierung und Integritätsprüfung.-Endpunkt und ein zeilenbasiertes Protokoll:

Kommando Zweck
PROGRAM <len> Therapieprogramm anlegen (mit Parameterpuffer)
PARAM <id> <hex> Stimulationsparameter schreiben
READBACK <id> Programm auslesen
RETIRE <id> Programm deaktivieren
SIGN <ptr> Programmer-Wand: Routine autorisieren (signieren)
SHOCK <id> Therapie abgeben
DISCONNECT Link trennen

SIGN <ptr> sticht sofort ins Auge. Ein Kommando, das „signiert“, und ein Argument, das aussieht wie ein Zeiger — das ist entweder eine Falle oder die halbe Lösung.

strings liefert außerdem zwei Zeilen zum Unterstreichen:

[dbg] shock id=%d captured tp=%p (window open)
[dbg] shock id=%d window closed, routine=%#lx

Ein Fenster, das sich öffnet und schließt. In einer Challenge um signierte Zeiger ist das keine Metapher.

Das Prozessmodell — die eigentliche Überraschung

Ich habe den Fehler gemacht, zuerst nach dem Bug zu suchen statt nach der Architektur. Die Architektur ist hier der Bug. handle_client macht drei Dinge, die den Rest erklären:

dup2(fd, 0); dup2(fd, 1); dup2(fd, 2);   // stdin/stdout/stderr == Socket
in  = fdopen(fd, "r");
out = fdopen(dup(fd), "w");
pthread_create(&t0, NULL, handle_cmd, &chan0);
pthread_create(&t1, NULL, handle_cmd, &chan1);

Zwei Worker-Threads, zwei Kanäle. Und die Zuteilung:

is_shock = (strncmp(line, "SHOCK", 5) == 0);
chan = &channels[is_shock];       // SHOCK -> Kanal 1, alles andere -> Kanal 0
lock(chan);
while (chan->ready) cond_wait(...);
memcpy(chan->cmdbuf, line, len+1);
chan->ready = 1;
cond_signal(...);
unlock(chan);
// zurück zum fgets — OHNE auf das Ergebnis zu warten

Der Verbindungs-Thread wartet nicht. Er schiebt das Kommando in einen Kanal und holt die nächste Zeile. Das heißt: SHOCK läuft in einem anderen Thread als jedes andere Kommando — und zwar aus einer einzigen Verbindung heraus, echt parallel. Man braucht keine zweite Session, kein Timing-Kunststück über das Netz. Man schreibt vier Zeilen in einen write() und hat zwei Threads, die gleichzeitig auf derselben Datenstruktur arbeiten.

Die Datenstruktur ist schlicht:

struct program {          // 0x18 Bytes, malloc'd
    void *routine;        // +0x00  PAC-signiert, Modifier 0
    char *buf;            // +0x08  Parameterpuffer
    uint32_t len;         // +0x10
    uint32_t active;      // +0x14
};

Die Schwachstelle

Bug 1: Das Signier-Orakel

SIGN in Pseudocode, direkt aus dem Disassembly:

uint64_t v = strtoul(arg, NULL, 0);   // beliebiger Wert vom Nutzer
void *s = malloc(0x50);
s[0x20] = pacia(v, 0);                // signieren, Modifier 0
if (debug) dprintf(dbg_fd, "[dbg] sign scratch=%p signed=%#lx", s, s[0x20]);
free(s);
puts("OK signed");

Drei Dinge stimmen hier gleichzeitig nicht.

Erstens signiert es einen beliebigen Wert. Nicht einen aus einer Whitelist, nicht einen validierten — strtoul und ab in die Signiereinheit.

Zweitens ist der Modifier konstant 0. PAC ist dafür gebaut, den Kontext mitzusignieren (Stackadresse, Typ, irgendwas Ortsgebundenes). Ein konstanter Modifier bedeutet: eine Signatur, die irgendwo gilt, gilt überall.

Drittens landet das Ergebnis in einem Heap-Chunk, der sofort wieder freigegeben wird. Ein malloc(0x50) ergibt einen 0x60-Chunk; der geht in den tcache. Ein anschließendes PROGRAM 80 holt exakt diesen Chunk als Parameterpuffer zurück, und READBACK liest ihn aus — die Signatur steht bei Offset 0x20:

link.sign(target)                      # Gerät signiert für uns
holder = link.program(0x50)            # Chunk kommt aus dem tcache zurück
signed = u64(link.readback(holder)[0x20:0x28])

Die Marketing-Aussage „Keys are not extractable“ stimmt übrigens wörtlich. Die Schlüssel verlassen den Chip nie. Sie müssen auch gar nicht — das Gerät signiert bereitwillig alles, was man ihm hinhält. Das ist der Unterschied zwischen „der Schlüssel ist sicher“ und „nur autorisierte Dinge werden signiert“, und genau diesen Unterschied verwechselt das Datenblatt.

Bug 2: Die Lücke zwischen Prüfung und Sprung

SHOCK, ebenfalls direkt aus dem Disassembly:

lock(&programs_mutex);
tp = programs[id];
if (!tp || tp->active != 1) { unlock(); return ERR; }
unlock(&programs_mutex);              // <-- Mutex fällt VOR der Nutzung

if (window_us) usleep(window_us);     // <-- und dann wird auch noch geschlafen

x1 = tp->routine;                     // <-- neu aus dem Speicher geladen
autia x1, 0;                          // authentifizieren
x0 = tp->buf;
blr x1;                               // springen

Ein Lehrbuch-TOCTOU. Der Zeiger wird geprüft, dann losgelassen, dann erneut geladen und benutzt. Wer zwischen unlock und ldr etwas an tp->routine ändert, bestimmt das Sprungziel — die Authentifizierung passiert danach, auf dem manipulierten Wert. Und PAC schützt nicht, weil wir mit Bug 1 eine gültige Signatur für ein beliebiges Ziel bekommen.

Wie groß ist das Fenster? window_us kommt aus PACE_WINDOW_US. Ist die Variable nicht gesetzt, greift der Wert im .data-Segment — einfach nachlesen:

>>> struct.unpack_from('<i', open('pacemaker','rb').read(), 0xb0014)[0]
2000

2000 Mikrosekunden. Zwei Millisekunden sind eine Ewigkeit, wenn man in dieser Zeit nur drei Kommandos durch einen bereits wartenden Worker-Thread schieben muss.

Sackgassen

Bis hierhin liest sich das geradlinig. War es nicht. Vier Irrwege, von denen drei auf denselben Denkfehler zurückgehen: ich habe geschlossen, statt zu messen.

1. Blindes Timing-Roulette

Der erste Ansatz probierte rund dreißig Timing-Varianten durch — Verzögerungen, Reihenfolgen, wiederholte PARAMs, alles auf einer Verbindung sequenziell. Er konnte nicht funktionieren, weil er das Zwei-Worker-Modell nicht kannte und deshalb versuchte, ein Race gegen einen Thread zu fahren, der die Kommandos ohnehin der Reihe nach abarbeitet.

Lehre: Ein Race-ExploitExploitCode oder Technik, die eine Schwachstelle gezielt ausnutzt., der nicht sagen kann, welche zwei Codepfade gegeneinander laufen, ist ein Zufallsgenerator.

2. Ich habe mir das Fenster selbst zerschossen

Nachdem die Kette stand, baute ich eine Option für eine Verzögerung zwischen SHOCK und RETIRE ein — und zerlegte dabei den einen write() in zwei:

link.send_raw(b"SHOCK %d\n" % victim)
link.send_raw(b"RETIRE %d\n...")        # separater TLS-Record

Trefferquote danach: 0 von 40. Bei 39 ms RTT driften zwei TLS-Records problemlos um mehr als zwei Millisekunden auseinander. Das Fenster war offen, wir kamen nur nicht rechtzeitig hin.

Gefunden habe ich das erst, nachdem ich aufgehört hatte zu raten und einen Diagnosemodus gebaut habe, der ohne jede Kenntnis der Ladeadresse funktioniert: Ich lasse das Gerät 0x1000 signieren — garantiert kein gültiges Sprungziel. Damit trennen sich die Fälle sauber:

Ergebnis Bedeutung
crash (Verbindung stirbt) Race gewonnen, gefälschter Zeiger wurde aufgerufen
lost ([pulse] delivered, params="UNTOUCHED_THERAPY") zu langsam
early (ERR no_program) RETIRE war vor SHOCK dran

Nach der Rückkehr zu einem write(): crash=26, lost=1, early=9 von 40, also rund 70 % Trefferquote.

Lehre: Baue dir für ein Race früh ein Messinstrument, das die drei Ausgänge unterscheidet. Ein Exploit, der nur „geklappt / nicht geklappt“ sagt, lässt dich am falschen Ende schrauben.

3. Zwei Base-Scans über 47 MB

Der teure Fehler. system() braucht die Ladeadresse, und die ist randomisiert.

Ich hatte den main-Pfad disassembliert und dort einen klassischen accept()/fork()-Server gesehen. Daraus folgte für mich: alle Kinder erben das Layout des Elternprozesses, ASLRAddress Space Layout RandomizationSchutzmechanismus, der Speicheradressen zufällig anordnet und Exploits dadurch erschwert. ist über alle Verbindungen hinweg identisch. Bestätigt schien das durch eine Beobachtung, die ich für einen Beweis hielt — die Heap-Adresse war über alle Verbindungen konstant.

Also habe ich gesucht: erst 64K-Raster über 32 MB, dann 4K-Raster, insgesamt rund 25.000 Verbindungen und gut eine Stunde. Jeder falsche Kandidat lässt das Kind abstürzen, was folgenlos ist — nur eben auch ergebnislos.

Der Denkfehler: das Deployment benutzt den fork()-Pfad gar nicht. Das Binary hat einen zweiten Modus, --inetd, und läuft unter socat, das pro Verbindung einen frischen Prozess startet. Verraten hätte es mir die Umgebung die ganze Zeit:

SOCAT_PID=57593
SOCAT_PEERADDR=[...]
SOCAT_VERSION=1.8.0.0

Jede Verbindung ist ein eigener exec mit eigenem ASLR. Ich habe die Base auf Verbindung A gesucht und auf Verbindung B benutzt. Kein Raster der Welt hätte da getroffen.

Und die konstante Heap-Adresse? Die ist echt — aber sie beweist das Gegenteil von dem, was ich hineingelesen habe. Das Ganze läuft unter qemu-user (anders bekommt man ARMv8.3-PAC auf x86-Hardware nicht emuliert), und qemu vergibt die mmap-Bereiche des Gastes deterministisch ab 0x400000000000. Randomisiert wird nur das Image, das über einen Host-mmap in den typischen 0x7f…-Bereich fällt. Zwei Adressräume, zwei völlig verschiedene Randomisierungen — und ich habe aus der Konstanz des einen auf die Konstanz des anderen geschlossen.

Lehre: Ein Deployment-Detail schlägt jede noch so saubere Analyse des Binaries. Und env ist billig.

4. Der Beweis, dass es kein Leck gibt

Parallel dazu hatte ich mir selbst bewiesen, dass ein Leak der Ladeadresse unmöglich sei:

Die einzigen Binary-Zeiger im lesbaren Speicher sind die signierten routine-Felder bei Offset 0 der Programm-Structs. Ein lebendes Struct kann nie gleichzeitig Parameterpuffer sein — ein Chunk, den wir lesen können, war also mindestens einmal freigegeben. Und free() schreibt in jedem Pfad an Offset 0: tcache schreibt next, fastbin schreibt fd, unsorted bin schreibt fd. Die Signatur ist also immer zerstört.

Jeder einzelne Satz stimmt. Die Schlussfolgerung ist trotzdem falsch, und der Fehler steckt im letzten Halbsatz: der überschriebene Wert ist das Leck.

Im tcache und im fastbin ist der eingetragene Zeiger ein Heap-Zeiger — dort lernt man nichts Neues. Im unsorted bin aber zeigen fd und bk auf den Listenkopf, und der liegt in main_arena. Bei einem statisch gelinkten Binary ist main_arena keine libc-Adresse, sondern liegt im .data des Images selbst.

Der Weg dorthin steht die ganze Zeit im Protokoll: PROGRAM erlaubt bis zu 4096 Bytes. Der tcache deckt nur Chunks bis 0x410 ab. Ein PROGRAM 4096 ergibt einen 0x1010-Chunk — zu groß für tcache, zu groß für fastbin. Beim Freigeben: unsorted bin. Dass die Obergrenze so weit über der tcache-Grenze liegt, war die ganze Zeit der Hinweis. Ich habe ihn als Bequemlichkeit gelesen.

Der Angriff

Der Leak

def leak_base(link):
    victim = link.program(0x1000)     # 0x1010-Chunk
    link.program(0x1000)              # Wächter: verhindert Konsolidierung in top
    link.retire(victim)               # -> unsorted bin, fd/bk zeigen auf main_arena
    again = link.program(0x1000)      # exakte Größe -> direkt zurück aus dem bin
    data = link.readback(again)       # fd/bk stehen noch im Nutzdatenbereich
    fd, bk = u64(data[:8]), u64(data[8:16])
    return fd - (MAIN_ARENA + 0x60)

Der Wächter-Chunk ist wichtig: grenzt der freigegebene Chunk an den top-Chunk, wird er dort hinein konsolidiert und es gibt gar keinen Listeneintrag.

Bleibt der Offset von main_arena im Image. Man könnte malloc disassemblieren und die adrp/add-Paare verfolgen. Es geht eleganter — main_arena ist beim Start selbstreferenziell: main_arena.next zeigt auf main_arena, und next liegt bei Offset 0x870. In einem PIE steht dafür eine Relokation, deren Addend genau r_offset - 0x870 ist:

for r in rela.iter_relocations():
    if r['r_addend'] and r['r_offset'] - r['r_addend'] == 0x870:
        print(hex(r['r_addend']))     # -> 0xb0650

Ein einziger Treffer über 1425 Relokationen. Und bin_at(av, 1) ist &av->bins[0] - 0x10, also av + 0x60. Die Probe aufs Exempel:

fd   = 0x7f50ad1706b0
base = 0x7f50ad0c0000
fd - base = 0xb06b0 = 0xb0650 + 0x60

Ein sauberer Leak, ohne Race, ohne Raten, in vier Kommandos.

Heap-Adressen über safe-linking

Moderne glibc verschleiert tcache-Zeiger: gespeichert wird nicht next, sondern (chunk >> 12) ^ next. Das ist als Schutz gedacht, verrät aber den letzten Listeneintrag geschenkt — dessen next ist NULL, gespeichert wird also chunk >> 12:

tcache[0x100]:  C2 -> C1 -> NULL
C2 enthält (C2>>12) ^ C1
C1 enthält C1>>12

Liegen beide auf derselben 4K-Seite, ist C1 = leak_C2 ^ leak_C1. Zwei PROGRAM, zwei RETIRE, zwei PROGRAM, zwei READBACK — fertig. Der zweite recycelte Chunk ist gleichzeitig unser Kommandopuffer, dessen Adresse wir damit kennen, ohne irgendetwas vorhersagen zu müssen.

Der Trick mit dem Chunk

Was macht man mit dem 2-ms-Fenster? Man sorgt dafür, dass der Speicher, auf den tp zeigt, in fremde Hände wechselt:

SHOCK 4        -> Worker 1: fängt tp = struct_v ein, schläft 2 ms
RETIRE 4       -> Worker 0: free(buf_v), free(struct_v)
PROGRAM 24     -> Worker 0: malloc(0x18) liefert struct_v als *Parameterpuffer*
PARAM 4 <hex>  -> Worker 0: schreibt routine + buf in genau diesen Speicher

Der Kern ist die dritte Zeile. RETIRE gibt erst buf, dann struct frei — der Struct-Chunk liegt danach ganz oben im tcache für 0x20. Das nächste malloc(0x18) bekommt ihn zurück, und PROGRAM reicht ihn als Puffer des neuen Programms weiter. Ab da schreibt PARAM direkt in die Struktur, die Worker 1 gleich auslesen wird. 16 Bytes reichen: signierter Zeiger auf system(), dahinter ein Zeiger auf unser Kommando.

fake = p64(signed_system) + p64(cmd_ptr)
link.send_raw(b"SHOCK 4\nRETIRE 4\nPROGRAM 24\nPARAM 4 %s\n" % fake.hex().encode())

Dass system() überhaupt im Binary steckt, ist bei statischem glibc geschenkt — gefunden über die /bin/sh-Referenz bei 0x719b0, die die Funktion bei 0xbc80 lädt. Und weil handle_client fd 0/1/2 per dup2 auf den Socket legt, läuft die Ausgabe von system() direkt zu uns zurück.

Alles zusammen

Der fertige Exploit macht alles auf einer Verbindung — das ist nach Sackgasse 3 keine Eleganz, sondern Pflicht:

Schritt Mechanismus
Ladeadresse PROGRAM 4096 → unsorted bin → fd → main_arena → Base
Heap-Adresse safe-linking über zwei recycelte tcache-Chunks
Signatur SIGN base+0xbc80, Rückleak aus dem freigegebenen 0x50-Chunk
Ausführung TOCTOU in SHOCK, 2 ms Fenster, zweiter Worker schiebt RETIRE+PROGRAM+PARAM dazwischen
Ausgabe system(), fd 0/1/2 sind per dup2 der Socket

Die Flag

[*] base=0x7f83332b0000  system=0x7f83332bbc80 -> signed=0x527f83332bbc80
[*] x0=0x4000008172b0  victim=4
OK retired
OK program=4
OK params=16
CR7_UNSIGNED_ROUTINE_RAN
DBH{pacem_XXX}

Rund 70 % Trefferquote pro Versuch, also praktisch beim ersten Anlauf.

Nachspiel: die Shell, die nur jede zweite Zeile hört

Ein exec sh am Ende des Kommandos gibt eine Shell — aber eine, die sich merkwürdig verhält:

$ ls
flag.txt  pacemaker  start.sh
$ cat flag.txt
ERR unknown_command
$ id
ERR unknown_command

Das sieht nach einer restricted shell aus, ist aber etwas anderes: system() forkt ein sh, das fd 0 mit dem Pacemaker teilt. Dessen Verbindungs-Thread steht weiter in seinem fgets und schnappt sich jede zweite Zeile. Wer gewinnt, entscheidet sich pro Eingabe neu.

Reparieren lässt sich das mit derselben Architektur, die uns das Race geschenkt hat. Der Verbindungs-Thread blockiert, sobald der Zielkanal belegt ist — und Worker 1 hängt nach dem geglückten Race in system() fest und leert seinen Kanal nicht mehr. Also hängt man an den Race-Write drei weitere SHOCK-Zeilen: die erste wird noch zugestellt, die zweite belegt den Kanal, bei der dritten schläft der Leser in pthread_cond_wait ein. Danach liest nur noch sh.

Man legt den Leser also mit genau dem Mechanismus schlafen, der einen vorher gewinnen ließ. Das ist die Art Symmetrie, für die man diese Challenges macht.

Was ich mitnehme

Zu PAC. Pointer Authentication scheitert hier an zwei Dingen, die beide nichts mit KryptografieCryptographyMethoden zum Schutz von Informationen durch Verschlüsselung, Signaturen und Hashfunktionen. zu tun haben. Ein konstanter Modifier macht jede Signatur überall gültig — der Kontext ist der halbe Sinn der Sache. Und ein Signier-Orakel, das ungeprüfte Eingaben signiert, hebelt das Ganze aus, ohne dass je ein Schlüssel den Chip verlässt. Dazu kommt, dass autia nichts nützt, wenn der Wert nach der Prüfung erneut aus dem Speicher geladen wird. PAC schützt Zeiger in Registern, nicht Datenstrukturen im Heap.

Zum Vorgehen. Meine drei Fehler waren derselbe Fehler in drei Kostümen: ich habe aus korrekten Einzelbeobachtungen weitergeschlossen, statt die Schlussfolgerung zu prüfen. Der fork()-Pfad war im Binary. Der freigegebene Chunk hatte an Offset 0 einen überschriebenen Wert. Die Heap-Adresse war konstant. Alles richtig — und jedes Mal die falsche Konsequenz.

Was am Ende geholfen hat, war nie das schärfere Nachdenken, sondern das billigere Experiment: einen Diagnosemodus bauen, der die drei Race-Ausgänge unterscheidet. Den Default-Wert aus dem .data lesen statt ihn durch 39 ms Netzlatenz messen zu wollen. Einmal env laufen lassen. Und, mit Abstand die günstigste Maßnahme: sich die Rohausgabe des Servers ansehen, bevor man eine Stunde lang Adressräume abrastert.

Randnotiz: unter /flag.txt lag zusätzlich ein im Image gebackener Platzhalter — CTF{p4c_1s_just_4_s1gn4tur3_r4c3}. Der beschreibt die Lösung besser als die echte Flag, verrät sie aber erst, wenn man sie schon hat.