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.