>samit_hota
Back to research
MOBILE & DEVICE SECURITY

Beyond Exported Flags: Securing Android Deep Links and Component Boundaries

Samit Hota·
#android#mobile-security#app-links#manifest

Implicit trust in Android component boundaries remains one of the most common architectural flaws in modern mobile applications. Developers routinely treat deep link handlers and internal activities as private code pathways, forgetting that the Android Operating System treats any exported component as a publicly accessible entry point. When an application exposes an activity capable of handling external intents without rigorous input validation, it effectively hands control of its internal screen stack to any arbitrary application installed on the user’s device.

Security models on Android rely on IPC (Inter-Process Communication) boundaries enforced at the manifest level. When those boundaries are misconfigured, the distinction between an authenticated internal state and an untrusted external request collapses entirely.

Audit Mechanics: Decoding Manifest Component Exposure

Analyzing an Android application’s exposure surface begins with its blueprint: AndroidManifest.xml. Before Android 12 (API level 31), activities containing <intent-filter> tags defaulted to being exported unless explicitly declared otherwise. While modern SDKs mandate explicit declaration of android:exported, legacy applications and improperly audited manifests frequently contain over-exposed activities.

To analyze component exposure, reverse engineers decompile the target APK using standard tools like jadx or apktool to inspect AndroidManifest.xml. The audit focuses on identifying activities where android:exported="true" is set alongside intent filters for custom URI schemes or general intent actions.

Consider an excerpt from an unhardened manifest:

<activity
    android:name="com.example.app.ui.AccountSettingsActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="settings" />
    </intent-filter>
</activity>

In this configuration, AccountSettingsActivity is fully exposed to the system. Any external app can construct an intent matching the myapp://settings scheme, or directly target the component name using explicit intent invocation via standard Android APIs or command-line bridge tools (adb shell am start).

The core issue here is intentionality: does AccountSettingsActivity truly need to be invoked by third-party applications, or was the intent-filter added merely to support deep links from a companion web service? In most cases, it is the latter, leaving an unauthenticated route into sensitive application state.

When an activity processes an incoming intent, it relies on data supplied in the Uri or extras bundle. If the destination activity assumes that incoming intents originate strictly from within its own code flow, severe authorization bypasses occur.

For example, custom schemes (myapp://) possess no inherent domain ownership verification on Android. Any malicious app installed on the same device can register the exact same scheme in its own manifest. When a user clicks a link with that scheme, Android prompts the user with an app chooser, or worse, routes the intent to the hijacking app if configured as a default handler.

Furthermore, exposed activities often parse query parameters or intent extras to decide which Fragment to display or which internal URL to load in an embedded WebView. Consider a vulnerable handler implementation inside an exposed activity:

// Vulnerable handling pattern inside an exposed Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    val data: Uri? = intent?.data
    data?.let { uri ->
        val redirectUrl = uri.getQueryParameter("url")
        if (redirectUrl != null) {
            // Unvalidated navigation to an external URL or internal view
            loadInternalWebView(redirectUrl)
        }
    }
}

Because AccountSettingsActivity is exported, an external attacker can supply arbitrary parameters via the intent. If redirectUrl is passed directly into a WebView or internal navigation controller without strict domain whitelisting, the app becomes vulnerable to open redirection, local file theft via file:// schemes, or execution of sensitive internal actions intended only for authenticated sessions.

Defending Entry Points: Explicit Exports and App Link Verification

Closing this attack surface requires a two-tiered defense: strict manifest visibility rules and cryptographically backed domain verification.

First, reduce the exposed attack surface to the bare minimum. If an activity is internal and should only be launched by other components within the same application, explicitly set android:exported="false" and remove any unnecessary intent filters:

<activity
    android:name="com.example.app.ui.AccountSettingsActivity"
    android:exported="false" />

If an activity must accept deep links from external web sources, upgrade custom URI schemes (myapp://) to Android App Links (https://). Android App Links associate a web domain owned by the developer directly with the mobile application using HTTP/HTTPS URIs combined with Digital Asset Links.

To enforce App Links, set android:autoVerify="true" on the intent filter:

<activity
    android:name="com.example.app.ui.DeepLinkHandlerActivity"
    android:exported="true">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="example.com" android:pathPrefix="/open" />
    </intent-filter>
</activity>

During application installation, the Android OS fetches the Digital Asset Links JSON file located at https://example.com/.well-known/assetlinks.json. This file must contain the package name and target app’s signing certificate SHA-256 fingerprint:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.app",
    "sha256_cert_fingerprints":
    ["14:6D:E9:A0:85:47:4E:1D:01:AF:5C:35:A9:E2:B0:1B:7D:6D:3E:68:1B:32:0C:6D:07:95:90:BB:EB:32:8A:2C"]
  }
}]

If validation succeeds, the OS establishes the application as the default, exclusive handler for https://example.com/open. Untrusted apps cannot hijack the URI, and open links bypass the system app selection dialog entirely.

Defensive Intent Parsing Strategy

Even when using Android App Links, data retrieved from an incoming intent must be treated as untrusted input. The receiving entry activity should act as a gateway, strictly validating parameters before passing control to internal business logic.

Implement strict whitelist validation on incoming URIs rather than trusting raw strings:

class DeepLinkHandlerActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        val uri = intent?.data
        if (uri != null && isValidDeepLink(uri)) {
            routeToInternalDestination(uri)
        } else {
            finish()
        }
    }

    private fun isValidDeepLink(uri: Uri): Boolean {
        // Enforce expected host, scheme, and path rules strictly
        if (uri.scheme != "https" || uri.host != "example.com") {
            return false
        }
        
        // Ensure path matches known safe destinations
        val path = uri.path ?: return false
        return path.startsWith("/open/profile") || path.startsWith("/open/settings")
    }

    private fun routeToInternalDestination(uri: Uri) {
        // Construct an explicit internal intent to transition away from the public entry point
        val safeIntent = Intent(this, InternalRouterActivity::class.java).apply {
            putExtra("validated_path", uri.path)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        startActivity(safeIntent)
        finish()
    }
}

By enforcing android:exported="false" on internal UI components, converting custom schemes to auto-verified App Links, and stripping execution context through explicit internal intents, developers eliminate component exposure vectors completely. Relying on obscurity or implicit navigation boundaries inside an Android application is a design defect—explicit verification is the only reliable architecture.

Want a second set of eyes on your security posture?

Let's talk about where your real exposure is.

Book an advisory call