205 Millisekunden reichen, um eine komplette Sitzung zu kippen. Diese Zeitspanne verursachte Probleme bei der Einführung von Device Bound Session Credentials (DBSC) bei Report URI. Der Erfahrungsbericht zeigt, wie die Theorie einer Sicherheitsverbesserung an der Realität eines produktiven Webservers scheitert.
Arbeitest du in einem Browser-Tab, wechselst kurz zur Seite, bist du plötzlich ausgeloggt. Kein Fehler, keine Meldung – einfach die Sitzung weg. Genau das erlebten die Beta-Nutzer von Report URI nach der DBSC-Einführung. Die Ursache war kein klassischer Programmierfehler, sondern ein Zusammenspiel aus Browser-Verhalten, Netzwerk-Latenz und einer Sicherheitsarchitektur, die strenger sein will als nötig.
DBSC bindet Sitzungscookies an das Gerät des Nutzers. Ein normales Session-Cookie ist ein reines Bearer-Token – ein Wert, den jeder Besitzer beliebig verwenden kann. DBSC verlangt den Besitz eines privaten Schlüssels, der im sicheren Hardware-Bereich des Geräts liegt. Infostealer-Malware, die Cookies aus dem Browser stiehlt, läuft damit ins Leere. Die Implementierung hat Tücken, die die Spezifikation nicht ahnen lässt.
DBSC verstehen: Einphasige Registrierung, zweiphasiger Refresh
Die Spezifikation liest sich zunächst so, als sei die Registrierung ein zweistufiger Prozess und der Refresh eine einzelne Anfrage. In der Praxis ist es umgekehrt: Die Registrierung ist ein einzelner Header, den der Server an eine erfolgreiche Antwort anhängt. Der Browser generiert daraufhin einen Schlüssel, signiert damit ein JWT und sendet es an den Registrierungs-Endpoint. Der Server prüft, speichert die Bindung und antwortet mit einem neuen Cookie. Ein einziger Roundtrip.
Der Refresh dagegen ist zweistufig. Der Browser schickt zunächst eine POST-Anfrage an den Refresh-Endpoint, ohne eine Challenge zu besitzen. Der Server antwortet mit Statuscode 403 und einem Header, der eine Herausforderung enthält. Erst dann signiert der Browser diese Herausforderung und sendet eine zweite POST-Anfrage. Der Server prüft die Signatur und liefert ein frisches Cookie. Das bedeutet: Der 403-Status ist hier der normale, gesunde Weg, kein Fehler. Wer sich das nicht einprägt, programmiert schnell eine falsche Fehlerbehandlung.
Der Autor berichtet, dass sogar ein erfahrener Entwickler einer Node-Implementierung Monate später in einem Issue zugab, das Verhalten habe ihn eine peinliche Zeit gekostet. Die Lektion: Wenn dein Refresh-Endpoint einen 403 zurückgibt, ist das ein Fortschritt, kein Abbruch. Wer den Refresh als Fehler behandelt, bricht die gesamte Sitzung ab, ohne dass der Nutzer eine Chance zur Korrektur hat.
Die erste Falle: Flüchtiger Zustand im Session-Store
Die nächste Falle ist heimtückischer, weil sie keinen Fehler wirft und dennoch die Sicherheit untergräbt. Das Team speicherte den DBSC-Zustand zuerst im normalen Session-Store – in dem Blob, den PHP-Sessions üblicherweise serialisieren und bei jeder Anfrage komplett zurückschreiben. Das ist der einfachste Weg, führt aber zu einer Race Condition.
Stell dir vor, ein Nutzer loggt sich ein und navigiert sofort zur Kontoübersicht. Während die Seite lädt, läuft parallel die Registrierung des DBSC-Schlüssels. Beide Prozesse lesen und schreiben denselben Session-Blob. Der letzte Schreibvorgang gewinnt. Wenn der Seiten-Request den Blob nach der Registrierung schreibt, wird die gerade erzeugte Bindung gelöscht. Die Folge: Der Server sieht keine Bindung, deaktiviert die DBSC-Erzwingung und behandelt die Sitzung stillschweigend wieder als normales Bearer-Token. Genau die Sicherheitslücke, die DBSC schließen sollte, ist wieder offen – ohne Log, ohne Hinweis.
Die Lösung ist simpel und leicht zu übersehen: Der DBSC-Zustand braucht einen eigenen Speicherbereich, getrennt vom übrigen Session-Blob. Die Schlüsselzugehörigkeit bleibt zwar über die Session-ID verknüpft, aber der Blob wird unabhängig geschrieben, sodass ein paralleler Seiten-Request die Bindung nicht überschreiben kann. Die Bibliothek warnt inzwischen ausdrücklich davor, den State in einem gemeinsam genutzten Blob abzulegen. Es ist eine dieser Lektionen, die nur dann teuer wird, wenn man sie ignoriert.
Rotationsregeln, die Chrome erzwingt
Drei weitere Stolpersteine betreffen die Rotation von Werten. Der Browser verlangt, dass sich der Cookie-Wert bei jedem Refresh ändert. Wenn der Server eine gültige Signatur prüft und einfach denselben Wert zurücksendet, interpretiert Chrome das so, als habe kein Refresh stattgefunden – und beendet die Sitzung. Das klingt pingelig, hat aber einen tieferen Sinn: Der Browser will beweisen, dass der Server tatsächlich gehandelt hat, dass die Sitzung lebt.
Dasselbe gilt für die Challenge. Auch sie muss bei jedem Refresh neu ausgestellt werden, sonst verweigert der Browser die Zusammenarbeit. Zudem darf auf der Registrierungs-Antwort keine Challenge stehen. Das sieht nach einer sinnvollen Optimierung aus, denn so würde der erste 403 erspart. Doch Chrome wirft einen Challenge-Fehler, und die Sitzung kommt nie in Gang. Der Grund liegt im Header: Die Challenge trägt eine ID, die eine bestimmte Sitzung benennt. Auf der Registrierungs-Antwort existiert diese Sitzung aber noch nicht – die ID verweist ins Leere, und der Browser verwirft die Challenge.
Hinzu kommt eine vierte Regel, die weniger mit Chrome zu tun hat als mit schlichter Zeitberechnung: Die Gültigkeitsdauer der Challenge muss länger sein als die Lebensdauer des Cookies. Der Browser speichert Challenges zwischen; wenn eine Challenge vor dem Ablauf des Cookies verfällt, hat er keinen brauchbaren Wert mehr für den nächsten Refresh. Die Bibliothek verweigert inzwischen die Konfiguration mit falschen Werten. Das spart späteres Kopfzerbrechen.
Die unsichtbare Race Condition: Der alte Cookie im neuen Gewand
Jetzt kommen wir zu der 205-Millisekunden-Story. Der gebundene Cookie rotiert bei jedem Refresh – aber nicht sofort. Der Refresh selbst ist ein Roundtrip, und währenddessen laufen im Browser noch andere Anfragen. Eine dieser Anfragen wurde 205 Millisekunden nach dem Start des Refreshs abgeschickt und trug den alten, bereits abgelaufenen Cookie-Wert. Aus Sicht des Browsers ist das vollkommen legitim, denn zu diesem Zeitpunkt existierte der neue Wert noch gar nicht.
Der Server dagegen vergleicht den präsentierten Cookie mit dem gespeicherten und findet eine Diskrepanz. Sein Urteil: gestohlener Cookie. Daraufhin beendet er die Sitzung, widerruft die Bindung und loggt den Nutzer aus. In der Beta-Testphase passierte genau das immer wieder – zufällig, unvorhersehbar, und ohne dass die Audit-Logs einen Hinweis lieferten. Erst ein detaillierter Request-Trace zeigte den Ablauf. Der Kern des Problems: Die Fehlerrate ist proportional zur Latenz. Auf dem lokalen Entwicklungsserver beträgt das Zeitfenster nur wenige Millisekunden, da sieht man nichts. Hinter einem CDN, über ein echtes Netzwerk, dauert der Refresh eine Sekunde oder länger, und dann feuert die fehlerhafte Erkennung fast bei jedem Refresh, der mit einer normalen Anfrage kollidiert. Auf einer viel besuchten Seite ist das fast immer der Fall.
Die Lösung ist elegant: Der Server akzeptiert zusätzlich zum aktuellen Cookie-Wert auch den unmittelbar vorherigen – aber nur so lange, wie dieser Wert vom Browser natürlicherweise ohnehin noch gesendet worden wäre. Es gibt keine ausgedachte Toleranzzeit wie „fünf Sekunden extra“. Das Fenster entspricht exakt der Restlebensdauer, die der alte Cookie im Browser noch gehabt hätte. Das ist ein realer Wert, abgeleitet aus der Systemlogik, und er verfällt von selbst. Die Sicherheitslücke bleibt trotzdem begrenzt: Ein gestohlener alter Cookie kann keine neue Signatur erzeugen, also scheitert der Dieb spätestens beim nächsten Refresh. Eine echte Angreiferin kann die Sitzung nicht übernehmen.
Verbotene Redirects: Warum 302 den Tab einfriert
Der nächste Stolperstein ist zugleich der spektakulärste, denn er führt zum vollständigen Deadlock. Die DBSC-Endpoints erbten den normalen Authentifizierungs-Gate der Anwendung, der bei fehlender Anmeldung auf die Login-Seite umleitet – also einen Redirect mit Statuscode 302 zurückgibt. In der Theorie harmlos. In der Praxis passiert Folgendes: Eine Sitzung ist abgelaufen, während ein Tab geöffnet im Hintergrund ruht. Der Tab wacht auf und möchte eine Seite laden. Der gebundene Cookie ist abgelaufen, also verschiebt Chrome die Navigation und ruft zuerst den Refresh-Endpoint auf. Der Auth-Gate sieht die abgelaufene Sitzung und antwortet mit 302 auf die Login-Seite. Chrome bleibt stehen – nicht im Sinne von „aufgeben“, sondern im wörtlichen Sinne. Die verschobene Navigation wird nie freigegeben, kein Timeout, kein Fehler. Der Tab zeigt eine leere Seite, für immer.
Ein Vergleich mit dem 401-Status macht das deutlich: Wenn der Server mit 401 antwortet, versteht Chrome das als „Sitzung beendet“, räumt den DBSC-Zustand auf und gibt die verschobene Navigation frei, die dann ganz normal zur Login-Seite weiterläuft. Dieselbe Absicht – „Nutzer ist nicht angemeldet“ – führt je nach Statuscode zu komplett unterschiedlichen Ergebnissen. Der Autor berichtet, dass dieser Bug in Chromium gemeldet wurde, und empfiehlt eine einfache, robuste Lösung: Die DBSC-Endpoints dürfen grundsätzlich keine Redirects senden. Statt den Redirect-Mechanismus zu erweitern, überschreiben sie ihn vollständig. So ist jede zukünftige Middleware, die versehentlich einen Redirect einbaut, ebenfalls abgedeckt.
Das ist eine der wichtigsten praktischen Erkenntnisse: Sicherheitsfeature-Implementierung verlangt nicht nur, dass du die Spezifikation kennst, sondern auch, dass du das Verhalten des Browsers in Randfällen wirklich testest. Die Theorie wird erst dann zur Praxis, wenn du die Fehler pflegst, die keiner erwartet.
Herausforderungen sind keine Angriffe: Die letzte Lektion
Der letzte Stolperstein kam von außen. Ein Beitragender aus den Niederlanden meldete ein Problem in seiner Produktionsumgebung: echte Chrome-Nutzer wurden ausgeloggt, obwohl alles korrekt lief. Die Ursache war eine abgelaufene Challenge. Seine Challenge-TTL betrug 900 Sekunden, der präsentierte Wert war 967 Sekunden alt. Der erste Refresh schlug also erwartungsgemäß fehl – ein benigner, wiederholbarer Zustand. Die Frage war: Wie behandelt der Server diesen Fehler?
Die Antwort in vielen Implementierungen: als Sicherheitsverstoß. Der Server sieht eine ungültige Challenge, nimmt an, ein Angreifer versuche die Sitzung zu kapern, und beendet die Sitzung, statt einfach eine frische Challenge auszustellen. Dabei ist eine abgelaufene Challenge völlig normal, denn der Browser hat sie zwischengespeichert und verwendet sie, bis etwas Neues kommt. Ein guter Server erkennt das, verwirft die alte Challenge, erzeugt eine neue und sendet sie an den Browser. Erst wenn der Browser nach erneutem Versuch immer noch eine falsche Signatur liefert, ist ein böswilliger Angriff plausibel.
Die Lektion: Verwechsle nicht jeden Fehlversuch mit einem Angriff. Die Fehlerbehandlung muss zwischen einem abgelaufenen, aber legitimem Wert und einer echten Kopie unterscheiden. Das erfordert ein Verständnis für die Lebenszyklen aller beteiligten Komponenten – Cookie, Challenge, Sitzung. Erst wenn du sie als funktionierende Einheit betrachtest, kannst du robuste Sicherheitslogik schreiben.
Die Erfahrungen aus der Produktion von Report URI zeigen: DBSC ist eine mächtige Verbesserung gegenüber reinen Bearer-Token, aber seine Einführung ist kein Plug-and-play. Die Spezifikation beschreibt die Architektur, nicht die Fallstricke. Wer die Stolpersteine kennt – die Race Conditions, die endlosen 302-Fallen, die Challenge-Missverständnisse – kann sie umgehen. Wer sie ignoriert, lernt sie im Laufe der Zeit kennen. Und in der Welt der Sicherheit ist es immer besser, aus fremden Fehlern zu lernen als aus eigenen.
Quelle: scotthelme.co.uk
