CVE-2024-11700
Overview
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 configuredmaxSuccessiveDialogSecondsLimit. - `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.
PromptAbuserDetector so confirmations occurring within TIME_SHOWN_OFFSET_SECONDS of the dialog appearing are discarded rather than acted upon.Attack Path
- Trigger the redirect prompt
A malicious website initiates an external-application launch, causing
SimpleRedirectDialogFragmentto display its confirmation dialog. - 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.
- 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.
- Immediate confirmation fires
In the unpatched code, the positive-button lambda invokes
onConfirmRedirect()at once, treating the synthesized tap as deliberate consent. - 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
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
| Function | Change | Notes |
|---|---|---|
SimpleRedirectDialogFragmentmobile/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.ktmobile/android/android-components/components/feature/app-links/src/test/java/mozilla/components/feature/app/links/SimpleRedirectDialogFragmentTest.kt
Audit Directions
- Immediate-action confirm buttonsSearch other dialog fragments and prompts where a positive/confirm button directly invokes a sensitive callback (external launch, permission grant, download) without any
PromptAbuserDetectoror minimum-visible-time gate. - Missing tapjacking defensesReview security-relevant UI that lacks
filterTouchesWhenObscuredor equivalent overlay protection, since dialogs confirming intent are prime tapjacking targets. - Uniform `PromptAbuserDetector` coverageAudit all consumers of
PromptAbuserDetectorto confirm they consistently callupdateJSDialogAbusedState()on show and checkareDialogsBeingAbused()before acting, so protection is not applied to only a subset of confirmation flows.
Patch
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
Regression Test / PoC
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