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. Undfree()schreibt in jedem Pfad an Offset 0: tcache schreibtnext, fastbin schreibtfd, unsorted bin schreibtfd. 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.