Deutschlands Bester Hacker - Tresor (Reversing) - 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 ReversingReverse EngineeringAnalyse eines Systems oder Programms zur Rekonstruktion seiner Funktionsweise.
Punkte 86
Angriffsklasse VM-ObfuskationObfuscationErschwert die Analyse von Code, Daten oder Kommunikation durch absichtliche Verkomplizierung. — verschlüsselter Bytecode, invertierbare Prüfung
Flag DBH{V1RTU3LL3_M4SCH1N3_G3KN4CKT}

Die Challenge

SafeBox Tresor 2.1, die Kommandozeilenversion eines Passwort-Safes. Er fragt nach der Master-Passphrase und sagt danach genau eines von zwei Dingen: Tresor geoeffnet. oder Abgelehnt.

Der Hersteller war offenbar der Meinung, dass eine Prüfroutine in nativem Maschinencode zu leicht zu lesen ist. Also läuft sie nicht in nativem Maschinencode.

Der Disassembler zeigt dir, wie geprüft wird — aber nicht, was. Die gesuchte Passphrase ist die Flag. Das Programm gibt sie nicht aus: wer den Sprung patcht, bekommt einen geöffneten Tresor und sonst nichts.

Der Hinweis „läuft nicht in nativem Maschinencode“ ist der zentrale Tipp: die Prüfung ist als Bytecode für eine eingebettete virtuelle Maschine umgesetzt. Der native Disassembler zeigt nur den VM-Interpreter (das Wie), nicht das ausgeführte Programm (das Was) — denn dieser Bytecode liegt verschlüsseltEncryptionWandelt Klartext mithilfe eines Schlüssels in nicht lesbaren Geheimtext um. vor.

Aufklärung

$ file tresor
tresor: ELF 64-bit LSB executable, x86-64, ..., dynamically linked, stripped

Interessante Strings:

SafeBox Tresor 2.1
Master-Passphrase:
Abgelehnt.
Tresor geoeffnet.
DBH{           <-- taucht bereits als Datenfragment auf

objdump -d -M intel tresor liefert genau eine relevante Funktion bei 0x401080. Sie zerfällt in drei Teile: eine Entschlüsselungsschleife, das Einlesen und Normalisieren der Eingabe, und eine Interpreter-Schleife mit einer 16-Einträge-Sprungtabelle.

Die Schwachstelle

Teil 1 — Der Bytecode wird entschlüsselt

mov    ecx, 0x1
movabs rsi, 0xCCCCCCCCCCCCCCCD        ; Magic: Division durch 5
mov    byte [rsp+0x90], 0x1           ; prog[0] = 1
.loop:
  ...                                 ; rax = rcx mod 5   (via mul-Magic)
  movzx eax, byte [rax + 0x4020e0]    ; key[rcx % 5]
  xor   al,  byte [rcx + 0x402100]    ; ^ data[rcx]
  mov   byte [rsp+rcx+0x90], al       ; prog[rcx] = ...
  add   rcx, 1
  cmp   rcx, 0x98
  jne   .loop

Eine simple XOR-Entschlüsselung von 152 (0x98) Bytes: prog[i] = key[i % 5] ^ data[i], mit dem 5-Byte-Key 9e 3c 71 a5 4b bei 0x4020e0 und den verschlüsselten Daten bei 0x402100.

Teil 2 — Eingabeformat

Nativer Code liest die Passphrase mit fgets (max. 0x80 Bytes), entfernt \r/\n und prüft vor dem Start der VM ein festes Format:

cmp   rcx, 0x20                       ; Länge == 32?
cmp   dword [rsp+0x10], 0x7b484244    ; beginnt mit "DBH{"
cmp   byte  [rsp+0x2f], 0x7d          ; endet mit "}"

Also: 32 Zeichen, Form DBH{ + 27 Zeichen + }. Die 27 inneren Zeichen sind der eigentlich gesuchte Inhalt.

Teil 3 — Die virtuelle Maschine

Die Interpreter-Schleife dispatcht über eine Sprungtabelle bei 0x402060:

cmp   r8b, 0xf
ja    reject
jmp   qword [8*r8 + 0x402060]         ; Opcode-Dispatch (16 Handler)
Element Ort Beschreibung
Register [rsp+0x08] 8 × 8-Bit-Register r0..r7
Tape / mem [rsp+0x130] Byte-Speicher
Eingabe [rsp+0x10] die 32 Zeichen der Passphrase
Programm [rsp+0x90] 152 Bytes entschlüsselter Bytecode
PC esi Program Counter, Instruktionen sind 4 Bytes
Iterationslimit edi = 0x186a0 100 000 Schritte als Deadlock-Schutz

Jede Instruktion ist [op, a, b, c]; a und b werden mit &7 auf Register maskiert, c ist ein voller Byte-Wert (Immediate bzw. Sprungziel).

Op Name Semantik
0 NOP PC += 4
1 LDI r[a] = c
2 LOAD r[a] = mem[r[b]]
3 STORE mem[r[b]] = r[a]
4 MOV r[a] = r[b]
5 ADD r[a] = (r[a] + r[b]) & 0xff
6 SUB r[a] = (r[a] - r[b]) & 0xff
7 XOR r[a] ^= r[b]
8 AND r[a] &= r[b]
9 ROLc r[a] = rol(r[a], c)
10 ROL4 r[a] = rol(r[a], 4)
11 OR r[a] |= r[b]
12 JNZ if r[a] != 0: PC = 4*c
13 JMP PC = 4*c
14 INP r[a] = (r[b] <= 31) ? input[r[b]] : 0
15 HALT r[a]==0 → „Tresor geoeffnet.“, sonst „Abgelehnt.”

Die Tape-Initialisierung ist das leicht zu übersehende Detail: das mem-Tape wird nicht auf Null gesetzt, sondern aus den Bytes hinter dem eigentlichen Programm befüllt. Es kopiert prog[0x70..0x97] (40 Bytes) an den Tape-Anfang. Diese Bytes sehen im Disassembler wie „Müll-Instruktionen“ aus — sie sind aber Daten:

mem[0x00..0x27] = prog[0x70..0x97]
  = 82 89 18 9d 58 db ab 3b 68 f4 cb 05 93 db e5 1d
    fe fa 0a 86 9b 17 1a d7 b0 6e bf 00 00 00 00 00
    53 34 46 45 2d 42 4f 58
  • mem[0..26] = Zielwerte pro Zeichen
  • mem[32..39] = 8-Byte-Sbox ("S4FE-BOX", ein netter Anspielungs-Gag)

Der Angriff

Der entschlüsselte Bytecode, disassembliert — Instruktionen 0 bis 27 sind das eigentliche Programm, ab 0x70 folgen die Tape-Daten:

 0  LDI   r0 = 0
 1  LDI   r2 = 0
 2  LDI   r4 = 0
 3  LDI   r3 = 4          ; <-- Schleifenanfang (JNZ-Ziel)
 4  ADD   r3 = r3 + r0    ; r3 = 4 + i
 5  INP   r1 = input[r3]  ; inneres Flag-Zeichen input[4+i]
 6  LDI   r5 = 7
 7  MOV   r6 = r0
 8  AND   r6 = r6 & r5    ; r6 = i & 7
 9  LDI   r5 = 32
10  ADD   r6 = r6 + 32    ; r6 = 32 + (i & 7)
11  LOAD  r5 = mem[r6]    ; Sbox-Byte
12  XOR   r1 = r1 ^ r5
13  ROLc  r1 = rol(r1, 3)
14  ROL4  r1 = rol(r1, 4) ; 3+4 = rol 7 = ror 1
15  ADD   r1 = r1 + r4    ; r4 = 7*i
16  LOAD  r5 = mem[r0]    ; Zielwert mem[i]
17  XOR   r1 = r1 ^ r5
18  OR    r2 = r2 | r1    ; Fehler akkumulieren
19  LDI   r5 = 1
20  ADD   r0 = r0 + 1     ; i++
21  LDI   r5 = 7
22  ADD   r4 = r4 + 7     ; r4 += 7
23  LDI   r5 = 27
24  MOV   r6 = r0
25  SUB   r6 = r6 - 27
26  JNZ   r6 -> 3         ; solange i != 27
27  HALT  r2              ; akzeptiere, wenn r2 == 0

Die Schleife läuft i = 0..26 und akkumuliert in r2 per OR die Differenzen. r2 bleibt genau dann 0, wenn jedes r1 in jeder Iteration 0 ist. Pro Zeichen:

t = input[4+i] ^ mem[32 + (i & 7)]     # XOR mit Sbox
t = ror(t, 1)                          # rol 3 dann rol 4 = ror 1
t = (t + 7*i) & 0xff                    # Addition
Bedingung:  t == mem[i]

Da jeder Schritt bijektiv ist, lässt sich das direkt nach dem Eingabezeichen auflösen:

input[4+i] = rol( (mem[i] - 7*i) mod 256, 1 ) ^ mem[32 + (i & 7)]

Der Solver:

key  = bytes.fromhex("9e3c71a54b")
data = bytes.fromhex(
"9f3c71a54a9c3c71a44f9e3c70a64b9a3972a54b903d72a54a9b3c76a14d9e3c"
"79a34e9e3d74a56b9b3a74a5499b3a71a24a9b3c78a44b9d3670a54b9b3d75a5"
"499b3c71a24a9b3c7aa74a9e3d74a54a9b3c74a54a9b3c76a04f9b3c70a04b85"
"3877a54b983a74a547983c72aa499e3cf32c530364aa0e70f6c8baa0d845d96c"
"5bb194baeab251498c1f1a4b9e3c71a518aa7a348809d164")

prog = bytearray(0x98); prog[0] = 1
for i in range(1, 0x98):
    prog[i] = key[i % 5] ^ data[i]

mem = prog[0x70:0x98]                      # Tape-Init-Daten
rol = lambda v, n: ((v << (n & 7)) | (v >> (8 - (n & 7)))) & 0xff

inner = bytes(
    rol((mem[i] - 7*i) & 0xff, 1) ^ mem[32 + (i & 7)]
    for i in range(27)
)
print("DBH{" + inner.decode() + "}")

Zur Gegenprobe habe ich die komplette VM in Python nachgebildet und den Kandidaten als Eingabe durchgeschickt → Endzustand r2 == 0 → ACCEPTED:

mem = bytearray(256); mem[:0x28] = prog[0x70:0x98]
inp = b"DBH{V1RTU3LL3_M4SCH1N3_G3KN4CKT}"
r = [0]*8; pc = 0
while True:
    op, a, b, c = prog[pc], prog[pc+1]&7, prog[pc+2]&7, prog[pc+3]
    if   op == 1:  r[a] = c
    elif op == 2:  r[a] = mem[r[b]]
    elif op == 3:  mem[r[b]] = r[a]
    elif op == 4:  r[a] = r[b]
    elif op == 5:  r[a] = (r[a]+r[b]) & 0xff
    elif op == 6:  r[a] = (r[a]-r[b]) & 0xff
    elif op == 7:  r[a] ^= r[b]
    elif op == 8:  r[a] &= r[b]
    elif op == 9:  r[a] = rol(r[a], c)
    elif op == 10: r[a] = rol(r[a], 4)
    elif op == 11: r[a] |= r[b]
    elif op == 12:
        if r[a] != 0: pc = 4*c; continue
    elif op == 13: pc = 4*c; continue
    elif op == 14: r[a] = inp[r[b]] if r[b] <= 31 else 0
    elif op == 15:
        print("ACCEPTED" if r[a] == 0 else "REJECTED"); break
    pc += 4

Die Flag

DBH{V1RTU3LL3_M4SCH1N3_G3KN4CKT}

Was ich mitnehme

„Virtuelle Maschine geknackt“ — passend, denn genau darum ging es: nicht den jne-Sprung patchen (das öffnet den Tresor, verrät aber nichts), sondern den verschlüsselten VM-Bytecode entschlüsseln, die 16 Opcodes rekonstruieren und die bijektive Prüfung Zeichen für Zeichen invertieren.

VM-Obfuskation verschiebt das Reversing eine Ebene nach oben: man analysiert nicht mehr das Programm, sondern erst den Interpreter, dann das Programm. Der Aufwand ist einmalig — sobald die Opcode-Tabelle steht, ist der Bytecode so lesbar wie jedes andere Listing. Und weil die Prüfung hier aus lauter invertierbaren Operationen besteht, war am Ende kein einziger Brute-ForceBrute-Force-AngriffSystematisches Ausprobieren vieler Zugangsdaten oder kryptografischer Schlüssel.-Schritt nötig.