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.oderAbgelehnt.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 Zeichenmem[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.