Skip to main content
Blog

Mobile

Deep links and exported components: the open doors of Android and iOS apps

Exported activities, services, receivers and providers, plus deep links, are ways into your app from outside. How to check them and close them.

· 10 min read

Photo: Berna Gaitán Otarán, CC BY-SA 4.0 · Wikimedia Commons

A mobile app's attack surface is not limited to its server API. On Android, every app can talk to other apps through intents; on iOS, an app is opened through URL schemes and universal links. These mechanisms are convenient: a link in an SMS opens the payment screen directly, a button in the browser jumps into the app. But each such "door" is a place where the app accepts external, untrusted data. When a mobile app goes through the security review, the expert lists exactly these doors and tests each one.

In this article we look at Android exported components, deep links, App Links verification and iOS universal links: what the problem is, how to check it yourself and how to close it.

What the problem is

An Android app is built from four kinds of components:

  • Activity — a screen;
  • Service — a background service;
  • Broadcast receiver — receives messages from the system or other apps;
  • Content provider — a layer that exposes data to other apps through URIs.

Each component is declared in AndroidManifest.xml and marked with the android:exported attribute. If exported="true", any other app on the device (including a malicious one) can call it directly. And the code inside the component is often written on the assumption that "only my own app calls me".

Typical mistakes:

  1. An internal screen is exported. For example, SettingsActivity or PinChangeActivity should only open after login, but because it is exported it can be opened from outside, bypassing authentication.
  2. Blind trust in intent data. An activity takes a url parameter from the intent and opens it in a WebView, or puts amount and account straight into a payment form. This is called intent spoofing: an external app sends a fake but plausible request.
  3. Unprotected content provider. The provider is exported and requires no permission, so another app reads user data via a content:// URI. Providers that serve files sometimes also allow path traversal with ../.
  4. A broadcast receiver accepts any message. For example, it does not check who sent the "refresh session" or "sync now" command.
  5. Deep link parameters are not validated. A myapp://open?url=... link opens any website in the in-app WebView, and myapp://transfer?to=...&sum=... performs an action directly.

Since Android 12 (API 31), every component with an intent filter must declare android:exported explicitly. That is a good change, but it does not solve the problem: developers often just put true everywhere to make the build error go away.

Deep links come in two kinds:

  • Custom URL scheme (myapp://...). Any app can register it. If two apps declare the same scheme, the system asks the user or opens the other one. So passing secrets through such a link (an OAuth code or a confirmation token, for example) is risky — another app can intercept it.
  • Android App Links / iOS Universal Links (https://example.uz/...). The domain owner places a file on the server confirming which app may open these links. Another app cannot "claim" that domain.

If OAuth uses a custom scheme, PKCE (RFC 7636) must be mandatory, so that an intercepted code is useless on its own.

Why it matters for the security review

Mobile app reviews are based on the OWASP MASVS and MASTG methodology. The MASVS PLATFORM group requires the app to interact with the platform securely: IPC mechanisms are exposed only when needed, and data from outside is validated. In the OWASP Mobile Top 10 (2024) this falls under M4 (Insufficient Input/Output Validation) and M8 (Security Misconfiguration), and sometimes M3 (Insecure Authentication/Authorization) when an exported screen allows bypassing authentication.

The expert unpacks the APK, extracts every exported component from the manifest and calls each one by hand. Automated tools flag this immediately as well, so closing it before the review is both easy and worthwhile.

How to check it yourself

1. Open the manifest

Unpack the release APK with apktool and look for exported components:

apktool d app-release.apk -o app_src
grep -n 'exported="true"' app_src/AndroidManifest.xml
grep -n '<intent-filter' app_src/AndroidManifest.xml

Note: what you need to check is the final, merged manifest. Libraries add their own components, and those are exposed under your app's name too. Android Studio shows this in the Merged Manifest tab or in APK Analyzer.

For each exported component ask one question: "Should another app call this?" If the answer is no — exported="false".

2. Call the components from outside

On a test device via adb:

# Open an activity directly
adb shell am start -n uz.example.app/.ui.PinChangeActivity

# Test a deep link
adb shell am start -a android.intent.action.VIEW \
  -d "myapp://open?url=https://evil.example.com"

# Send a broadcast
adb shell am broadcast -a uz.example.app.SYNC_NOW

# Read from a content provider
adb shell content query --uri content://uz.example.app.provider/users

Expected result: internal screens do not open or redirect to login; the deep link does not open a foreign domain; the broadcast is ignored; the provider returns a permission error.

For broader testing there are tools like drozer, which list and invoke exported components automatically.

adb shell pm get-app-links uz.example.app

The domain should show verified. If it shows none or legacy_failure, the assetlinks.json file is wrong or missing.

curl -i https://example.uz/.well-known/assetlinks.json

The response must be 200, Content-Type: application/json, with no redirect, and contain the SHA-256 fingerprint of the release signing certificate.

4. Check iOS

In the Info.plist inside the IPA, look at CFBundleURLTypes (custom schemes), and in the entitlements at com.apple.developer.associated-domains. The server file:

curl -i https://example.uz/.well-known/apple-app-site-association

Open a link in the simulator:

xcrun simctl openurl booted "myapp://open?url=https://evil.example.com"

How to fix it

Android: the manifest

Internal components must be closed:

<activity
    android:name=".ui.PinChangeActivity"
    android:exported="false" />

<receiver
    android:name=".sync.SyncReceiver"
    android:exported="false" />

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

If a component really must be open only to your own family of apps, use a signature-level permission:

<permission
    android:name="uz.example.permission.INTERNAL"
    android:protectionLevel="signature" />

<service
    android:name=".api.PartnerService"
    android:exported="true"
    android:permission="uz.example.permission.INTERNAL" />

Do not write your own provider for file sharing — use FileProvider and list only a specific folder in file_paths.xml (not the whole internal storage).

Bind the deep link activity only to a verified domain:

<activity android:name=".DeepLinkActivity" 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.uz"
              android:pathPrefix="/app" />
    </intent-filter>
</activity>

On the server, /.well-known/assetlinks.json:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "uz.example.app",
    "sha256_cert_fingerprints": ["AB:CD:...:EF"]
  }
}]

If you use Google Play App Signing, take the fingerprint of the app signing key from Play Console. Make sure nginx serves the file with the right type:

location = /.well-known/assetlinks.json {
    default_type application/json;
}

Every input is untrusted input

Even with a correct manifest, deep link parameters must be validated. A Kotlin example:

val uri = intent.data ?: return finish()
if (uri.scheme != "https" || uri.host != "example.uz") return finish()

val target = uri.getQueryParameter("url")
val allowed = setOf("example.uz", "pay.example.uz")
val host = target?.let { Uri.parse(it).host }
if (host == null || host !in allowed) return finish()

Rules:

  • A deep link never performs an action directly. It only opens a screen, and the user confirms the action (for example, sees the payment amount and enters a PIN).
  • A screen opened via a deep link also checks the session and permissions.
  • An external URL goes through a domain allowlist before it is opened in a WebView.
  • When creating a PendingIntent, use FLAG_IMMUTABLE and never pass an empty (implicit) intent.
  • Calling startActivity() with a value from an incoming intent (intent redirection) — only for internal, explicitly named components.

Flutter

In Flutter, deep links are usually handled with the app_links package or through go_router. The manifest part is the same as above; on the Dart side, check the route:

GoRoute(
  path: '/app/payment/:id',
  redirect: (context, state) {
    final loggedIn = auth.isLoggedIn;
    if (!loggedIn) return '/login?next=${state.uri.path}';
    return null;
  },
  builder: (context, state) =>
      PaymentScreen(id: state.pathParameters['id']!),
),

Route parameters should be identifiers only; the amount, account number and other important values are fetched from the server by that identifier.

Add Associated Domains in Xcode: applinks:example.uz. The apple-app-site-association file on the server (no extension, JSON, no redirect):

{
  "applinks": {
    "details": [{
      "appIDs": ["TEAMID.uz.example.app"],
      "components": [{ "/": "/app/*" }]
    }]
  }
}

Where possible, replace the custom scheme (myapp://) with a universal link. If you keep it, validate the host, path and parameters in application(_:open:options:) or SwiftUI's onOpenURL exactly as on Android. Never pass secret codes through a custom scheme.

Short checklist

  • Every exported="true" in the merged manifest is justified; the rest are false.
  • Components added by libraries have been reviewed too.
  • Content providers are not exported or are protected by a signature permission; files go through FileProvider.
  • Broadcast receivers do not react to external messages.
  • Deep links are https App Links / Universal Links; assetlinks.json and apple-app-site-association are correct on the server.
  • pm get-app-links shows verified.
  • Deep link parameters are checked against an allowlist; no action is performed directly.
  • A screen opened via a deep link checks the session.
  • If OAuth uses a custom scheme, PKCE is enabled.
  • PendingIntents use FLAG_IMMUTABLE.
  • Manual testing with adb am start, am broadcast and content query has been done.

Conclusion

Exported components and deep links are the most overlooked part of an app: they are opened by a single attribute at build time and are invisible in code review. The expert, however, lists them in the very first step. Cleaning up the manifest, moving to App Links / Universal Links and treating every external input as untrusted is the right way to close these findings. Related articles: WebView security and what a mobile app security review checks.

If you want to see the list of open doors in your app before the review, request a security scan. The Claude AI agent quickly goes through the manifest and code, and our specialist confirms each finding on a device and helps you close it.