Medium CVSS 8.1 firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMalicious websites may have been able to perform user intent confirmation through tapjacking. This could have led to users unknowingly approving the launch of external applications, potentially exposing them to underlying vulnerabilities.
ComponentCore
Bug ClassLogic Error
Tracker1836921
Fix commita7a0cfe58556 (firefox) +26/-5
CWECWE-1021
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedShaheen Fazim
Disclosed2024-11-26

Background

Tapjacking
a UI-redress attack where a malicious app or web page overlays or rapidly swaps content so a user’s tap lands on a security-sensitive control (like a confirm button) they did not intend to press.
`SimpleRedirectDialogFragment`
the Android Components dialog shown to confirm launching an external application from an app link, whose positive button invokes onConfirmRedirect().
`PromptAbuserDetector`
a support utility that tracks how quickly dialogs appear and, via areDialogsBeingAbused(), flags confirmations arriving faster than a configured maxSuccessiveDialogSecondsLimit.
`onConfirmRedirect`
the callback that actually proceeds with opening the external application once the user approves the redirect.

Root Cause Analysis

The vulnerable path was in SimpleRedirectDialogFragment.onCreateDialog(), where the positive (“OK”) button was wired directly via setPositiveButton(positiveButtonText) { _, _ -> onConfirmRedirect() }. This meant any tap delivered to the positive button—including one synthesized moments after the dialog rendered by an overlay or a freshly swapped-in view—immediately triggered onConfirmRedirect() with no check on how recently the dialog had appeared. The invariant violated is that a user’s confirmation must reflect deliberate intent, i.e. the dialog must have been visible long enough for the user to actually read and choose it before its confirm action fires.

The fix empties the setPositiveButton lambda and instead attaches an OnClickListener inside a setOnShowListener, gating onConfirmRedirect() behind promptAbuserDetector.areDialogsBeingAbused(). Because PromptAbuserDetector is constructed with maxSuccessiveDialogSecondsLimit = TIME_SHOWN_OFFSET_SECONDS and updateJSDialogAbusedState() is called when the dialog is created, a tap that arrives within the offset window is treated as abuse and rejected instead of confirming the redirect.

Key insight
The core mistake was confirming the external-app launch on any button tap without verifying the dialog had been genuinely visible to the user, allowing a too-fast tap to count as consent; the fix interposes PromptAbuserDetector so confirmations occurring within TIME_SHOWN_OFFSET_SECONDS of the dialog appearing are discarded rather than acted upon.

Attack Path

  1. Trigger the redirect prompt A malicious website initiates an external-application launch, causing SimpleRedirectDialogFragment to display its confirmation dialog.
  2. Overlay the confirm control The attacker uses a tapjacking overlay or a rapidly swapped view so the user believes they are tapping a benign UI element positioned over the dialog’s positive button.
  3. Deliver the crafted tap The user’s tap is routed to the “OK” button almost immediately after the dialog renders, before they can read or comprehend the real prompt.
  4. Immediate confirmation fires In the unpatched code, the positive-button lambda invokes onConfirmRedirect() at once, treating the synthesized tap as deliberate consent.
  5. External application launches The redirect proceeds and the external application is opened without the user’s informed approval, exposing them to whatever that application or scheme enables.

Impact Assessment

An attacker gains the ability to obtain a user’s “confirmation” for launching an external application without the user’s informed consent, effectively bypassing the intent-confirmation dialog through tapjacking. This occurs in the context of Firefox for Android’s app-links UI, in the browser/foreground process rendering SimpleRedirectDialogFragment. Preconditions are that the user can be induced to tap a controlled overlay (or otherwise deliver a tap) within the brief window immediately after the confirmation dialog appears; the rated impact is moderate.

Changed Functions

FunctionChangeNotes
SimpleRedirectDialogFragment
mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragment.kt
modified

Files Changed

  • mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragment.kt
  • mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt

Audit Directions

  • Immediate-action confirm buttons
    Search other dialog fragments and prompts where a positive/confirm button directly invokes a sensitive callback (external launch, permission grant, download) without any PromptAbuserDetector or minimum-visible-time gate.
  • Missing tapjacking defenses
    Review security-relevant UI that lacks filterTouchesWhenObscured or equivalent overlay protection, since dialogs confirming intent are prime tapjacking targets.
  • Uniform `PromptAbuserDetector` coverage
    Audit all consumers of PromptAbuserDetector to confirm they consistently call updateJSDialogAbusedState() on show and check areDialogsBeingAbused() before acting, so protection is not applied to only a subset of confirmation flows.
diff --git a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragment.kt b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragment.kt
index 793c8c2ef68..5e56d06bca6 100644
--- a/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragment.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/main/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragment.kt
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
 import androidx.annotation.StyleRes
 import androidx.annotation.VisibleForTesting
 import androidx.appcompat.app.AlertDialog
+import mozilla.components.support.ktx.util.PromptAbuserDetector
 import mozilla.components.ui.widgets.withCenterAlignedButtons
 
 /**
@@ -23,6 +24,10 @@ import mozilla.components.ui.widgets.withCenterAlignedButtons
  */
 class SimpleRedirectDialogFragment : RedirectDialogFragment() {
 
+    @VisibleForTesting
+    internal var promptAbuserDetector =
+        PromptAbuserDetector(maxSuccessiveDialogSecondsLimit = TIME_SHOWN_OFFSET_SECONDS)
+
     @VisibleForTesting
     internal var testingContext: Context? = null
 
@@ -32,6 +37,8 @@ class SimpleRedirectDialogFragment : RedirectDialogFragment() {
             return if (themeID == 0) AlertDialog.Builder(context) else AlertDialog.Builder(context, themeID)
         }
 
+        promptAbuserDetector.updateJSDialogAbusedState()
+
         return with(requireBundle()) {
             val dialogTitleText = getInt(KEY_TITLE_TEXT, R.string.mozac_feature_applinks_normal_confirm_dialog_title)
             val dialogMessageString = getString(KEY_MESSAGE_STRING, "")
@@ -40,18 +47,29 @@ class SimpleRedirectDialogFragment : RedirectDialogFragment() {
             val themeResId = getInt(KEY_THEME_ID, 0)
             val cancelable = getBoolean(KEY_CANCELABLE, false)
 
-            getBuilder(themeResId)
+            val dialog = getBuilder(themeResId)
                 .setTitle(dialogTitleText)
                 .setMessage(dialogMessageString)
-                .setPositiveButton(positiveButtonText) { _, _ ->
-                    onConfirmRedirect()
-                }
+                .setPositiveButton(positiveButtonText) { _, _ -> }
                 .setNegativeButton(negativeButtonText) { _, _ ->
                     onCancelRedirect()
                 }
                 .setCancelable(cancelable)
                 .create()
-                .withCenterAlignedButtons()
+
+            dialog.withCenterAlignedButtons()
+            dialog.setOnShowListener {
+                val okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
+                okButton.setOnClickListener {
+                    if (promptAbuserDetector.areDialogsBeingAbused()) {
+                        promptAbuserDetector.updateJSDialogAbusedState()
+                    } else {
+                        onConfirmRedirect()
+                        dialog.dismiss()
+                    }
+                }
+            }
+            dialog
         }
     }
 
@@ -101,6 +119,7 @@ class SimpleRedirectDialogFragment : RedirectDialogFragment() {
         const val KEY_THEME_ID = "KEY_THEME_ID"
 
         const val KEY_CANCELABLE = "KEY_CANCELABLE"
+        private const val TIME_SHOWN_OFFSET_SECONDS = 1
     }
 
     private fun requireBundle(): Bundle {
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
index 268e4df4d99..49ad38c71e5 100644
--- a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
@@ -13,6 +13,7 @@ import mozilla.components.support.test.mock
 import mozilla.components.support.test.robolectric.testContext
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
+import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mockito.doNothing
@@ -27,6 +28,7 @@ class SimpleRedirectDialogFragmentTest {
     private val themeResId = appcompatR.style.Theme_AppCompat_Light
 
     @Test
+    @Ignore
     fun `Dialog confirmed callback is called correctly`() {
         var onConfirmCalled = false
         var onCancelCalled = false
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
index 268e4df4d99..49ad38c71e5 100644
--- a/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
+++ b/mobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
@@ -13,6 +13,7 @@ import mozilla.components.support.test.mock
 import mozilla.components.support.test.robolectric.testContext
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
+import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mockito.doNothing
@@ -27,6 +28,7 @@ class SimpleRedirectDialogFragmentTest {
     private val themeResId = appcompatR.style.Theme_AppCompat_Light
 
     @Test
+    @Ignore
     fun `Dialog confirmed callback is called correctly`() {
         var onConfirmCalled = false
         var onCancelCalled = false
Loading diff…