CVE-2024-9956
Overview
Background
- `fido:` URI scheme
- a URI scheme used by the WebAuthn “hybrid” (caBLE) transport that, when handed to the Android OS, invokes the platform passkey flow to authenticate over Bluetooth to a nearby device.
- App links / external intents
- Android’s mechanism where a browser hands a URL it does not handle itself (a non-web scheme) to another installed app or the OS via an
Intent, rather than loading it as a page. - `BLOCKED_SCHEMES`
- the allowlist-inverse in
GeckoEngineSessionthat lists URI schemes the engine must refuse to load directly. - `ALWAYS_DENY_SCHEMES`
- the set in
AppLinksUseCasesenumerating schemes that must never be treated as an external app link and dispatched to the OS.
Root Cause Analysis
On Firefox for Android, a navigation to a URL whose scheme the Gecko engine does not render is routed through the app-links machinery, which resolves an external Intent and hands the URI to the operating system. The fido: scheme was absent from every guard on this path: it was not in GeckoEngineSession.BLOCKED_SCHEMES, not in AppLinksUseCases.ALWAYS_DENY_SCHEMES, and IntentUtils.isUriSafeForScheme returned true for it. Because a scriptable page could trigger navigation to an arbitrary fido: URI, a malicious site could cause the browser to pass a fido: link to the OS, launching the platform “hybrid” passkey transport without a genuine, user-initiated WebAuthn ceremony. The invariant violated is that sensitive OS-level schemes tied to credential/authenticator flows must not be reachable from untrusted web content.
The fix adds "fido" to both scheme deny-lists and makes isUriSafeForScheme explicitly return false for fido:, so the engine neither loads the URI nor forwards it as an external intent.
fido: scheme from the browser’s scheme deny-lists, leaving a web-reachable path that forwarded it to the OS; the fix closes that path by adding fido to BLOCKED_SCHEMES and ALWAYS_DENY_SCHEMES and rejecting it in isUriSafeForScheme.Attack Path
- Lure the victim An attacker gets the target to open a malicious page in Firefox for Android while the attacker’s machine sits within Bluetooth range.
- Trigger a `fido:` navigation
The page navigates to a crafted
fido:URI; Gecko does not render this scheme, so it falls through to the external app-link resolver. - Escape to the OS
Because
fidois missing fromBLOCKED_SCHEMES/ALWAYS_DENY_SCHEMESandisUriSafeForSchemereturnstrue, the URI is packaged as anIntentand handed to Android. - Invoke the hybrid transport The OS launches the “hybrid”/caBLE passkey flow, prompting the user to authenticate with their passkey.
- Hijack the ceremony The attacker’s nearby computer is the caBLE peer, so the victim’s passkey authenticates the attacker’s login session to the target account.
Impact Assessment
Files Changed
mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.ktmobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.ktmobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.ktmobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.ktmobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.javamobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
Audit Directions
- Scheme deny-list completenessCross-check every scheme guard (
BLOCKED_SCHEMES,ALWAYS_DENY_SCHEMES,isUriSafeForScheme) against the full set of OS/authenticator schemes to find any privileged scheme still reachable from web content. - Web-reachable OS handoff pathsTrace every code path where an unhandled navigation falls through to
Intentresolution and confirm each is gated so untrusted pages cannot silently launch OS-level flows. - Case and normalization gapsVerify scheme comparisons are case-insensitive and normalized so variants like
FIDO:or padded/encoded forms cannot bypass the deny-lists.
Patch
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
index 0980c997796..2e2898ffb9e 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
@@ -1553,7 +1553,7 @@ class GeckoEngineSession(
internal const val ABOUT_BLANK = "about:blank"
internal const val JS_SCHEME = "javascript"
internal val BLOCKED_SCHEMES =
- listOf("file", "resource", JS_SCHEME) // See 1684761 and 1684947
+ listOf("file", "resource", "fido", JS_SCHEME) // See 1684761 and 1684947
/**
* Provides an ErrorType corresponding to the error code provided.
diff --git a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
index be9c48c9d7c..5fc4e22ba5f 100644
--- a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/AppLinksUseCases.kt
@@ -325,6 +325,7 @@ class AppLinksUseCases(
"https", "moz-extension", "moz-safe-about", "resource", "view-source", "ws", "wss", "blob",
)
- internal val ALWAYS_DENY_SCHEMES: Set<String> = setOf("jar", "file", "javascript", "data", "about", "content")
+ internal val ALWAYS_DENY_SCHEMES: Set<String> =
+ setOf("jar", "file", "javascript", "data", "about", "content", "fido")
}
}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
index 36fde18a02e..ff582dfe33b 100644
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
@@ -76,6 +76,10 @@ public class IntentUtils {
return getSafeIntent(aUri) != null;
}
+ if ("fido".equals(scheme)) {
+ return false;
+ }
+
return true;
}
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
index f5b121169d4..ef7087a4cdd 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
@@ -637,6 +637,11 @@ class GeckoEngineSessionTest {
engineSession.loadUrl("RESOURCE://package/test.text")
verify(geckoSession, never()).load(GeckoSession.Loader().uri("resource://package/test.text"))
verify(geckoSession, never()).load(GeckoSession.Loader().uri("RESOURCE://package/test.text"))
+
+ engineSession.loadUrl("fido:/12345678")
+ engineSession.loadUrl("FIDO:/12345678")
+ verify(geckoSession, never()).load(GeckoSession.Loader().uri("fido:/12345678"))
+ verify(geckoSession, never()).load(GeckoSession.Loader().uri("FIDO:/12345678"))
}
@Test
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
index a03057e069f..a43351739d7 100644
--- a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
@@ -47,6 +47,7 @@ class AppLinksUseCasesTest {
private val javascriptUrl = "javascript:'hello, world'"
private val jarUrl = "jar:file://some/path/test.html"
private val contentUrl = "content://media/external_primary/downloads/12345"
+ private val fidoPath = "fido:12345678"
private val fileType = "audio/mpeg"
private val layerUrl = "https://example.com"
private val layerPackage = "com.example.app"
@@ -217,6 +218,15 @@ class AppLinksUseCasesTest {
assertFalse(redirect.isRedirect())
}
+ @Test
+ fun `A fido url is not an app link`() {
+ val context = createContext(Triple(fidoPath, appPackage, ""))
+ val subject = AppLinksUseCases(context, { true })
+
+ val redirect = subject.interceptedAppLinkRedirect(fidoPath)
+ assertFalse(redirect.isRedirect())
+ }
+
@Test
fun `Will not redirect app link if browser option set to false and scheme is supported`() {
val context = createContext(Triple(appUrl, appPackage, ""))
diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
index 24315ff585c..6f9d0b405e1 100644
--- a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
+++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
@@ -63,4 +63,10 @@ public class IntentUtilsTest {
final String uri = "intent:non_scheme_intent#Intent;end";
assertTrue(IntentUtils.isUriSafeForScheme(uri));
}
+
+ @Test
+ public void unsafeFidoUri() {
+ final String uri = "fido:/12345678";
+ assertFalse(IntentUtils.isUriSafeForScheme(uri));
+ }
}
Regression Test / PoC
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
index f5b121169d4..ef7087a4cdd 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt
@@ -637,6 +637,11 @@ class GeckoEngineSessionTest {
engineSession.loadUrl("RESOURCE://package/test.text")
verify(geckoSession, never()).load(GeckoSession.Loader().uri("resource://package/test.text"))
verify(geckoSession, never()).load(GeckoSession.Loader().uri("RESOURCE://package/test.text"))
+
+ engineSession.loadUrl("fido:/12345678")
+ engineSession.loadUrl("FIDO:/12345678")
+ verify(geckoSession, never()).load(GeckoSession.Loader().uri("fido:/12345678"))
+ verify(geckoSession, never()).load(GeckoSession.Loader().uri("FIDO:/12345678"))
}
@Test
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
index a03057e069f..a43351739d7 100644
--- a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/AppLinksUseCasesTest.kt
@@ -47,6 +47,7 @@ class AppLinksUseCasesTest {
private val javascriptUrl = "javascript:'hello, world'"
private val jarUrl = "jar:file://some/path/test.html"
private val contentUrl = "content://media/external_primary/downloads/12345"
+ private val fidoPath = "fido:12345678"
private val fileType = "audio/mpeg"
private val layerUrl = "https://example.com"
private val layerPackage = "com.example.app"
@@ -217,6 +218,15 @@ class AppLinksUseCasesTest {
assertFalse(redirect.isRedirect())
}
+ @Test
+ fun `A fido url is not an app link`() {
+ val context = createContext(Triple(fidoPath, appPackage, ""))
+ val subject = AppLinksUseCases(context, { true })
+
+ val redirect = subject.interceptedAppLinkRedirect(fidoPath)
+ assertFalse(redirect.isRedirect())
+ }
+
@Test
fun `Will not redirect app link if browser option set to false and scheme is supported`() {
val context = createContext(Triple(appUrl, appPackage, ""))
diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
index 24315ff585c..6f9d0b405e1 100644
--- a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
+++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java
@@ -63,4 +63,10 @@ public class IntentUtilsTest {
final String uri = "intent:non_scheme_intent#Intent;end";
assertTrue(IntentUtils.isUriSafeForScheme(uri));
}
+
+ @Test
+ public void unsafeFidoUri() {
+ final String uri = "fido:/12345678";
+ assertFalse(IntentUtils.isUriSafeForScheme(uri));
+ }
}