CVE-2024-11703
Overview
Background
- `BiometricAuthenticationManager`
- A Fenix singleton
objectthat holds the sharedBiometricAuthenticationNeededInfostate gating whether saved-login screens require device authentication. - `BiometricAuthenticationNeededInfo`
- A
data classwhose flag drives whether the biometric/PIN prompt is shown before revealing secure content such as saved passwords. - `setSecureContentVisibility`
- A per-fragment method that shows or hides the sensitive login fields depending on the boolean passed to it.
- `onResume`
- An Android lifecycle callback invoked whenever a fragment returns to the foreground, where Fenix decides whether re-authentication is needed.
Root Cause Analysis
Before the fix, EditLoginFragment and LoginDetailFragment tracked authentication state with a single shouldAuthenticate boolean inside BiometricAuthenticationNeededInfo. When onResume found shouldAuthenticate to be false, it unconditionally called setSecureContentVisibility(true), revealing saved passwords under the assumption that a false value meant the user had already authenticated. This conflated two distinct states — “no prompt needs to be shown” and “the user has successfully authenticated” — so any code path that cleared shouldAuthenticate to false (for example onDestroyView, or a returning activity result that was cancelled) would leave the app in a state where the else branch of onResume exposed credentials without a PIN check. The invariant that secure content must only be visible after a successful authentication was therefore violated.
The fix introduces a separate authenticated boolean that is set to true only inside the onAuthSuccess callback, and rewrites the else branch to call setSecureContentVisibility(BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated), so visibility now tracks proven authentication rather than the ambiguous “no prompt needed” flag.
shouldAuthenticate) to mean both “don’t show the prompt” and “the user is authenticated,” letting an un-authenticated fragment fall into the else branch and reveal passwords; the fix separates these into shouldShowAuthenticationPrompt and authenticated so content is shown only when authentication was actually proven.Attack Path
- Open a saved login
The user navigates to
LoginDetailFragmentorEditLoginFragmentand successfully passes the biometric/PIN prompt, which setsshouldAuthenticatetofalse. - Leave and return to the app
The fragment is backgrounded and its view is destroyed, or an activity result returns, causing a handler such as
onDestroyVieworregisterForActivityResultto setshouldAuthenticatetofalsewithout any authentication having occurred. - Trigger `onResume` in the false branch
When the fragment resumes,
onResumeobservesshouldAuthenticate == falseand takes theelsebranch. - Reveal passwords without a PIN
The
elsebranch callssetSecureContentVisibility(true)unconditionally, displaying the saved password without ever prompting for device authentication.
Impact Assessment
false without a successful prompt, such as returning to the app after the fragment view was destroyed. The impact is rated moderate because it requires local device access rather than remote exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
VisibilityLifecycleObservermobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt |
modified | |
ifmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt |
modified | |
ifmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt |
modified | |
ifmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt |
modified |
Files Changed
mobile/android/fenix/app/src/main/java/org/mozilla/fenix/BiometricAuthenticationManager.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt
Audit Directions
- Overloaded boolean stateLook for single flags used to mean both “no action needed” and “action succeeded,” especially where a
false/default value is later treated as proof of a completed security check. - Lifecycle-driven flag resetsAudit
onPause,onResume,onDestroyView, and activity-result callbacks that mutate shared authentication state, ensuring teardown or cancellation never leaves a state interpreted as authenticated. - Default-value visibility gatingReview any
setSecureContentVisibility-style call that reveals sensitive data in anelseor default branch without explicitly confirming a successful authentication result.
Patch
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/BiometricAuthenticationManager.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/BiometricAuthenticationManager.kt
index 94ca4218989..88212360b42 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/BiometricAuthenticationManager.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/BiometricAuthenticationManager.kt
@@ -12,9 +12,11 @@ object BiometricAuthenticationManager {
}
/**
- * Data class containing the boolean that dictates the need of displaying the
- * biometric authentication prompt
+ * Data class containing a boolean that dictates the need of displaying the
+ * biometric authentication prompt and another boolean about the biometric authentication
+ * being successful or not
*/
data class BiometricAuthenticationNeededInfo(
- var shouldAuthenticate: Boolean = true,
+ var shouldShowAuthenticationPrompt: Boolean = true,
+ var authenticated: Boolean = false,
)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt
index 99522b207e3..08962ba5a85 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt
@@ -15,6 +15,8 @@ import org.mozilla.fenix.BiometricAuthenticationManager
class VisibilityLifecycleObserver : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate = true
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
+ true
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = false
}
}
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt
index 334377ead15..90de5de9db9 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt
@@ -382,7 +382,8 @@ class AddLoginFragment : Fragment(R.layout.fragment_add_login), MenuProvider {
override fun onDestroyView() {
super.onDestroyView()
_binding = null
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate = false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
+ false
}
companion object {
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt
index 807d46e25a5..132e86dae3c 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt
@@ -72,8 +72,9 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login), MenuProvider {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startForResult = registerForActivityResult {
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate =
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = true
setSecureContentVisibility(true)
}
}
@@ -330,17 +331,23 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login), MenuProvider {
override fun onResume() {
super.onResume()
- if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate) {
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate =
+ if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) {
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = false
setSecureContentVisibility(false)
+
bindBiometricsCredentialsPromptOrShowWarning(
view = requireView(),
onShowPinVerification = { intent -> startForResult.launch(intent) },
- onAuthSuccess = { setSecureContentVisibility(true) },
+ onAuthSuccess = {
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated =
+ true
+ setSecureContentVisibility(true)
+ },
)
} else {
- setSecureContentVisibility(true)
+ setSecureContentVisibility(BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated)
}
}
@@ -362,7 +369,7 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login), MenuProvider {
override fun onDestroyView() {
super.onDestroyView()
_binding = null
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate = false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt = false
}
private fun setSecureContentVisibility(isVisible: Boolean) {
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt
index 1cc3cf96ad4..2e85971acf8 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt
@@ -82,8 +82,9 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu
_binding = FragmentLoginDetailBinding.bind(view)
startForResult = registerForActivityResult {
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate =
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = true
setSecureContentVisibility(true)
}
@@ -139,17 +140,23 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu
override fun onResume() {
super.onResume()
- if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate) {
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate =
+ if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) {
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = false
setSecureContentVisibility(false)
+
bindBiometricsCredentialsPromptOrShowWarning(
view = requireView(),
onShowPinVerification = { intent -> startForResult.launch(intent) },
- onAuthSuccess = { setSecureContentVisibility(true) },
+ onAuthSuccess = {
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated =
+ true
+ setSecureContentVisibility(true)
+ },
)
} else {
- setSecureContentVisibility(true)
+ setSecureContentVisibility(BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated)
}
}
@@ -274,7 +281,7 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu
override fun onDestroyView() {
super.onDestroyView()
_binding = null
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate = false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt = false
}
private fun setSecureContentVisibility(isVisible: Boolean) {
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt
index 7b506d052d2..20d9564084c 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt
@@ -78,24 +78,32 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startForResult = registerForActivityResult {
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate =
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = true
setSecureContentVisibility(true)
}
}
override fun onResume() {
super.onResume()
- if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate) {
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate = false
+ if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) {
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
+ false
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = false
setSecureContentVisibility(false)
+
bindBiometricsCredentialsPromptOrShowWarning(
view = requireView(),
onShowPinVerification = { intent -> startForResult.launch(intent) },
- onAuthSuccess = { setSecureContentVisibility(true) },
+ onAuthSuccess = {
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated =
+ true
+ setSecureContentVisibility(true)
+ },
)
} else {
- setSecureContentVisibility(true)
+ setSecureContentVisibility(BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated)
}
initToolbar()
}
@@ -109,7 +117,8 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
_binding = FragmentSavedLoginsBinding.bind(view)
setSecureContentVisibility(false)
- BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldAuthenticate = true
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt = true
+ BiometricAuthenticationManager.biometricAuthenticationNeededInfo.authenticated = false
savedLoginsStore =
StoreProvider.get(findNavController().getBackStackEntry(R.id.savedLogins)) {