Medium CVSS 5.7 firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
5.7
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionOn Android, Firefox may have inadvertently allowed viewing saved passwords without the required device PIN authentication.
ComponentCore
Bug ClassLogic Error
Tracker1928779
Fix commit24e70ab57d0e (firefox) +51/-23
CWECWE-522
CVSS vectorCVSS:3.1/AV:P/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedMultiple Reporters
Disclosed2024-11-26

Background

`BiometricAuthenticationManager`
A Fenix singleton object that holds the shared BiometricAuthenticationNeededInfo state gating whether saved-login screens require device authentication.
`BiometricAuthenticationNeededInfo`
A data class whose 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.

Key insight
The single mistake was overloading one boolean (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

  1. Open a saved login The user navigates to LoginDetailFragment or EditLoginFragment and successfully passes the biometric/PIN prompt, which sets shouldAuthenticate to false.
  2. 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 onDestroyView or registerForActivityResult to set shouldAuthenticate to false without any authentication having occurred.
  3. Trigger `onResume` in the false branch When the fragment resumes, onResume observes shouldAuthenticate == false and takes the else branch.
  4. Reveal passwords without a PIN The else branch calls setSecureContentVisibility(true) unconditionally, displaying the saved password without ever prompting for device authentication.

Impact Assessment

An attacker with brief physical access to an unlocked device can view stored credentials on the login-detail and edit-login screens without satisfying the required device PIN or biometric check. The exposure occurs in the Fenix (Firefox for Android) UI process on the saved-logins settings screens, and the precondition is reaching one of the code paths that clears the authentication flag to 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

FunctionChangeNotes
VisibilityLifecycleObserver
mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt
modified
if
mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt
modified
if
mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt
modified
if
mobile/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.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/VisibilityLifecycleObserver.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt
  • mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt

Audit Directions

  • Overloaded boolean state
    Look 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 resets
    Audit 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 gating
    Review any setSecureContentVisibility-style call that reveals sensitive data in an else or default branch without explicitly confirming a successful authentication result.
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)) {
Loading diff…