← All posts

Wir haben MFA vorgeschrieben. Ein Query-Parameter hat sie abgeschaltet.

Authagonal·July 14, 2026
securitymfaoidcwar-story

Wir haben Multi-Faktor-Authentifizierung ausgeliefert. Nach dem Passwort fragte die Login-Seite einen zweiten Faktor ab, so wie man es erwarten würde. Wir haben es getestet, es funktionierte, wir sind weitergezogen. Dann, bei einem Pre-Launch-Durchgang über unseren eigenen Auth-Server, stellten wir fest, dass man das Ganze überspringen konnte, indem man eine URL editiert.

So sieht das aus. In einem OIDC-Flow schickt die Anwendung den Benutzer zu /connect/authorize. Ist er noch nicht angemeldet, leitet der Server ihn zur Login-Seite um, mit dem ursprünglichen Ziel in einem Parameter verstaut: /login?returnUrl=/connect/authorize?.... Man gibt sein Passwort ein, besteht die MFA, und der Login-Flow schickt einen zurück zu dieser returnUrl, woraufhin /connect/authorize einen Authorization Code ausstellt und die Tokens folgen.

Der Bug steckte in der Reihenfolge der Schritte. Der Passwort-Schritt meldete einen an. Sprich: Er setzte ein authentifiziertes Cookie. MFA war die nächste Seite in der Sequenz. Und /connect/authorize, der Endpoint, der tatsächlich Tokens herausgibt, stellte vorher genau eine Frage: Ist dieser Request authentifiziert? Ob die Session einen zweiten Faktor bestanden hatte, fragte er nie. "Authentifiziert" und "mit MFA authentifiziert" waren dasselbe Cookie.

Der Bypass ist also nichts Raffiniertes. Man schickt sein Passwort ab. Man bekommt das authentifizierte Cookie. Und dann geht man, statt dem Flow zum MFA-Prompt zu folgen, selbst direkt zur returnUrl, /connect/authorize?..., der Adresse, die der Server einem ganz am Anfang mitgegeben hat. Authorize sieht einen authentifizierten Principal, führt seinen einen Check aus und stellt den Code aus. Der zweite Faktor findet nie statt. Der MFA-Prompt war ein Schritt in einem Flur, und nichts hat einen daran gehindert, einfach außen herum zu laufen.

Dass so etwas Tests überlebt, liegt daran, dass der Happy Path wasserdicht ist. Wer sich wie ein Mensch durch die Oberfläche klickt, wird jedes Mal abgefragt. Das Gate ist echt. Es steht nur an der falschen Stelle. Die Sicherheitsgrenze in einem OIDC-Server ist nicht der Login-Screen. Sie ist /connect/authorize, der Punkt, an dem aus einer Session Tokens werden. Jede Anforderung, die nur in den Seiten vor dieser Grenze lebt, ist unverbindlich, denn wer den zugrunde liegenden Request stellen kann, kann ihn auch direkt stellen.

Diese Umdeutung ist der Fix. MFA ist kein Schritt, den man durchführt. Sie ist eine Eigenschaft, die die Session entweder hat oder nicht. Also haben wir aufgehört, sie als Seite zu modellieren, und angefangen, sie als Claim zu modellieren. Wer den zweiten Faktor besteht, dem schreibt der Login-Flow einen mfa_authenticated-Marker ins Auth-Cookie. /connect/authorize akzeptiert nicht mehr "authentifiziert". Er verlangt "authentifiziert und mit diesem Marker unterwegs". Ein Nur-Passwort-Cookie ist am Token-Endpoint jetzt wirkungslos: Es wird zurückgeschickt, um die MFA abzuschließen, egal auf welche URL man es richtet, denn was ihm fehlt, ist ein Claim, kein Seitenbesuch.

Federation ergibt sich daraus ganz sauber. Meldet sich ein Benutzer über einen externen Identity Provider an, der seine eigene MFA durchgeführt hat, bürgt dieser Provider für den zweiten Faktor, also setzt der föderierte Login denselben Marker. SSO-Benutzer werden nicht zweimal für etwas abgefragt, das ihr IdP bereits erzwungen hat. Der Marker ist die einzige Quelle der Wahrheit, und jeder Pfad, der MFA legitim erfüllen kann, schreibt ihn.

Die übertragbare Lektion hat mit MFA im Speziellen nichts zu tun. Sie lautet: Erzwinge eine Kontrolle an der Grenze, die das Schützenswerte tatsächlich herausgibt, nicht an dem Interface-Schritt, der dorthin führen soll. Wir hatten den Check. Wir hatten die MFA-Logik geschrieben, getestet, ausgeliefert. Sie hing nur an dem Teil des Systems, den ein Angreifer gar nicht benutzen muss. Consent-Screens, AGB-Bestätigung, Step-up für sensible Scopes: derselbe Fehlermodus. Wenn der Endpoint, der das Credential ausstellt, die Bedingung nicht prüft, ist der Screen, der danach fragt, ein Vorschlag.

Wir haben diesen Fall erwischt, bevor irgendein Kunde auf ihn traf, im selben Audit, das noch ein paar seiner Geschwister zutage förderte. Das ist das Argument dafür, die eigene Auth so durchzugehen, als würde man sie angreifen, und konkret dafür, an jedem Endpoint, der etwas Wertvolles herausgibt, nicht zu fragen "hat der Benutzer den Flow durchlaufen", sondern "was beweist dieser Request tatsächlich". Unserer bewies ein Passwort. Wir haben ihn als Beweis für zwei Faktoren behandelt.

MFA an dem Endpoint zu erzwingen, der die Tokens prägt, statt auf der Seite, die danach fragt, ist der ganze Unterschied zwischen echter MFA und einem Vorschlag. Authagonal erzwingt sie dort, wo das Credential ausgestellt wird, sodass eine von Hand editierte Return-URL ins Leere läuft.