Zum Inhalt
wannacode

JWT und seine Fallen

Kurz gesagt: Ein JWT ist ein signiertes Token aus Header, Payload und Signatur; der Payload ist nicht verschlüsselt und jeder kann ihn lesen, und der Server muss Algorithmus, Signatur und exp selbst prüfen.

Problem

Das Token, das Admin wurde

Die mobile App des Shops benutzt keine Seiten, sondern eine API. Das Team wählt JWT, „damit der Server keine Sitzungen speichern muss“, und kopiert die Token-Prüfung aus einem alten Blogpost. Monate später schreibt ein Sicherheitsforscher an den Shop: Mit einem Token, das er von Hand geschrieben hat, ist er als admin in die Erstattungs-API gekommen.

Der Schlüssel des Shops ist nicht geleakt. Das Admin-Passwort auch nicht. Der Forscher hat nichts gebrochen: Die Prüfung hat sein Token von selbst akzeptiert.

Die Idee

Ein signierter Ausweis

Die Sitzung aus der vorigen Lektion ist eine Garderobenmarke: Der Server führt eine Tabelle, wer hinter welcher Id steht. Ein JWT (JSON Web Token) ist ein anderer Ansatz: Das Token selbst sagt, wer du bist und bis wann es gültig ist, und der Server signiert es, damit es nicht geändert werden kann.

Das Token hat drei Teile, getrennt durch Punkte:

  • header: wie es signiert ist, zum Beispiel {"alg":"HS256","typ":"JWT"};
  • payload: was es sagt, zum Beispiel {"sub":"maria","exp":1793000900}. sub ist der Nutzer, exp der Moment des Ablaufs, in Sekunden seit dem 1. Januar 1970;
  • signature: HMAC-SHA256 über "header.payload" mit einem geheimen Schlüssel des Servers.

Header und Payload sind JSON, geschrieben in base64url: base64 mit - und _ statt + und / und ohne = am Ende, damit es in eine Adresse und einen Header passt. Base64url ist eine Schreibweise, keine Verschlüsselung. Das Token ist signiert, nicht geheim: Jeder liest es, aber ohne den Schlüssel kann es niemand ändern, ohne dass die Signatur nicht mehr passt.

Sieh es dir an

Die drei Teile und das gefälschte Token

static String verify(String token, long now) {
String[] parts = token.split("\\.", -1);
if (parts.length != 3) return "✗ Format";
// der Algorithmus ist festgelegt: nie aus dem Token genommen
String alg = text(decode(parts[0]), "alg");
if (!"HS256".equals(alg)) return "✗ Algorithmus";
String data = parts[0] + "." + parts[1];
if (!same(sign(data), parts[2])) return "✗ Signatur";
String payload = decode(parts[1]);
Long exp = number(payload, "exp");
if (exp == null || now >= exp) return "✗ abgelaufen";
return "✓ " + text(payload, "sub");
}
Schritt für Schritt, in Worten
  1. Der Header sagt, wie das Token signiert ist: HS256. Das JSON wird zu base64url-Text.
  2. Der Payload sagt, wer und bis wann: sub maria, exp in 15 Minuten. Auch base64url.
  3. Die Signatur ist HMAC-SHA256 über "header.payload" mit dem Schlüssel des Servers. Ohne den Schlüssel kann sie niemand erzeugen.
  4. Das Token sind die drei Teile mit Punkten. Die mobile App schickt es bei jeder Anfrage mit.
  5. Jeder, der das Token hat, liest den Payload ohne Schlüssel: base64url ist keine Verschlüsselung. Signiert, nicht geheim.
  6. Jetzt ein gefälschtes Token: Der Header sagt alg none, der Payload sagt sub admin, und es gibt keine Signatur.
  7. Die naive Prüfung fragt das Token, welchen Algorithmus sie benutzen soll. "none" überspringt die Signatur, und admin kommt herein.
  8. Die Lösung legt den Algorithmus fest: nur HS256, vom Server bestimmt. alg none wird vor allem anderen abgelehnt.
  9. Ein echtes, aber altes Token: Die Signatur stimmt, aber exp ist vorbei. Die naive Prüfung ließ es herein; diese nicht.
  10. Drei Prüfungen in dieser Reihenfolge: der vom Server gewählte Algorithmus, die Signatur, der Ablauf. Der Payload ist nie ein Geheimnis.

Signatur und base64url stecken in der Standardbibliothek jeder Sprache. Der Schlüssel hier ist ein Demo-Schlüssel: Der echte besteht aus 32 Zufallsbytes außerhalb des Codes (die Lektion „Geheimnisse im Code und in Git“).

// ein Demo-Schlüssel; der echte besteht aus 32 Zufallsbytes aus einem Secret Store
static final byte[] KEY = "demo-key-only-for-this-lesson".getBytes(UTF_8);
static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
static String encode(String json) {
return B64.encodeToString(json.getBytes(UTF_8));
}
static String sign(String data) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(KEY, "HmacSHA256"));
return B64.encodeToString(hmac.doFinal(data.getBytes(UTF_8)));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
static String issue(String user, long exp) {
String header = """
{"alg":"HS256","typ":"JWT"}""";
String payload = """
{"sub":"%s","exp":%d}""".formatted(user, exp);
String data = encode(header) + "." + encode(payload);
return data + "." + sign(data);
}
Java hat kein JSON in der Standardbibliothek, deshalb klebt issue die beiden JSON-Texte mit einem Textblock und formatted zusammen. Das ist nur sicher, weil user eine Id aus unserer eigenen Datenbank ist, ohne Anführungszeichen. Ein echtes Projekt nimmt eine Bibliothek, die auch escaped.

Ein Token für Maria, 15 Minuten gültig:

long now = 1_793_000_000L;
String token = issue("maria", now + 900);
String[] parts = token.split("\\.");
System.out.println("header: " + parts[0]);
System.out.println("payload: " + parts[1]);
System.out.println("Signatur:" + parts[2]);
System.out.println("Payload ohne den Schlüssel: " + decode(parts[1]));
Ausgabe
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0
Signatur:mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw
Payload ohne den Schlüssel: {"sub":"maria","exp":1793000900}

Der Schlüssel und „jetzt“ sind fest, deshalb ist das Token bei jedem Lauf und in allen vier Sprachen dasselbe. Die letzte Zeile ist der Payload, gelesen ohne den Schlüssel.

Probier es aus

Sage die Ausgabe voraus

Jemand hat Marias Token abgefangen. Es gibt keinen Schlüssel. Was gibt das aus?

String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";
String payload = token.split("\\.")[1];
byte[] bytes = Base64.getUrlDecoder().decode(payload);
System.out.println(new String(bytes, UTF_8));
Antwort

Die Idee

Falle 1: Der Algorithmus kommt aus dem Token

Hier ist die Prüfung aus dem Blogpost. Um Header und Payload zu lesen, nutzt sie zwei Hilfsfunktionen:

static String decode(String part) {
return new String(Base64.getUrlDecoder().decode(part), UTF_8);
}
// Java hat kein JSON in der Standardbibliothek: Für zwei Felder reicht ein Regex
static String text(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
static Long number(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\":(\\d+)").matcher(json);
return m.find() ? Long.parseLong(m.group(1)) : null;
}
// SO NICHT: vertraut dem Algorithmus im Token
static String naiveVerify(String token) {
String[] parts = token.split("\\.", -1);
String alg = text(decode(parts[0]), "alg");
if ("HS256".equals(alg)) {
String data = parts[0] + "." + parts[1];
if (!sign(data).equals(parts[2]))
return "✗ Signatur";
}
// "none": Das Token selbst sagt, dass es keine Signatur hat
return "✓ " + text(decode(parts[1]), "sub");
}

Sie liest alg aus dem Header und prüft die Signatur nur, wenn dort HS256 steht. Der Standard kennt wirklich einen Algorithmus none: „ein Token ohne Signatur“. Hier sind vier Tokens: das echte, dasselbe mit geändertem Payload, ein handgeschriebenes Token mit alg: none, sub: admin und leerer Signatur, und ein echtes, aber abgelaufenes Token:

long now = 1_793_000_000L;
String good = issue("maria", now + 900);
String[] p = good.split("\\.");
String admin = """
{"sub":"admin","exp":%d}""".formatted(now + 900);
// ein geänderter Payload mit der alten Signatur
String changed = p[0] + "." + encode(admin) + "." + p[2];
// alg: none und überhaupt keine Signatur
String none = encode("""
{"alg":"none","typ":"JWT"}""") + "." + encode(admin) + ".";
String old = issue("maria", now - 60);
System.out.println("echt: " + naiveVerify(good));
System.out.println("geänderter Payload: " + naiveVerify(changed));
System.out.println("alg: none: " + naiveVerify(none));
System.out.println("abgelaufen: " + naiveVerify(old));
Ausgabe
echt: ✓ maria
geänderter Payload: ✗ Signatur
alg: none: ✓ admin
abgelaufen: ✓ maria

Ein geänderter Payload wird erwischt, weil die Signatur nicht mehr passt. Aber alg: none lässt admin ohne jede Signatur herein, und das abgelaufene Token geht durch, weil exp nie angesehen wird. Der Header ist Nutzereingabe, wie alles im Token, bis die Signatur geprüft ist (die Lektion „Nicht vertrauenswürdige Eingaben“). Eine Prüfung, die das Token fragt, wie sie es prüfen soll, vertraut dem Angreifer.

Sieh es dir an

Die Lösung: drei Prüfungen der Reihe nach

static String verify(String token, long now) {
String[] parts = token.split("\\.", -1);
if (parts.length != 3) return "✗ Format";
// der Algorithmus ist festgelegt: nie aus dem Token genommen
String alg = text(decode(parts[0]), "alg");
if (!"HS256".equals(alg)) return "✗ Algorithmus";
String data = parts[0] + "." + parts[1];
if (!same(sign(data), parts[2])) return "✗ Signatur";
String payload = decode(parts[1]);
Long exp = number(payload, "exp");
if (exp == null || now >= exp) return "✗ abgelaufen";
return "✓ " + text(payload, "sub");
}
static boolean same(String a, String b) {
// vergleicht alle Bytes, ohne beim ersten Unterschied anzuhalten
return MessageDigest.isEqual(a.getBytes(UTF_8), b.getBytes(UTF_8));
}
  1. Der Algorithmus ist festgelegt. Der Server weiß, dass er mit HS256 signiert, und akzeptiert nichts anderes. none hört hier auf.
  2. Die Signatur wird in konstanter Zeit verglichen, wie das CSRF-Token in der vorigen Lektion.
  3. exp ist Pflicht. Ein Token ohne exp oder mit einem exp in der Vergangenheit wird abgelehnt.

Dieselben vier Tokens:

long now = 1_793_000_000L;
String good = issue("maria", now + 900);
String[] p = good.split("\\.");
String admin = """
{"sub":"admin","exp":%d}""".formatted(now + 900);
// ein geänderter Payload mit der alten Signatur
String changed = p[0] + "." + encode(admin) + "." + p[2];
// alg: none und überhaupt keine Signatur
String none = encode("""
{"alg":"none","typ":"JWT"}""") + "." + encode(admin) + ".";
String old = issue("maria", now - 60);
System.out.println("echt: " + verify(good, now));
System.out.println("geänderter Payload: " + verify(changed, now));
System.out.println("alg: none: " + verify(none, now));
System.out.println("abgelaufen: " + verify(old, now));
Ausgabe
echt: ✓ maria
geänderter Payload: ✗ Signatur
alg: none: ✗ Algorithmus
abgelaufen: ✗ abgelaufen

Probier es aus

Ergänze die Lücke

Welchen Algorithmus akzeptiert die Prüfung?

String alg = text(decode(parts[0]), "alg");
if (!"".equals(alg)) return "✗ Algorithmus";

Probier es aus

Finde den Fehler

Diese Prüfung legt HS256 fest und vergleicht die Signatur richtig. Aber eine alte Version der App hat Tokens ohne exp ausgestellt, und sie funktionieren bis heute. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Falle 2: Wo das Token lebt und wie es widerrufen wird

localStorage. Web-Apps speichern ein JWT oft in localStorage, weil es bequem ist. Aber jedes Skript auf der Seite liest localStorage. Eine XSS-Lücke, und das fremde Skript schickt das Token an den Angreifer, der es benutzt, bis es abläuft. Die Signatur hilft nicht: Das Token ist nicht gefälscht, es ist ganz gestohlen. Im Browser ist ein Cookie mit HttpOnly der sicherere Ort, aber dann geht es von selbst mit, und du brauchst den CSRF-Schutz aus der vorigen Lektion.

Widerrufen. Der Server prüft nur die Signatur und exp. Wird Marias Handy gestohlen, funktioniert ihr Token bis exp, auch wenn sie ihr Passwort ändert. Deshalb sind Access-Tokens kurz (5 bis 15 Minuten), und eine lange Anmeldung nutzt ein Refresh-Token, das der Server speichert und löschen kann. Eine andere Möglichkeit ist eine Liste widerrufener Tokens auf dem Server, aber dann speichert der Server wieder Zustand, wie bei einer Sitzung.

Wann eine Sitzung einfacher ist. Hast du eine Website mit einem Server, ist das Sitzungs-Cookie aus der vorigen Lektion die einfachere Antwort: Du widerrufst es, indem du die Zeile löschst, es gibt keine Signatur zu prüfen und keinen Algorithmus, den man falsch wählen kann. JWT ergibt Sinn, wenn viele Dienste das Token ohne gemeinsame Sitzungstabelle prüfen oder zwischen verschiedenen Firmen (die Anmeldung mit Google ist OpenID Connect, und das ist JWT).

Genauer, für Neugierige

Die Bibliotheken. In einem echten Projekt schreibst du JWT nicht von Hand: Java hat jjwt, java-jwt (Auth0) und Nimbus JOSE + JWT; C# hat Microsoft.IdentityModel.JsonWebTokens, und ASP.NET Core prüft Tokens mit AddJwtBearer; Python hat PyJWT; Node hat jsonwebtoken und jose. Auch mit einer Bibliothek legst du den Algorithmus ausdrücklich fest: jwt.decode(token, key, algorithms=["HS256"]) in PyJWT, jwt.verify(token, key, { algorithms: ["HS256"] }) in jsonwebtoken, ValidAlgorithms in TokenValidationParameters, verifyWith(key) in jjwt. Alte Versionen mancher Bibliotheken akzeptierten none, wenn man ihnen den Algorithmus nicht sagte.

RS256 und ES256. Hier signiert ein privater Schlüssel und ein öffentlicher prüft: So können viele Dienste prüfen, ohne signieren zu können. Dann gibt es eine weitere Falle, die Algorithm Confusion: Ein Server, der RS256 erwartet, aber das Token fragt, akzeptiert vielleicht HS256 mit dem öffentlichen Schlüssel als HMAC-Schlüssel. Die Lösung ist dieselbe: ein festgelegter Algorithmus.

Weitere Felder. iat (wann ausgestellt), nbf (nicht vor), aud (für welchen Dienst), iss (wer es ausgestellt hat), jti (eine eindeutige Id, zum Widerrufen). Die Uhren der Server laufen auseinander, deshalb erlauben die Bibliotheken bei exp einige Sekunden Spielraum.

Ein verschlüsseltes JWT gibt es (JWE), aber es wird selten gebraucht: Es ist einfacher, keine Geheimnisse ins Token zu legen.

In der Lektion liest Java JSON mit einem regulären Ausdruck, weil die Standardbibliothek kein JSON hat. Für unsere zwei Felder reicht das, aber es ist kein Parser: Eine echte Bibliothek (Jackson in jjwt) liest JSON richtig.

Übung

Auf allen Geräten abmelden

Löse die Aufgabe in deinem eigenen Editor.

Maria verliert ihr Handy und tippt auf „Auf allen Geräten abmelden“. Füge dem Payload jti hinzu, eine zufällige eindeutige Id des Tokens. Schreibe logout, das die jti des Tokens in einer Menge widerrufener ablegt, und eine Prüfung, die nach verify ein widerrufenes Token mit „✗ widerrufen“ ablehnt. Eine neue Anmeldung ergibt ein neues Token, das funktioniert.

Ausgabe
vor dem Abmelden: ✓ maria
nach dem Abmelden: ✗ widerrufen
neue Anmeldung: ✓ maria

Weitere Aufgaben

  • Ein kurzes Leben. Mach das Access-Token 5 Minuten lang und füge ein Refresh-Token hinzu, das der Server in einer Tabelle speichert. Was musst du jetzt bei „Auf allen Geräten abmelden“ löschen?
  • Aufräumen. Eine widerrufene jti muss nur so lange gespeichert werden, bis das Token ohnehin ablaufen würde. Speichere auch exp und entferne die alten Einträge.

Test

Teste dich

1. Wer kann den Payload eines JWT lesen?
2. Warum legt die Prüfung den Algorithmus fest (nur HS256), statt ihn aus dem Header zu lesen?
3. Eine Web-App speichert das JWT in localStorage. Was passiert, wenn die Seite eine XSS-Lücke hat?
4. Marias Handy wird gestohlen, und das Token der App ist 30 Tage gültig. Was kann der Shop tun?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·