Мобильная разработка Безопасность мобильного: хранение секретов, биометрия, пиннинг, реверс
0%

Безопасность мобильного: хранение секретов, биометрия, пиннинг, реверс

Безопасность мобильного: хранение секретов, биометрия, пиннинг, реверс

В вебе граница безопасности проходит по HTTP-запросу. Ваш код живёт на сервере, браузер получает разметку и скрипты, а всё важное — пароли, ключи, бизнес-правила — остаётся там, куда чужой человек не дотянется. Фронтенд можно править в DevTools сколько угодно: сервер всё равно пересчитает цену и проверит права.

На мобильном граница сдвинута. Вы отдаёте пользователю сам бинарь: скомпилированный код, ресурсы, конфиги, зашитые строки. Вместе с ним — данные: кэш, локальную базу, токены сессии, черновики. И вместе с ними — исполняющую среду, которую пользователь может рутовать, отлаживать и подменять по частям. Единственное, что остаётся вашим, — сервер. Отсюда принцип, из которого выводится вся статья:

Мобильное приложение — недоверенный клиент, работающий на территории противника. Всё, что вы делаете на устройстве, — не барьер, а повышение цены атаки. Барьер существует только на сервере.

Это не значит, что клиентская безопасность бессмысленна. Разница между «токен в SharedPreferences открытым текстом» и «токен под ключом в StrombBox, привязанным к биометрии» — это разница между «телефон потеряли в баре, деньги ушли» и «телефон потеряли в баре, ничего не произошло». Просто ценность каждой меры надо считать честно: от какого противника она защищает и сколько стоит в разработке и поддержке.

Полезно держать в голове устройство песочницы и подписи из статьи о платформах: мобильная безопасность во многом есть следствие того, как устроены изоляция процессов и цепочка доверия при установке. Общая теория (модель угроз, прикладная криптография, OAuth, токены) разобрана в треке по безопасности; здесь мы приземляем её на API двух платформ.

Модель угроз: против кого мы вообще защищаемся

Главная ошибка мобильной безопасности — «защищаться вообще». Получается карго-культ: пиннинг без процесса ротации, шифрование базы ключом, лежащим рядом с базой, детект jailbreak, снимаемый одной строкой Frida. Начинать надо с вопроса «кто противник и что он получит». Противников пять, и они очень разные.

  • Сетевой наблюдатель — кафе, аэропорт, скомпрометированный роутер, DPI оператора. Хочет читать и подменять трафик. Против него: TLS, ATS и cleartext-политики, иногда пиннинг.
  • Вор с устройством в руках — заблокированным или, что хуже, разблокированным. Хочет войти и вывести деньги. Против него: классы доступности хранилищ, биометрический гейт, таймаут сессии, отзыв устройства.
  • Вредоносное приложение на том же устройстве — ограничено песочницей, но живёт в общем мире: буфер обмена, экспортированные компоненты, URL-схемы, скриншоты. Против него: гигиена межпроцессных границ.
  • Владелец устройства как противник — самый недооценённый класс. Хочет бесплатную подписку, чит в игре, автоматизацию вашего API. У него root, Frida, отладчик, пересборка. Клиентские проверки против него бесполезны в принципе — работает только серверная авторизация и аттестация.
  • Ваша собственная цепочка поставки — сторонний SDK выполняется в вашем процессе с вашими правами: читает вашу песочницу, видит ваши токены, ходит в свою сеть. Формально не «атака», но именно так данные утекают чаще всего (цепочка поставки).

Стрелки внутрь устройства идут отовсюду, и закрыть их все нельзя. Единственный узел без входящих стрелок от противника — сервер. Проектируйте так, чтобы компрометация клиента стоила пользователю его собственных данных, а не данных всех остальных.

Формальный каркас для этого разговора существует: OWASP MASVS с группами требований MASVS-STORAGE, -CRYPTO, -AUTH, -NETWORK, -PLATFORM, -CODE, -RESILIENCE и MASTG — набор конкретных тестов к ним (mas.owasp.org). Держите его открытым при проектировании: он избавляет от споров «а нужно ли нам это» — там прямо написано, какое требование к какому уровню зрелости относится.

Хранение секретов: где что лежит и кто это прочитает

Первый вопрос почти любого мобильного разработчика — «куда положить токен?». Ответ зависит от того, кому вы не хотите его отдавать: платформы дают не одно хранилище, а лестницу с разными свойствами.

Что лежит на устройстве и кто это прочитает

Три вывода из этой таблицы стоят всей остальной статьи:

  1. UserDefaults и SharedPreferences — не хранилище секретов. Это plist и XML в песочнице: уезжают в бэкап, читаются с рутованного устройства текстовым редактором, переживают переустановку.
  2. Песочница спасает от чужого приложения, но не от владельца. Столбец «другое приложение» почти весь зелёный — это заслуга ОС, а не ваша. Столбец «root + Frida» почти весь красный, и изменить это нельзя.
  3. Аппаратный ключ — единственная строка с прочерком почти везде. Не потому, что чип волшебный, а потому, что ключ физически не покидает его: даже с 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 дней, и пиннинг листа означает релиз каждые три месяца под угрозой полной неработоспособности флота.

// 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 — это ревью, а ревью — это дни. Поэтому вводить пиннинг можно только вместе с процессом:

  1. Минимум два пина: текущий ключ и заранее сгенерированный резервный, хранящийся отдельно. Один пин — бомба замедленного действия.
  2. Дата истечения (expiration): по её наступлении пиннинг отключается сам, и приложение продолжает работать на обычной проверке цепочки. Лучше деградировать до стандартной безопасности, чем стать кирпичом.
  3. Kill-switch — возможность выключить пиннинг удалённо, но не через тот же канал, который он защищает (обычно подписанный конфиг на отдельном домене).
  4. Мониторинг: метрика «ошибки 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() } бесполезен ровно так же, как любая другая клиентская проверка.

Способ проверять себя: возьмите любую свою «защиту» и спросите — что будет, если злоумышленник просто удалит эту строчку из кода? Если ответ «получит доступ к чужим данным» — проверка стоит не в том месте.

Часть уязвимостей мобильного вообще не про криптографию, а про то, что приложение слишком открыто наружу.

Экспортированные компоненты (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 — в статье о токенах.

Приоритеты: что делать сначала

Бюджет на безопасность конечен, поэтому меры полезно разложить по осям «сколько стоит внедрить и поддерживать» и «насколько снижает реальный риск».

Левый верхний угол обязателен в любом приложении, включая пет-проект. Правый верхний вводят, когда в приложении появляются деньги или медицинские данные. Нижний левый — недорогая гигиена, делать заодно. Нижний правый требует явного обоснования: эти меры дорогие и обходятся мотивированным противником, поэтому оправданы только когда цель — поднять цену массовой автоматизированной атаки, а не остановить одного исследователя.

Практический порядок внедрения для команды, начинающей с нуля:

  1. Убрать секреты из репозитория и из бинаря, вынести всё серверное за прокси.
  2. Перенести токены в Keychain/Keystore с осмысленным классом доступности.
  3. Проверить, что ATS не отключён, cleartext запрещён, пользовательские CA не доверяются в release.
  4. Сделать полноценный wipeAll() и корректную ротацию refresh-токенов.
  5. Вычистить логи, breadcrumbs и снимки экрана.
  6. Ввести биометрический step-up на опасных операциях — сразу через CryptoObject / Secure Enclave.
  7. Прогнать сборку через MobSF и вручную по чек-листу MASTG, исправить найденное.
  8. И только теперь обсуждать пиннинг, аттестацию и обфускацию — с процессом ротации и мониторингом.

Шаг 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 один раз целиком, честно отметьте несоответствия и превратите список в задачи с приоритетами из квадранта выше. Это дешевле любого пентеста и даёт ту же карту.

Источники

Что дальше

Тестирование мобильного: юнит, UI, устройства, фермы, бета-каналы — как проверять всё вышеописанное автоматически: где заканчиваются юнит-тесты и начинается инструментальный прогон на реальном устройстве, зачем нужны фермы устройств, как ловить регрессии на матрице из десятков моделей и версий ОС и что даёт бета-канал перед раскаткой на всех.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов