In the digital age, the escalation of phishing attacks has pushed the envelope on the development of more robust security measures. Multi-factor authentication (MFA) has stood out as a frontline defense, promising an added layer of security by requiring multiple forms of verification.
During a red team engagement, we discovered that the customer was using Okta FastPass, a phishing-resistant MFA solution as Okta claims. FastPass uses a client-side application, Okta Verify, to perform all the required checks and authenticate the user based on various factors such as biometrics or Windows Hello.
What makes it phishing resistant you might wonder. Let's dive into it.
How Okta Verify works
Okta describes the architecture in their FastPass technical whitepaper. There are 5 basic flows of how the backend, the web app and Okta Verify all talk to each other.
Loopback (Desktop)
Custom URL (Desktop)
Credential SSO Extension (iOS and MacOS)
Universal Link (iOS)
App Link (Android)
In this blog post we will be talking about the first two flows, Loopback and Custom URL, focusing only on Windows and MacOS.
Loopback flow
When Okta requires a user to authenticate via Okta Verify, either when using FastPass or if it is required by security policies, Loopback is the default flow. In this case, the web app then tries to interact with Okta Verify by probing localhost on various ports via JavaScript:
If Okta Verify responds with HTTP 200 OK, then the web app sends another request to http://localhost:<port>/challenge with a challenge request, in the form of JWT token, in its body:
Okta Verify then prompts the user to authorize the sign in, and the above JWT is subsequently signed with the user's private key (TPM). Subsequently, it submits the signed challenge back to Okta server and returns the result to the user's browser.
Custom URL flow
When the Loopback flow fails for any reason (e.g. when Okta Verify is not running), then the challenge request can be passed to Okta Verify via a URI scheme which will essentially force the app to launch. The resulting URL will be like the following:
com-okta-authenticator:/deviceChallenge?challengeRequest=eyJraWQiOiJ1eHQ2RFVXbmNtdDlmQXk4NmFWOyFibFRPNXpoWkpNYm1QWFl2...SNIP...
The remaining steps are identical to the Loopback flow.
Phishing-resistant (?)
Now what happens when you try to phish a user from a non okta.com domain? Okta Verify will warn that this is probably a phishing attempt.
How does it know? In the Loopback flow, when probing localhost, note the HTTP Origin header. This is inserted by the browser and cannot be influenced by the user. The Origin will be validated by Okta Verify, and used during the challenge request flow. If it is not one of the authorized domain, such as okta.com, the backend will reject the authentication attempt. Simple, yet effective.
Quoting the Okta's whitepaper:
The loopback server will be able to validate the origin header and detect any mismatches. There is no way for an attacker to programmatically change the origin headers in JavaScript, which makes this feature phishing resistant. When this is detected, authentication fails, the event is logged in SysLog, and the user is shown a suspicious activity page.
So everything seems to rely on the HTTP Origin header.
That should not be surprising, especially considering the robustness of the HTTP Origin header in mitigating phishing attacks. Its efficacy is underscored by its adoption in key industry standards, such as those established by the FIDO Alliance. The FIDO specifications outline the use of HTTP Origin headers as a foundational measure against Man-in-the-Middle (MiTM) phishing attacks. By binding user logins to the origin, the authentication process ensures that only the legitimate site can successfully authenticate with the security key. Even if a user is deceived into interacting with a fake site, the authentication will invariably fail due to the disparity in origins.
But where is the Origin header defined in the Custom URL flow? This is not an AJAX request, but simply launching an application through a custom URI.
Indeed, in the Custom URL flow, the determination of Origin header becomes more nuanced due to the nature of the protocol involved. When launching an application through a custom URI, in this case using the com-okta-authenticator schema, the traditional mechanism of relying on the HTTP Origin header falls short.
Transitioning from Loopback to Custom URI flow
Once understood the weakest challenge method, circumventing anti-phishing protections could be as simple as redirecting the application from the Loopback to the Custom URI challenge method.
From a technical perspective, the requests share significant similarities, and the JWT sent in the "challengeRequest" field remains consistent.
Loopback requests are sent via POST, while Custom URI requests, in both cases the same JWT token is provided into the URI. This implies that mounting the attack merely required creating a straightforward string match and replace in our Muraena proxy:
customContent = [
[
"\"challengeMethod\":\"LOOPBACK\",\"challengeRequest\":\"", "\"challengeMethod\":\"CUSTOM_URI\",\"href\":\"com-okta-authenticator:/deviceChallenge?challengeRequest="
]
]
This instructed the web application to use only the Custom URI flow instead of LOOPBACK and that was enough to bypass any anti-phishing protection.
Furthermore, it's worth noting that in the Okta Verify interface, the displayed domain during authentication remains the original one (oktapreview.com in this case) rather than the phishing domain: phishing.click. This behavior stems from the fact that Okta Verify, lacking any Origin header to analyze, retrieves the content directly from the JWT token.
The following video demonstrates the entire attack flow.
These red team exercises and techniques, can/should be used in conjunction with scenario based, adversary simulation, performed on an automated and scheduled basis. For more information on how we can help you stay secure, please see our Nemesis products.
Timeline
March 2024
Bug discovered during a red teaming engagement
4th April 2024
Initial contact with Okta and disclosure of technical details
5th April 2024
Response from Okta and acknowledgment of issue
16th April 2024
Okta confirmed the bug fix
6th May 2024
Public disclosure
Comments