Перейти к основному содержанию
Блог

Мобильные

Deep link и экспортированные компоненты: открытые двери Android- и iOS-приложения

Экспортированные activity, service, receiver и provider, а также deep link — это вход в приложение извне. Как их проверить и закрыть.

· 10 мин. чтения

Фото: Berna Gaitán Otarán, CC BY-SA 4.0 · Wikimedia Commons

Поверхность атаки мобильного приложения не ограничивается его серверным API. На Android каждое приложение может общаться с другими через интенты, на iOS приложение открывается через URL-схемы и universal links. Эти механизмы удобны: ссылка из SMS сразу открывает страницу оплаты, кнопка в браузере переводит в приложение. Но каждая такая «дверь» — место, где приложение принимает внешние, недоверенные данные. При экспертизе мобильного приложения эксперт составляет список именно этих дверей и проверяет каждую.

В этой статье разберём экспортированные компоненты Android, deep link, верификацию App Links и universal links в iOS: в чём проблема, как проверить самостоятельно и как закрыть.

В чём проблема

Android-приложение состоит из четырёх типов компонентов:

  • Activity — экран;
  • Service — фоновая служба;
  • Broadcast receiver — получатель сообщений от системы или других приложений;
  • Content provider — слой, отдающий данные другим приложениям через URI.

Каждый компонент объявляется в AndroidManifest.xml и помечается атрибутом android:exported. Если exported="true", любое другое приложение на устройстве (включая вредоносное) может вызвать его напрямую. А код внутри компонента часто написан с предположением «меня вызывает только моё приложение».

Типичные ошибки:

  1. Экспортирован внутренний экран. Например, SettingsActivity или PinChangeActivity должны открываться после входа, но из-за экспорта их можно открыть извне, в обход аутентификации.
  2. Слепое доверие данным интента. Activity берёт из интента параметр url и открывает его в WebView или подставляет amount, account прямо в форму оплаты. Это называется intent spoofing: внешнее приложение отправляет поддельный, но правдоподобный запрос.
  3. Незащищённый content provider. Provider экспортирован и не требует разрешения (permission) — другое приложение читает данные пользователя через URI content://. В провайдерах, отдающих файлы, встречается и path traversal через ../.
  4. Broadcast receiver принимает любое сообщение. Например, не проверяет, кто отправил команду «обновить сессию» или «синхронизировать».
  5. Параметры deep link не проверяются. Ссылка myapp://open?url=... открывает любой сайт во встроенном WebView, а myapp://transfer?to=...&sum=... сразу выполняет действие.

Начиная с Android 12 (API 31) для каждого компонента с intent-filter значение android:exported нужно указывать явно. Это хорошее изменение, но проблему оно не решает: разработчики часто ставят true везде, лишь бы убрать ошибку сборки.

Deep link бывает двух видов:

  • Custom URL-схема (myapp://...). Её может зарегистрировать любое приложение. Если две программы объявляют одну и ту же схему, система спрашивает пользователя или открывает другую. Значит, передавать через такую ссылку секреты (например, код OAuth или токен подтверждения) опасно — его может перехватить другое приложение.
  • Android App Links / iOS Universal Links (https://example.uz/...). Владелец домена размещает на сервере файл и подтверждает, какое приложение может открывать эти ссылки. Другое приложение не может «занять» этот домен.

Если для OAuth используется custom-схема, обязателен PKCE (RFC 7636) — тогда перехваченный код сам по себе бесполезен.

Почему это важно для экспертизы

Экспертиза мобильных приложений опирается на методологию OWASP MASVS и MASTG. Группа PLATFORM в MASVS требует, чтобы приложение безопасно взаимодействовало с платформой: IPC-механизмы открываются только при необходимости, данные извне проверяются. По OWASP Mobile Top 10 (2024) это категории M4 (Insufficient Input/Output Validation) и M8 (Security Misconfiguration), иногда M3 (Insecure Authentication/Authorization) — если экспортированный экран позволяет обойти аутентификацию.

Эксперт распаковывает APK, выписывает из манифеста все экспортированные компоненты и вызывает каждый вручную. Автоматические инструменты тоже сразу показывают эту находку, поэтому закрыть её до экспертизы и просто, и полезно.

Как проверить самостоятельно

1. Откройте манифест

Распакуйте release APK с помощью apktool и найдите экспортированные компоненты:

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

Обратите внимание: проверять нужно итоговый, объединённый (merged) манифест. Библиотеки добавляют свои компоненты, и они тоже открыты от имени вашего приложения. В Android Studio это видно во вкладке Merged Manifest или в APK Analyzer.

Для каждого экспортированного компонента задайте один вопрос: «Должно ли его вызывать другое приложение?» Если нет — exported="false".

2. Вызовите компоненты извне

На тестовом устройстве через adb:

# Открыть activity напрямую
adb shell am start -n uz.example.app/.ui.PinChangeActivity

# Проверить deep link
adb shell am start -a android.intent.action.VIEW \
  -d "myapp://open?url=https://evil.example.com"

# Отправить broadcast
adb shell am broadcast -a uz.example.app.SYNC_NOW

# Прочитать content provider
adb shell content query --uri content://uz.example.app.provider/users

Ожидаемый результат: внутренние экраны не открываются или возвращают на вход; deep link не открывает чужой домен; broadcast игнорируется; provider возвращает ошибку доступа.

Для более широкой проверки есть инструменты вроде drozer — они автоматически перечисляют и вызывают экспортированные компоненты.

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

Рядом с доменом должно быть verified. Если none или legacy_failure, файл assetlinks.json неверен или не найден.

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

Ответ должен быть 200, Content-Type: application/json, без редиректа, и содержать SHA-256 хеш сертификата release-подписи.

4. Проверьте iOS

В Info.plist внутри IPA посмотрите CFBundleURLTypes (custom-схемы), а в entitlements — com.apple.developer.associated-domains. Файл на сервере:

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

Открыть ссылку в симуляторе:

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

Как закрыть

Android: манифест

Внутренние компоненты должны быть закрыты:

<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>

Если компонент действительно должен быть открыт только для семейства ваших приложений, используйте разрешение уровня signature:

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

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

Для обмена файлами не пишите свой provider — используйте FileProvider и указывайте в file_paths.xml только конкретную папку (а не всё внутреннее хранилище).

Привяжите activity для deep link только к подтверждённому домену:

<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>

На сервере /.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"]
  }
}]

Если используется Google Play App Signing, берите хеш ключа подписи приложения из Play Console. Проверьте, что nginx отдаёт файл с правильным типом:

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

Любой вход — недоверенный

Даже при правильном манифесте параметры deep link нужно проверять. Пример на Kotlin:

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()

Правила:

  • Deep link никогда не выполняет действие напрямую. Он только открывает экран, а пользователь сам подтверждает действие (например, видит сумму оплаты и вводит PIN).
  • Экран, открытый через deep link, тоже проверяет сессию и права.
  • Внешний URL перед открытием в WebView проходит через allowlist доменов.
  • При создании PendingIntent используйте FLAG_IMMUTABLE и не передавайте пустой (implicit) интент.
  • startActivity() со значением из входящего интента (intent redirection) — только для внутренних, явно указанных компонентов.

Flutter

Во Flutter deep link обычно обрабатываются пакетом app_links или через go_router. Манифест — как выше, а на стороне Dart проверяйте маршрут:

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']!),
),

Параметры маршрута — только идентификаторы; сумма, номер счёта и другие важные значения берутся с сервера по идентификатору.

Добавьте в Xcode Associated Domains: applinks:example.uz. Файл apple-app-site-association на сервере (без расширения, JSON, без редиректа):

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

По возможности замените custom-схему (myapp://) на universal link. Если она остаётся, в application(_:open:options:) или onOpenURL в SwiftUI проверяйте host, путь и параметры так же, как на Android. Не передавайте секретные коды через custom-схему.

Короткий чек-лист

  • Каждый exported="true" в merged-манифесте обоснован; остальные — false.
  • Компоненты, добавленные библиотеками, тоже просмотрены.
  • Content provider не экспортированы или защищены разрешением signature; файлы — через FileProvider.
  • Broadcast receiver не реагируют на внешние сообщения.
  • Deep link — это https App Links / Universal Links; assetlinks.json и apple-app-site-association на сервере корректны.
  • pm get-app-links показывает verified.
  • Параметры deep link проверяются по allowlist; действие не выполняется напрямую.
  • Экран, открытый через deep link, проверяет сессию.
  • Если OAuth через custom-схему — включён PKCE.
  • PendingIntent — с FLAG_IMMUTABLE.
  • Проведена ручная проверка через adb am start, am broadcast, content query.

Вывод

Экспортированные компоненты и deep link — самая незаметная часть приложения: они открываются одним атрибутом при сборке и не видны при ревью кода. А эксперт перечисляет их на первом же шаге. Очистка манифеста, переход на App Links / Universal Links и отношение к каждому внешнему входу как к недоверенному — самый правильный способ закрыть эти находки. Близкие по теме статьи: безопасность WebView и что проверяют на экспертизе мобильного приложения.

Если хотите увидеть список открытых дверей вашего приложения до экспертизы, оставьте заявку на сканирование. Claude AI агент быстро просмотрит манифест и код, а наш специалист подтвердит каждую находку на устройстве и поможет её закрыть.