CVE-2025-1939
Overview
Background
- Custom Tabs
- An Android feature that lets a third-party app open web content inside a host browser’s UI. In Firefox for Android (Fenix) this is served by
ExternalAppBrowserActivity, a subclass ofHomeActivity. - `dispatchTouchEvent`
- The Android
Activityentry point through which every touch is routed before it reaches a view. Returningtrueconsumes the event so no child view acts on it. - Enter animation
- When an activity launches it plays a transition animation.
onEnterAnimationComplete()is the system callback fired once that animation has finished and the window is fully, stably on screen. - Tapjacking / UI redress
- Delivering taps to UI that is still moving, partially transparent, or mid-transition, so the user believes they are touching something other than what actually receives the tap (e.g. a permission grant).
Root Cause Analysis
The fix adds a touch gate to ExternalAppBrowserActivity (mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt). Before the patch, the Custom Tab activity inherited HomeActivity.dispatchTouchEvent, which processed touches immediately — including during the activity’s launch/enter transition animation. Because a launching Custom Tab can be animated in (slide/fade), a malicious calling app controlling the transition could present attacker-chosen pixels while the real, touch-sensitive UI (for example a permission or sensitive-action control) was already live underneath, so a tap landed on the browser control the user could not clearly see. The invariant being violated is “do not deliver touches to the Custom Tab UI until the window is visually settled.”
The patch introduces a var isFinishedAnimating = false flag and overrides dispatchTouchEvent: while !isFinishedAnimating it returns true, swallowing every touch. The flag is flipped to true only in the newly overridden onEnterAnimationComplete(). To make this override possible, HomeActivity.dispatchTouchEvent was changed from final override to plain override (HomeActivity.kt) so the subclass can extend it. Net effect: no touch is honored until the enter animation has genuinely completed, closing the window in which transition animations could be used to hide what the user is clicking.
onEnterAnimationComplete().Attack Path
- Malicious Android app Installs on the device and invokes Firefox’s Custom Tabs to load a page, controlling the launch/transition animation parameters.
- Animation cover During the enter animation the Custom Tab UI is already interactive, but the transition presents misleading pixels over the real controls (inferred: exact overlaid control depends on what sensitive prompt is reachable in that window).
- User tap The user, believing they are tapping benign content, taps where a sensitive browser control (e.g. a permission grant) actually sits.
- Primitive The tap is delivered to the hidden control, granting a sensitive permission or triggering a sensitive action without informed consent (UI redress / tapjacking).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt |
modified |
Files Changed
mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.ktmobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
Audit Directions
- Other animated activitiesReview any
Activity/Fragmentthat becomes interactive before its enter transition completes, especially ones that can be launched by third-party apps, for the same early-touch exposure. - Sensitive prompts under Custom TabsEnumerate which permission/sensitive controls can appear in a Custom Tab early in its lifecycle; those are the real targets of this class of tapjacking.
- `final` removalsConfirm no other
HomeActivitysubclass relied on the previously-finaldispatchTouchEvent, and that no subclass now forgets to call through.
Patch
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
index cf5c73b4f90..15c41299aa1 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
@@ -972,7 +972,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
return false
}
- final override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
+ override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
ProfilerMarkers.addForDispatchTouchEvent(components.core.engine.profiler, ev)
return super.dispatchTouchEvent(ev)
}
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
index 774c0525a36..452f3dcbbc9 100644
--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
+++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/customtabs/ExternalAppBrowserActivity.kt
@@ -7,6 +7,7 @@ package org.mozilla.fenix.customtabs
import android.app.assist.AssistContent
import android.net.Uri
import android.os.Build
+import android.view.MotionEvent
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.state.selector.findCustomTab
@@ -24,6 +25,8 @@ const val EXTRA_IS_SANDBOX_CUSTOM_TAB = "org.mozilla.fenix.customtabs.EXTRA_IS_S
*/
@Suppress("TooManyFunctions")
open class ExternalAppBrowserActivity : HomeActivity() {
+ var isFinishedAnimating = false
+
override fun onResume() {
super.onResume()
@@ -74,4 +77,17 @@ open class ExternalAppBrowserActivity : HomeActivity() {
val currentTabUrl = getExternalTab()?.content?.url
outContent?.webUri = currentTabUrl?.let { Uri.parse(it) }
}
+
+ override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
+ if (!isFinishedAnimating) {
+ return true
+ }
+
+ return super.dispatchTouchEvent(ev)
+ }
+
+ override fun onEnterAnimationComplete() {
+ super.onEnterAnimationComplete()
+ isFinishedAnimating = true
+ }
}