Мобильные
Deep link и экспортированные компоненты: открытые двери Android- и iOS-приложения
Экспортированные activity, service, receiver и provider, а также deep link — это вход в приложение извне. Как их проверить и закрыть.
· 10 мин. чтения

Поверхность атаки мобильного приложения не ограничивается его серверным 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", любое другое приложение на устройстве (включая вредоносное) может вызвать его напрямую. А код внутри компонента часто написан с предположением «меня вызывает только моё приложение».
Типичные ошибки:
- Экспортирован внутренний экран. Например,
SettingsActivityилиPinChangeActivityдолжны открываться после входа, но из-за экспорта их можно открыть извне, в обход аутентификации. - Слепое доверие данным интента. Activity берёт из интента параметр
urlи открывает его в WebView или подставляетamount,accountпрямо в форму оплаты. Это называется intent spoofing: внешнее приложение отправляет поддельный, но правдоподобный запрос. - Незащищённый content provider. Provider экспортирован и не требует разрешения (permission) — другое приложение читает данные пользователя через URI
content://. В провайдерах, отдающих файлы, встречается и path traversal через../. - Broadcast receiver принимает любое сообщение. Например, не проверяет, кто отправил команду «обновить сессию» или «синхронизировать».
- Параметры deep link не проверяются. Ссылка
myapp://open?url=...открывает любой сайт во встроенном WebView, аmyapp://transfer?to=...&sum=...сразу выполняет действие.
Начиная с Android 12 (API 31) для каждого компонента с intent-filter значение android:exported нужно указывать явно. Это хорошее изменение, но проблему оно не решает: разработчики часто ставят true везде, лишь бы убрать ошибку сборки.
Разница между custom-схемой и App Links
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 — они автоматически перечисляют и вызывают экспортированные компоненты.
3. Проверьте статус App Links
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 только конкретную папку (а не всё внутреннее хранилище).
Android: App Links
Привяжите 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']!),
),
Параметры маршрута — только идентификаторы; сумма, номер счёта и другие важные значения берутся с сервера по идентификатору.
iOS: universal links
Добавьте в 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 — это
httpsApp 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 агент быстро просмотрит манифест и код, а наш специалист подтвердит каждую находку на устройстве и поможет её закрыть.


