Deutschlands Bester Hacker - Serienfehler (Crypto) - 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 Crypto
Punkte 338
Angriffsklasse Gemeinsamer RSA-Primfaktor (Batch-GCD)
Flag DBH{G3M31NS4M3R_PR1MF4KT0R_1N_S3R13}

Die Challenge

Gegeben war ein ZIP-Archiv mit vielen Router-Zertifikaten und einem verschlüsselten Supportpaket:

router-zertifikate.zip
├── certs/
│   ├── nx300-0001.pem
│   ├── ...
│   └── nx300-0128.pem
├── supportpaket.json
└── LIESMICH.txt

Die README beschreibt die Situation: 128 selbstsignierte HTTPS-Gerätezertifikate, jeweils mit RSA-2048 und e = 65537. Das Supportpaket wurde per RSA-OAEP mit SHA-256 gegen den öffentlichen Schlüssel des Zielgeräts verschlüsseltEncryptionWandelt Klartext mithilfe eines Schlüssels in nicht lesbaren Geheimtext um.. Das Zielgerät ist NX300-0042.

Aufklärung

Bei RSA besteht der öffentliche Modulus aus zwei geheimen Primzahlen n = p * q. Wenn jedes Gerät seinen Schlüssel korrekt erzeugt, dürfen zwei verschiedene Geräte niemals denselben Primfaktor verwenden. Bei schlechter EntropieEntropyMaß für Unvorhersehbarkeit, insbesondere bei Schlüsseln, Passwörtern und Zufallswerten. beim Bootstrapping passiert aber genau das:

n_a = p * q_a
n_b = p * q_b

Dann ist gcd(n_a, n_b) = p — direkt aus den öffentlichen Schlüsseln berechenbar. Bei 128 Zertifikaten ist ein paarweiser Vergleich trivial machbar. Genau danach habe ich gesucht.

Die Schwachstelle

Zunächst die Moduli aus den Zertifikaten ziehen:

from zipfile import ZipFile
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa

archive = 'router-zertifikate.zip'
moduli = {}

with ZipFile(archive) as z:
    for name in z.namelist():
        if not name.startswith('certs/') or not name.endswith('.pem'):
            continue

        cert = x509.load_pem_x509_certificate(z.read(name))
        pub = cert.public_key()

        if not isinstance(pub, rsa.RSAPublicKey):
            continue

        numbers = pub.public_numbers()
        device = name.rsplit('/', 1)[1].removesuffix('.pem').upper()
        moduli[device] = (numbers.n, numbers.e)

Dann alle Paare per gcd vergleichen:

from math import gcd

hits = []
devices = sorted(moduli)

for i, a in enumerate(devices):
    n_a, e_a = moduli[a]
    for b in devices[i + 1:]:
        n_b, e_b = moduli[b]
        g = gcd(n_a, n_b)

        if 1 < g < n_a and 1 < g < n_b:
            hits.append((a, b, g))

Die Ausgabe zeigt die Schwachstelle:

NX300-0042 NX300-0097 1024

Der gemeinsame Faktor ist 1024 Bit lang — passt zu RSA-2048, das typischerweise aus zwei etwa 1024 Bit langen Primzahlen besteht. Und das Zielgerät ist Teil der Kollision.

Der Angriff

Für NX300-0042 sind damit p und q bekannt, der private Schlüssel folgt direkt:

target = 'NX300-0042'
n, e = moduli[target]

p = hits[0][2]
q = n // p

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)

private_numbers = rsa.RSAPrivateNumbers(
    p=p,
    q=q,
    d=d,
    dmp1=d % (p - 1),
    dmq1=d % (q - 1),
    iqmp=pow(q, -1, p),
    public_numbers=rsa.RSAPublicNumbers(e=e, n=n),
)

private_key = private_numbers.private_key()

Hier wird nichts gebrutet — die Faktorisierung entsteht unmittelbar aus dem gemeinsamen Primfaktor.

Anschließend das Supportpaket entschlüsseln, mit exakt derselben Padding-Konfiguration wie in der README beschrieben:

import base64, json
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes

with ZipFile(archive) as z:
    support = json.loads(z.read('supportpaket.json'))

plaintext = private_key.decrypt(
    base64.b64decode(support['chiffrat']),
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
    ),
)

print(plaintext.decode())

Die Ausgabe ist ein JSON-Dokument mit dem Wartungscode — und darin die Flag.

Die Flag

DBH{G3M31NS4M3R_PR1MF4KT0R_1N_S3R13}

Was ich mitnehme

Eine klassische RSA-Panne: werden Primzahlen mit schlechter Entropie erzeugt — typisch bei embedded Geräten, die direkt nach dem ersten Boot Schlüssel generieren — können verschiedene Moduli einen Faktor teilen. Das ist fatal, weil der private Schlüssel dann aus rein öffentlichen Zertifikatsdaten rekonstruierbar ist.

Der Angriff skaliert: mit Batch-GCD lassen sich Millionen von Moduli in vertretbarer Zeit gegeneinander prüfen. Genau das haben große Internet-Scans in der Vergangenheit mit Produktivzertifikaten gemacht — mit unangenehmen Trefferquoten.