Безопасность мобильного: хранение секретов, биометрия, пиннинг, реверс
В вебе граница безопасности проходит по HTTP-запросу. Ваш код живёт на сервере, браузер получает разметку и скрипты, а всё важное — пароли, ключи, бизнес-правила — остаётся там, куда чужой человек не дотянется. Фронтенд можно править в DevTools сколько угодно: сервер всё равно пересчитает цену и проверит права.
На мобильном граница сдвинута. Вы отдаёте пользователю сам бинарь: скомпилированный код, ресурсы, конфиги, зашитые строки. Вместе с ним — данные: кэш, локальную базу, токены сессии, черновики. И вместе с ними — исполняющую среду, которую пользователь может рутовать, отлаживать и подменять по частям. Единственное, что остаётся вашим, — сервер. Отсюда принцип, из которого выводится вся статья:
Мобильное приложение — недоверенный клиент, работающий на территории противника. Всё, что вы делаете на устройстве, — не барьер, а повышение цены атаки. Барьер существует только на сервере.
Это не значит, что клиентская безопасность бессмысленна. Разница между «токен в SharedPreferences
открытым текстом» и «токен под ключом в StrombBox, привязанным к биометрии» — это разница между «телефон
потеряли в баре, деньги ушли» и «телефон потеряли в баре, ничего не произошло». Просто ценность каждой меры
надо считать честно: от какого противника она защищает и сколько стоит в разработке и поддержке.
Полезно держать в голове устройство песочницы и подписи из статьи о платформах: мобильная безопасность во многом есть следствие того, как устроены изоляция процессов и цепочка доверия при установке. Общая теория (модель угроз, прикладная криптография, OAuth, токены) разобрана в треке по безопасности; здесь мы приземляем её на API двух платформ.
Модель угроз: против кого мы вообще защищаемся
Главная ошибка мобильной безопасности — «защищаться вообще». Получается карго-культ: пиннинг без процесса ротации, шифрование базы ключом, лежащим рядом с базой, детект jailbreak, снимаемый одной строкой Frida. Начинать надо с вопроса «кто противник и что он получит». Противников пять, и они очень разные.
- Сетевой наблюдатель — кафе, аэропорт, скомпрометированный роутер, DPI оператора. Хочет читать и подменять трафик. Против него: TLS, ATS и cleartext-политики, иногда пиннинг.
- Вор с устройством в руках — заблокированным или, что хуже, разблокированным. Хочет войти и вывести деньги. Против него: классы доступности хранилищ, биометрический гейт, таймаут сессии, отзыв устройства.
- Вредоносное приложение на том же устройстве — ограничено песочницей, но живёт в общем мире: буфер обмена, экспортированные компоненты, URL-схемы, скриншоты. Против него: гигиена межпроцессных границ.
- Владелец устройства как противник — самый недооценённый класс. Хочет бесплатную подписку, чит в игре, автоматизацию вашего API. У него root, Frida, отладчик, пересборка. Клиентские проверки против него бесполезны в принципе — работает только серверная авторизация и аттестация.
- Ваша собственная цепочка поставки — сторонний SDK выполняется в вашем процессе с вашими правами: читает вашу песочницу, видит ваши токены, ходит в свою сеть. Формально не «атака», но именно так данные утекают чаще всего (цепочка поставки).
бинарь, ресурсы, строки"] store["Локальные данные
БД, кэш, токены"] sdk["Сторонние SDK
в вашем процессе"] os["ОС: песочница,
Keychain / Keystore"] hw["Чип: Secure Enclave /
StrongBox / TEE"] end subgraph net["Сеть — наблюдаемая зона"] tls["TLS-канал"] end subgraph server["Ваш сервер — единственная доверенная зона"] api["Авторизация, лимиты,
бизнес-правила, цены"] end app --> store sdk --> store app --> os os --> hw app --> tls --> api attacker1["Сетевой наблюдатель"] -.перехват.-> tls attacker2["Вор с устройством"] -.разблокированный экран.-> store attacker3["Владелец с root + Frida"] -.патч и хуки.-> app attacker4["Чужое приложение"] -.буфер, deep links.-> app style server stroke:#4fa373,stroke-width:2px style device stroke:#c96a6a,stroke-width:2px
Стрелки внутрь устройства идут отовсюду, и закрыть их все нельзя. Единственный узел без входящих стрелок от противника — сервер. Проектируйте так, чтобы компрометация клиента стоила пользователю его собственных данных, а не данных всех остальных.
Формальный каркас для этого разговора существует: OWASP MASVS с группами требований MASVS-STORAGE, -CRYPTO, -AUTH, -NETWORK, -PLATFORM, -CODE, -RESILIENCE и MASTG — набор конкретных тестов к ним (mas.owasp.org). Держите его открытым при проектировании: он избавляет от споров «а нужно ли нам это» — там прямо написано, какое требование к какому уровню зрелости относится.
Хранение секретов: где что лежит и кто это прочитает
Первый вопрос почти любого мобильного разработчика — «куда положить токен?». Ответ зависит от того, кому вы не хотите его отдавать: платформы дают не одно хранилище, а лестницу с разными свойствами.
Три вывода из этой таблицы стоят всей остальной статьи:
UserDefaultsиSharedPreferences— не хранилище секретов. Это plist и XML в песочнице: уезжают в бэкап, читаются с рутованного устройства текстовым редактором, переживают переустановку.- Песочница спасает от чужого приложения, но не от владельца. Столбец «другое приложение» почти весь зелёный — это заслуга ОС, а не ваша. Столбец «root + Frida» почти весь красный, и изменить это нельзя.
- Аппаратный ключ — единственная строка с прочерком почти везде. Не потому, что чип волшебный, а потому, что ключ физически не покидает его: даже с root противник не унесёт ключ, он сможет только попросить чип поработать, пока держит ваше устройство в руках.
iOS: Keychain и классы доступности
Keychain — отдельная база данных, которой владеет демон securityd; она зашифрована ключами, часть которых
выводится из пасскода. Приложение не читает её файл напрямую — просит демон достать элемент, а тот проверяет
права по entitlement keychain-access-groups. Ключевой атрибут — класс доступности: он определяет, при
каком состоянии устройства элемент вообще расшифровывается.
| Класс | Когда доступен | Уезжает в бэкап |
|---|---|---|
kSecAttrAccessibleWhenUnlocked |
только при разблокированном экране | да |
kSecAttrAccessibleAfterFirstUnlock |
после первой разблокировки с загрузки | да |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
при разблокировке, только это устройство | нет |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly |
только если задан пасскод | нет |
AfterFirstUnlock нужен фоновым задачам: без него sync-движок из
статьи о фоновой работе не прочитает токен, пока телефон лежит
в кармане, — а цена в том, что элемент доступен фоновому противнику на тех же условиях. Суффикс
ThisDeviceOnly исключает элемент из iCloud-бэкапа: токен не «переедет» на новое устройство при
восстановлении, и это почти всегда правильно — доверие к устройству не должно копироваться.
import Security
import LocalAuthentication
/// Кладём refresh-токен так, чтобы его нельзя было достать ни из бэкапа,
/// ни при заблокированном экране, ни без биометрии.
func storeRefreshToken(_ token: Data, account: String) throws {
// biometryCurrentSet привязывает элемент к ТЕКУЩЕМУ набору биометрии:
// добавили новый отпечаток — элемент становится нечитаемым.
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, [.biometryCurrentSet], &error
) else { throw error!.takeRetainedValue() as Error }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.app.auth",
kSecAttrAccount as String: account,
kSecValueData as String: token,
kSecAttrAccessControl as String: access,
]
SecItemDelete(query as CFDictionary) // перезапись = удалить и добавить заново
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
func readRefreshToken(account: String, reason: String) throws -> Data {
let context = LAContext()
context.localizedReason = reason
context.touchIDAuthenticationAllowableReuseDuration = 60 // не переспрашивать минуту
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.app.auth",
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context, // диалог покажет сам Keychain
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { throw KeychainError.status(status) }
return data
}
Диалог биометрии здесь показывает сам Keychain при попытке чтения — это принципиально сильнее, чем
«сначала спросим LAContext, потом прочитаем» (почему — в разделе о биометрии). Отдельная ловушка iOS:
Keychain переживает удаление приложения. Пользователь снёс приложение, поставил заново — и получил
старый токен от старого аккаунта. Лечится флагом «первый запуск» в UserDefaults (они как раз удаляются)
с очисткой Keychain при его отсутствии.
Файлы защищаются отдельным механизмом — Data Protection. По умолчанию файл создаётся с классом
NSFileProtectionCompleteUntilFirstUserAuthentication; для чувствительных ставьте NSFileProtectionComplete,
помня, что фоновая задача такой файл не откроет.
Android: Keystore и почему обёртки не спасают
Android идёт от другой абстракции. Keystore хранит не значения, а ключи, в идеале — внутри TEE или отдельного чипа StrongBox. Материал ключа приложению не выдаётся никогда: вы получаете хендл и просите систему выполнить операцию. Значение (тот же токен) шифруете сами и кладёте в обычный файл.
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
private const val ALIAS = "auth_token_key"
/// Симметричный ключ, который нельзя вынести из чипа и который требует
/// подтверждения личности перед каждым использованием.
fun ensureKey(strongBox: Boolean = true): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val spec = KeyGenParameterSpec.Builder(
ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
// 0 = подтверждение на каждую операцию; требуем сильную биометрию либо пасскод
.setUserAuthenticationParameters(
0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
.setInvalidatedByBiometricEnrollment(true) // новый отпечаток убивает ключ
.setIsStrongBoxBacked(strongBox)
.build()
return try {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }.generateKey()
} catch (e: android.security.keystore.StrongBoxUnavailableException) {
ensureKey(strongBox = false) // StrongBox есть далеко не на всех устройствах
}
}
/// Шифротекст и IV кладём куда угодно: без ключа из чипа они бесполезны.
fun encrypt(plain: ByteArray): Pair<ByteArray, ByteArray> {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, ensureKey())
return cipher.iv to cipher.doFinal(plain)
}
Три нюанса, на которых спотыкаются все:
setInvalidatedByBiometricEnrollment(true)ломает ключ при добавлении отпечатка — и это правильно: иначе тот, кто подсмотрел пасскод, добавит свой палец и получит доступ к «защищённому биометрией» токену. Ваша задача — обработатьKeyPermanentlyInvalidatedExceptionи попросить войти заново, а не спрятать ошибку.- Ключи Keystore удаляются при удалении приложения — в отличие от iOS-Keychain. Значит, зашифрованные данные из бэкапа после восстановления не расшифруются; это надо предусмотреть в сценарии восстановления.
androidx.security:security-crypto(EncryptedSharedPreferences) долго была ответом по умолчанию, но объявлена устаревшей. Полезно понять, что она делала: генерировала мастер-ключ в Keystore и шифровала им пары ключ-значение. То же самое пишется руками за полсотни строк — зато без зависимости и с контролем над политикой аутентификации.
Кроссплатформа: обёртка транслирует безопасность, но не создаёт её
Flutter и React Native не имеют собственных защищённых хранилищ и не могут иметь: плагины вызывают те же Keychain и Keystore через платформенный канал. Отсюда правило — проверяйте дефолты плагина: они выбраны так, чтобы не падало ни у кого, а не под вашу модель угроз.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
// first_unlock_this_device: фоновая синхронизация работает,
// но элемент не уедет в iCloud-бэкап и на новое устройство.
accessibility: KeychainAccessibility.first_unlock_this_device,
synchronizable: false,
),
);
Future<void> saveToken(String token) => storage.write(key: 'refresh', value: token);
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword('user', refreshToken, {
service: 'com.example.app.auth',
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET, // биометрия при чтении
accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY, // не в бэкап
securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE, // только аппаратный Keystore
});
Дальше — честный разговор о цене решения, который мы ведём весь трек (см. сравнение подходов). Для безопасности расклад такой.
Стоимость. Кроссплатформа экономит на UI и бизнес-логике, но не экономит на безопасности. Keychain и Keystore придётся понимать всё равно, только теперь через прослойку со своими багами и своим темпом обновлений. Каждая нетривиальная политика — аттестация, пиннинг с ротацией, ключ в Secure Enclave — заканчивается платформенным каналом и двумя нативными реализациями, то есть тем же объёмом работы плюс интеграция.
Качество. Различия измеримы. JS-бандл React Native извлекается из сборки и читается — Hermes-байткод
разбирается публичными инструментами, так что «спрятанная» в JS константа эквивалентна публичной. Dart в
релизе компилируется в машинный код AOT, читается тяжелее, но специализированные инструменты для Flutter
существуют. Отдельная особенность Flutter: dart:io использует собственный BoringSSL и не смотрит на
пользовательские CA и системный прокси. Побочный эффект — MITM обычным Burp «не работает», из-за чего
команды ошибочно считают, что у них уже есть пиннинг. Его там нет: атакующий пропатчит библиотеку.
Найм. Одна кроссплатформенная команда не отменяет потребности в человеке, который читает документацию Apple и Google по безопасности. На рынке это senior-нативщик, и стоит он как senior-нативщик: экономия на числе платформ не даёт экономии на квалификации.
Что нельзя спрятать в приложении вообще
- Серверные API-ключи (платёжный провайдер, ключ с правами записи в БД). Спрятать их нельзя: обфускация,
склейка из кусков, хранение в
.soотодвигают находку на часы. Правильный путь — прокси через ваш бэкенд. client_secretв OAuth. Мобильное приложение — публичный клиент по определению (RFC 8252), секрета у него быть не может. Вместо него — PKCE (RFC 7636) и системный браузер (ASWebAuthenticationSession/ Custom Tabs), а не встроенный WebView; механика — в статье про OAuth и OIDC.- Ключи публичных SDK (карты, аналитика) не прячут, а ограничивают: привязка к bundle id и подписи, квоты и биллинговые лимиты на стороне провайдера.
- Бизнес-правила, приносящие деньги — цена, лимиты, «премиум активен» — вычисляются на сервере. Клиент только отображает результат.
Биометрия: что она на самом деле доказывает
Биометрию почти всегда принимают за аутентификацию. Это не аутентификация: сервер про ваш палец ничего не знает и знать не может. Биометрия — локальный ключ от локального замка, и весь вопрос в том, что этот замок держит. Способов применения два, и они не эквивалентны.
Слабый способ. Показать системный диалог, получить true, продолжить:
let ctx = LAContext()
let ok = try await ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Подтвердите вход")
if ok { showSecretScreen() } // ← это булево подделывается одной строкой Frida
Безопасность здесь держится на честности вашего же кода: на рутованном устройстве метод хукается, и вместо
диалога сразу приходит true. Способ уместен, когда защищаемое — комфорт, а не деньги: «скрыть баланс от
того, кто взял телефон посмотреть фото».
Сильный способ. Ключ в Secure Enclave / StrongBox, созданный с требованием аутентификации. Биометрия здесь не возвращает булево — она разблокирует криптографическую операцию. Обойти это подменой ветвления нельзя: без ключа не появится корректной подписи, а ключ не появится без пальца.
Разница радикальна. В слабом варианте компрометация клиента равна компрометации операции. В сильном сервер принимает решение по подписи, которую невозможно получить без физического устройства и присутствия пользователя; патч клиента не даёт ничего.
// iOS: ключ P-256 внутри Secure Enclave, привязанный к текущей биометрии.
func createDeviceKey() throws -> SecKey {
var error: Unmanaged<CFError>?
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], &error)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256, // Secure Enclave умеет только P-256
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.app.devicekey".data(using: .utf8)!,
kSecAttrAccessControl as String: access,
],
]
guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue() as Error
}
return key
}
/// Системный диалог появится здесь, внутри операции с ключом, а не «где-то до».
func sign(challenge: Data, with key: SecKey) throws -> Data {
var error: Unmanaged<CFError>?
guard let sig = SecKeyCreateSignature(
key, .ecdsaSignatureMessageX962SHA256, challenge as CFData, &error
) else { throw error!.takeRetainedValue() as Error }
return sig as Data
}
// Android: тот же приём через BiometricPrompt + CryptoObject.
// CryptoObject работает ТОЛЬКО с BIOMETRIC_STRONG (Class 3) — это и есть критерий «сильной» биометрии.
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
// Внутри — уже разблокированный объект. Подделать его нельзя,
// в отличие от булева «пользователь подтвердил».
val signature = result.cryptoObject!!.signature!!
signature.update(challenge)
sendToServer(signature.sign())
}
override fun onAuthenticationError(code: Int, msg: CharSequence) { /* отмена, локаут */ }
})
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Подтвердите перевод")
.setSubtitle("15 000 ₽ на счёт •• 4417") // сумма ВНУТРИ системного диалога, не над ним
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setNegativeButtonText("Отмена")
.build()
prompt.authenticate(info, BiometricPrompt.CryptoObject(signatureFromKeystore()))
Мелочь, которая не мелочь: что именно подписывается. Если чип подписывает пустышку («пользователь подтвердил»), атакующий с доступом к разблокированному устройству переиграет подпись на другую операцию. Подписывайте смысл транзакции — сумму, получателя, nonce — и показывайте их в системном диалоге. Это и есть принцип «what you see is what you sign».
Политика запроса биометрии — вопрос продуктовый, но с инженерными последствиями. «Биометрия на каждый
запуск» приучает тыкать пальцем не глядя и убивает конверсию. Рабочая схема: сессия живёт долго (недели),
биометрия требуется на step-up — перед переводом, сменой пароля, добавлением получателя, показом
реквизитов. touchIDAuthenticationAllowableReuseDuration на iOS и окно доверия в
setUserAuthenticationParameters(timeout, ...) на Android дают компромисс: подтвердил один раз — следующие
30–60 секунд не переспрашиваем.
И последнее: биометрия не защищает от принуждения. Палец прикладывают силой, лицо разблокируется взглядом. Для по-настоящему опасных операций держите серверный барьер — задержку, лимит, подтверждение по второму каналу.
Транспорт: TLS, платформенные дефолты и цена пиннинга
Хорошая новость: половину работы платформы сделали за вас. App Transport Security на iOS запрещает
открытый HTTP и слабые версии TLS по умолчанию; Android с API 28 по умолчанию имеет
cleartextTrafficPermitted="false", а с API 24 перестал доверять пользовательским CA — то есть перехват
трафика через сертификат Burp не работает без явного разрешения в конфиге.
Первое правило поэтому скучное: не отключайте то, что включено. Строчка NSAllowsArbitraryLoads: true,
добавленная «чтобы тестовый стенд заработал», живёт годами и превращается в вопрос на ревью App Store, где
придётся объяснять, зачем вам открытый HTTP. Правильно — точечное исключение для домена стенда и только
в debug-конфигурации.
<!-- res/xml/network_security_config.xml — Android -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" /> <!-- пользовательские CA не доверяем -->
</trust-anchors>
</base-config>
<!-- Пиннинг: два SPKI-хеша (текущий и резервный) плюс дата истечения. -->
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">k3XnEYQCK79AtL9GYnT/nyhsabas03V+bhRQYHQbpXU=</pin>
<pin digest="SHA-256">q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ=</pin>
</pin-set>
</domain-config>
<!-- Прокси для отладки разрешаем только в debug-сборке. -->
<debug-overrides>
<trust-anchors><certificates src="user" /></trust-anchors>
</debug-overrides>
</network-security-config>
Обычная проверка TLS отвечает на вопрос «выдал ли сертификат кто-то из полутора сотен доверенных CA?». Пиннинг сужает вопрос до «выдан ли он тем самым ключом, который мы ожидаем?». Пинить можно лист, промежуточный сертификат или CA — и почти всегда правильный ответ SPKI-хеш промежуточного сертификата: листовые сертификаты Let’s Encrypt живут 90 дней, и пиннинг листа означает релиз каждые три месяца под угрозой полной неработоспособности флота.
От Frida внутри процесса не спасает ничто.
// Android: пиннинг в коде, если он не в конфиге, — CertificatePinner в OkHttp.
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/k3XnEYQCK79AtL9GYnT/nyhsabas03V+bhRQYHQbpXU=")
.add("api.example.com", "sha256/q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ=")
.build()
).build()
<!-- iOS 14+: декларативный пиннинг прямо в Info.plist, без делегатов и своей проверки. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSIncludesSubdomains</key><true/>
<key>NSPinnedCAIdentities</key>
<array>
<dict><key>SPKI-SHA256-BASE64</key><string>k3XnEYQCK79AtL9GYnT/nyhsabas03V+bhRQYHQbpXU=</string></dict>
<dict><key>SPKI-SHA256-BASE64</key><string>q4PO2G2cbkZhZ82+JgmRUyGMoAeozA+BSXVXQWB8XWQ=</string></dict>
</array>
</dict>
</dict>
</dict>
Во Flutter пиннинг делается на уровне SecurityContext и колбэка проверки — и здесь легко сделать ровно
наоборот, отключив проверку целиком:
// НИКОГДА не пишите `badCertificateCallback = (cert, host, port) => true`:
// это отключение валидации, а не пиннинг.
final client = HttpClient()
..badCertificateCallback = (cert, host, port) => false;
/// Свой пин: сравниваем SHA-256 от DER-представления сертификата с ожидаемым.
bool pinMatches(X509Certificate cert) =>
kPinnedFingerprints.contains(sha256.convert(cert.der).toString());
Честная цена пиннинга. Это единственная мера в статье, которая умеет уронить весь продакшн: сертификат сменили экстренно (отзыв, миграция CDN, инцидент у CA) — и все установленные приложения одновременно теряют связь. Починка требует релиза, а релиз на iOS — это ревью, а ревью — это дни. Поэтому вводить пиннинг можно только вместе с процессом:
- Минимум два пина: текущий ключ и заранее сгенерированный резервный, хранящийся отдельно. Один пин — бомба замедленного действия.
- Дата истечения (
expiration): по её наступлении пиннинг отключается сам, и приложение продолжает работать на обычной проверке цепочки. Лучше деградировать до стандартной безопасности, чем стать кирпичом. - Kill-switch — возможность выключить пиннинг удалённо, но не через тот же канал, который он защищает (обычно подписанный конфиг на отдельном домене).
- Мониторинг: метрика «ошибки TLS-валидации» с алертом. Всплеск — это либо атака, либо ваш собственный факап с ротацией; знать надо в обоих случаях.
И трезво о выигрыше: пиннинг не защищает от владельца устройства — готовые скрипты Frida снимают его за минуты, пересборка APK с изменённым конфигом занимает десять минут. Он защищает от корпоративного MITM, вредоносного профиля MDM, скомпрометированного CA — от сценария «пользователь на нашей стороне, но канал враждебен». Если нет активов, ради которых кто-то поставит прокси, а есть три релиза в год и никакого процесса ротации ключей, пиннинг вам скорее навредит. Подробнее о TLS — в статье о транспортной безопасности.
Реверс-инжиниринг: что видит тот, кто скачал вашу сборку
Ваш артефакт публично доступен: любой человек берёт APK из зеркала или снимает его с устройства через
adb pull, и дальше работает с файлом у себя на диске.
# Android: от установленного приложения до читаемого Kotlin — три команды.
adb shell pm path com.example.app # найти путь к APK на устройстве
adb pull /data/app/.../base.apk
jadx-gui base.apk # декомпиляция в почти-исходник
apktool d base.apk && apktool b base # правка smali и пересборка
# Динамика: хук любого метода без исходников и без пересборки.
frida -U -f com.example.app -l bypass.js # снять пиннинг, детект root, проверку подписки
objection -g com.example.app explore # интерактивно: дамп Keystore, обход биометрии
Обфускация (R8/ProGuard) полезна, но переоценена. Она переименовывает классы и методы:
PaymentValidator.checkSignature() становится a.b(). Чтение замедляется, но строковые константы не
трогаются — URL, ключи, имена флагов, тексты ошибок остаются на месте и служат навигацией по коду. Цена
тоже есть: нечитаемые крэш-стеки без mapping-файла (см. релиз и сторы)
и периодические падения на рефлексии. Включать стоит, считать защитой — нет.
iOS обфусцирован слабее, чем принято думать. Objective-C-рантайм обязан хранить имена селекторов, поэтому
class-dump восстанавливает интерфейсы автоматически; Swift компилируется в машинный код, но метаданные типов
и mangled-имена остаются. Единственная реальная преграда — FairPlay-шифрование бинаря из App Store, но она
снимается на jailbroken-устройстве, а дампы популярных приложений лежат в открытом доступе.
Детект root и jailbreak — сигнал, а не защита. Проверки /Applications/Cydia.app, наличия su,
ro.debuggable, успешности fork() ловят ленивого противника и бессильны против Magisk с DenyList или
хука на саму функцию проверки. Ставить стоит — доля атак отсекается, телеметрия полезна. Строить на этом
безопасность нельзя.
Аттестация — серверная проверка, а не клиентская. Play Integrity API на Android (пришёл на смену
SafetyNet Attestation) и App Attest / DeviceCheck на iOS дают вердикт: приложение подлинное, установлено из
стора, устройство не подделано. Критично понимать: вердикт — это подписанный токен, который обязан
проверять ваш бэкенд. Клиентский код вида if (integrityOk) { proceed() } бесполезен ровно так же, как
любая другая клиентская проверка.
от которой зависит безопасность"] --> Q1{"Что произойдёт,
если её обойти?"} Q1 -->|"Ущерб пользователю
этого устройства"| Client["Можно на клиенте:
биометрия, таймаут сессии,
маскирование экрана"] Q1 -->|"Ущерб компании
или другим людям"| Q2{"Может ли сервер
проверить это сам?"} Q2 -->|Да| Server["Только на сервере:
цены, лимиты, права,
владение объектом"] Q2 -->|"Нет, нужен контекст
устройства"| Attest["Аттестация:
Play Integrity / App Attest,
токен проверяет бэкенд"] Client --> Note1["Клиентскую проверку дублируем
на сервере, если она влияет на данные"] Attest --> Note2["Вердикт — сигнал риска, а не допуск:
степ-ап, лимиты, ручная проверка"] style Server stroke:#4fa373,stroke-width:2px style Attest stroke:#4bb2c9,stroke-width:2px
Способ проверять себя: возьмите любую свою «защиту» и спросите — что будет, если злоумышленник просто удалит эту строчку из кода? Если ответ «получит доступ к чужим данным» — проверка стоит не в том месте.
Платформенная поверхность: разрешения, deep links, WebView, экран
Часть уязвимостей мобильного вообще не про криптографию, а про то, что приложение слишком открыто наружу.
Экспортированные компоненты (Android). Activity, Service, BroadcastReceiver с intent-filter доступны
другим приложениям; с API 31 атрибут android:exported обязателен явно. Экспортируем только то, что задумано
как публичный вход, и валидируем всё, что приходит в Intent, как недоверенный ввод.
Deep links. Кастомную схему myapp:// может объявить любое приложение и перехватить редирект. Поэтому
OAuth-редиректы и чувствительные ссылки идут через проверенные механизмы — App Links с assetlinks.json
на Android и Universal Links с apple-app-site-association на iOS, где владение доменом доказывается
криптографически.
WebView — самая частая дыра гибридов. addJavascriptInterface открывает JS-коду мост в нативный код;
недоверенный URL в WebView с включённым JS и доступом к файлам — приглашение. Не грузите сторонний контент
в свой WebView, не включайте setAllowFileAccessFromFileURLs, а для авторизации используйте
ASWebAuthenticationSession / Chrome Custom Tabs, где ваш код к содержимому вообще не имеет доступа.
Буфер обмена. Одноразовый код или номер карты в буфере доступны всем. Android 12+ показывает уведомление о чтении буфера, iOS 16+ спрашивает разрешение — но лучше не класть туда секреты и чистить по таймеру.
Снимок экрана в переключателе задач. Система делает скриншот при уходе в фон, и он лежит на диске:
// Android: запрещает и скриншоты, и попадание содержимого в снимок переключателя задач.
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
// iOS аналога FLAG_SECURE нет — закрываем содержимое оверлеем на время ухода в фон.
func sceneWillResignActive(_ scene: UIScene) {
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
blur.frame = window?.bounds ?? .zero
blur.tag = 9090
window?.addSubview(blur)
}
func sceneDidBecomeActive(_ scene: UIScene) { window?.viewWithTag(9090)?.removeFromSuperview() }
Разрешения и приватность. Каждое лишнее разрешение — и поверхность атаки, и риск на ревью. iOS требует
PrivacyInfo.xcprivacy с декларацией причин использования ряда API, Google Play — заполненную форму Data
Safety; расхождение между декларацией и реальным поведением SDK — частая причина отклонения релиза
(см. релиз и сторы).
Логи и крэш-репорты. print(response) доживает до релиза чаще, чем кажется. Нужен структурированный
логгер, который в release-сборке выкидывает всё, кроме warning/error, и явный список полей, маскируемых
в breadcrumbs крэш-репортера.
Архитектура: где в приложении живёт безопасность
Чтобы всё это не расползлось по экранам, безопасность оформляется как слой — в той же логике, что и остальные слои из статьи об архитектуре: один интерфейс, две платформенных реализации, тесты на контракт.
Что даёт такая раскладка:
- Политика выражена типом, а не строкой в коде — нельзя случайно положить refresh-токен с политикой «настройки интерфейса», компилятор требует явного выбора.
wipeAll()существует как операция. Выход из аккаунта, «выйти на всех устройствах», ошибка «токен отозван» — все они сходятся в один метод, который чистит Keychain/Keystore, локальную БД, кэш изображений и хранилище WebView. Без него «выход» оставляет данные на диске.- Обновление токенов в одной точке. Гонка на 401 (десять параллельных запросов, десять одновременных refresh, ротация убивает семейство токенов и разлогинивает всех) — классика; лечится единственной сериализованной точкой обновления. Ротация и reuse detection — в статье о токенах.
Приоритеты: что делать сначала
Бюджет на безопасность конечен, поэтому меры полезно разложить по осям «сколько стоит внедрить и поддерживать» и «насколько снижает реальный риск».
Левый верхний угол обязателен в любом приложении, включая пет-проект. Правый верхний вводят, когда в приложении появляются деньги или медицинские данные. Нижний левый — недорогая гигиена, делать заодно. Нижний правый требует явного обоснования: эти меры дорогие и обходятся мотивированным противником, поэтому оправданы только когда цель — поднять цену массовой автоматизированной атаки, а не остановить одного исследователя.
Практический порядок внедрения для команды, начинающей с нуля:
- Убрать секреты из репозитория и из бинаря, вынести всё серверное за прокси.
- Перенести токены в Keychain/Keystore с осмысленным классом доступности.
- Проверить, что ATS не отключён, cleartext запрещён, пользовательские CA не доверяются в release.
- Сделать полноценный
wipeAll()и корректную ротацию refresh-токенов. - Вычистить логи, breadcrumbs и снимки экрана.
- Ввести биометрический step-up на опасных операциях — сразу через CryptoObject / Secure Enclave.
- Прогнать сборку через MobSF и вручную по чек-листу MASTG, исправить найденное.
- И только теперь обсуждать пиннинг, аттестацию и обфускацию — с процессом ротации и мониторингом.
Шаг 7 естественно встраивается в конвейер: статический анализ сборки в CI плюс отдельный прогон на каждый релиз-кандидат (см. безопасность в пайплайне). Как проверять всё это на реальных устройствах — в следующей статье трека.
Типичные ошибки
- Токен в
SharedPreferences/UserDefaults«потому что так во всех туториалах» — самая частая находка любого пентеста мобильного приложения. - Свой велосипед вместо Keystore: AES-ключ, зашитый в код или собранный из констант. Находится за десять минут, а данные при этом считаются защищёнными — что хуже, чем не шифровать.
- Биометрия как булево:
if (biometricOk) { unlock() }обходится одной строкой Frida и создаёт иллюзию защиты у продукта и у аудитора. - Пиннинг без резервного пина и даты истечения — однажды уронит продакшн, а починка займёт столько, сколько длится ревью в App Store.
allowBackup="true"по умолчанию и незаполненныеdataExtractionRules: база с персональными данными уезжает в облако и достаётся с ноутбука.- Аттестация, проверяемая на клиенте — дорогой способ ничего не сделать.
- Проверка прав только на клиенте: спрятанная кнопка «удалить» при открытом API-эндпоинте.
- Логирование тел запросов и ответов в release-сборке, включая заголовок
Authorization. - Секреты в CI, попадающие в артефакты и логи пайплайна (см. управление секретами).
- Отсутствие
wipeAll(): после выхода из аккаунта локальная база остаётся на диске.
Мини-итог
Клиент недоверен. Всё, что вы отправили пользователю, ему принадлежит — код, данные, исполняющая среда. Любую клиентскую проверку можно снять. Значит, решения, от которых зависят деньги и чужие данные, принимает сервер, а клиентские меры измеряются не «защищает / не защищает», а «во сколько обходится атака».
Платформы дали сильные примитивы — пользуйтесь именно ими. Secure Enclave и StrongBox, классы
доступности Keychain, setUserAuthenticationRequired, ATS и network security config, App Attest и Play
Integrity — это годы работы платформенных команд; самодельная криптография уступает им на порядки.
Биометрия имеет смысл только тогда, когда она открывает ключ. Диалог, возвращающий true, — элемент
интерфейса. Подпись челленджа ключом из чипа — механизм безопасности, переживающий компрометацию вашего кода.
Совет по процессу: пройдите по MASVS один раз целиком, честно отметьте несоответствия и превратите список в задачи с приоритетами из квадранта выше. Это дешевле любого пентеста и даёт ту же карту.
Источники
- OWASP. Mobile Application Security — MASVS (требования) и MASTG (тесты и техники), основной справочник по теме.
- Apple. Apple Platform Security Guide — Secure Enclave, Data Protection и Keychain изнутри.
- Apple. Keychain Services, Local Authentication, DeviceCheck и App Attest.
- Apple. Preventing Insecure Network Connections
— ATS и
NSPinnedDomains. - Android Developers. Security best practices, Android Keystore, Biometric authentication, Network security configuration.
- Google. Play Integrity API — верификация вердикта на сервере.
- IETF. RFC 8252: OAuth 2.0 for Native Apps и RFC 7636: PKCE.
- Frida, objection, jadx, MobSF — инструменты, которыми вас будут исследовать; полезно запустить их по своей сборке первым.
- NIST. SP 800-63B: Digital Identity Guidelines — чем биометрия является и чем не является с точки зрения аутентификации.
Что дальше
Тестирование мобильного: юнит, UI, устройства, фермы, бета-каналы — как проверять всё вышеописанное автоматически: где заканчиваются юнит-тесты и начинается инструментальный прогон на реальном устройстве, зачем нужны фермы устройств, как ловить регрессии на матрице из десятков моделей и версий ОС и что даёт бета-канал перед раскаткой на всех.