64
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Как не надо внедрять биометрическую аутентификацию в мобильных приложениях и как сделать это правильно

Время чтения 33 минуты
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
64
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

Привет! Меня зовут Ярослав Макаров, я занимаюсь анализом защищенности мобильных и веб-приложений в Singleton Security. Моя задача — находить уязвимости в коде приложений и помогать командам исправлять их до того момента, как они станут известны другим.

Биометрия уже давно стала нормой в мобильных приложениях: от сканера отпечатков пальцев до Face ID и сканеров радужки глаза — все они рассматриваются как современные и удобные способы подтверждения личности. Пользователю достаточно просто посмотреть или прикоснуться для доступа к данным, что делает процесс быстрым и удобным. Однако за этой внешней простотой и комфортом нередко скрываются серьезные проблемы в реализации системы безопасности биометрических данных. Даже при использовании стандартных API для биометрии существует риск того, что система защиты может оказаться неэффективной или даже обманутой в случаях физического доступа к устройству злоумышленником. В этом материале я расскажу о том, почему использование визуального подтверждения не обеспечивает полной безопасности и как можно внедрить биометрическую систему, которая действительно может служить надежным барьером. 

Вот три распространенные ошибки, которые делают защиту менее эффективной, и способы их исправления. Статья универсальна и может быть полезна разработчикам независимо от платформы (Android или iOS), а также разработчикам на Flutter.

Как не надо внедрять биометрическую аутентификацию в мобильных приложениях и как сделать это правильно

(Event-bound Authentication) Использование аутентификации «по событию» для доступа в приложение

Android (native)

В современных версиях Android наиболее часто используемым классом для реализации биометрической аутентификации является androidx.biometric.BiometricPrompt. Однако из-за ограниченной и неоднозначной документации разработчики нередко внедряют небезопасные решения, которые внешне работают корректно, но по факту обходят ключевые механизмы безопасности Android, в первую очередь защиту на базе криптографических ключей в Keystore.

Одна из самых распространенных ошибок — реализация аутентификации «по событию», когда приложение просто реагирует на успешное прохождение биометрической проверки (true), не связывая ее с дешифровкой реального секрета. В этом случае биометрия становится декоративной: злоумышленнику достаточно подменить результат аутентификации или вызвать нужный метод напрямую — и защита больше не работает.

Основной метод BiometricPrompt, отвечающий за безопасность, — это authenticate. Он представлен в двух вариантах: с PromptInfo и с PromptInfo + CryptoObject. Первый используется для простой проверки биометрии и не обеспечивает защиты данных — это всего лишь разблокировка интерфейса. Второй выглядит безопаснее, так как позволяет связать биометрию с криптографией через CryptoObject. Но есть нюанс: если CryptoObject не используется в реальных криптооперациях (например, для расшифровки ключа аутентификации из Android Keystore), защита становится формальной. Чтобы биометрия действительно обеспечивала безопасность, она должна быть не просто проверкой, а частью механизма, который технически не даст доступ к данным без нее.

Отмечу, что описанная уязвимость характерна не только для новых API и аналогичные ошибки регулярно встречаются и при использовании FingerprintManager.

Давайте рассмотрим пример уязвимого кода (хардкод секрета используется в демонстрационных целях). Листинг 1, Kotlin:


class VaultActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_vault)

        val executor = ContextCompat.getMainExecutor(this)
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Доступ к кошельку")
            .setSubtitle("Подтвердите отпечаток")
            .setNegativeButtonText("Отмена")
            .build()

        val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                // Просто показываем приватный ключ без криптографической проверки
                findViewById<TextView>(R.id.privateKeyView).apply {
                    text = "Приватный ключ:n4JRtcs3T...QTq"
                    visibility = View.VISIBLE
                }
            }
        })

        findViewById<Button>(R.id.unlockVaultButton).setOnClickListener {
            biometricPrompt.authenticate(promptInfo)
        }
    }
}

 

Чтобы получить доступ к такому приватному ключу, злоумышленнику достаточно запустить простой скрипт в тулките динамической инструментализации Frida.

Как исправить?

Безопасная реализация должна использовать:

  • Android Keystore для генерации ключа биометрии;
  • Cipher, привязанный к биометрии;
  • CryptoObject для обеспечения дешифрования/шифрования секрета (аутентификационного токена) только после успешной биометрической аутентификации.

Пример защищенной реализации (листинг 2, Kotlin):


class VaultActivity : AppCompatActivity() {

    private val keyAlias = "vault_key"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_vault)

        // Генерирует AES-ключ в AndroidKeyStore с привязкой к биометрической аутентификации.
        // Сохранение происходит под псевдонимом keyAlias.
        // Полную реализацию см. в Листингах 5 и 6.
        generateBiometricKey()
 
        // Возвращает Cipher, инициализированный в режиме ENCRYPT_MODE 
        // с только что созданным ключом из AndroidKeyStore (keyAlias).
        // Этот Cipher нужно обернуть в BiometricPrompt.CryptoObject 
        // и передать в BiometricPrompt.authenticate(...),
        // чтобы система знала, что биометрия используется для разблокировки 
        // доступа к этому Cipher (связанному с ним ключу).
        val cipher = getCipher()
        val executor = ContextCompat.getMainExecutor(this)
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Доступ к кошельку")
            .setSubtitle("Подтвердите отпечаток")
            .setNegativeButtonText("Отмена")
            .build()

        val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                try {
                    val cipher = result.cryptoObject?.cipher ?: throw IllegalStateException("Cipher is null")
                    val secret = "4JRtcs3T...QTq".toByteArray(Charsets.UTF_8) 
// упрощенная реализация, в реальном приложении код не должен знать секреты
                    val encrypted = cipher.doFinal(secret)
                    val encryptedBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)

                    findViewById<TextView>(R.id.privateKeyView).apply {
                        text = "Приватный ключ (зашифрован биометрическим ключом):n$encryptedBase64"
                        visibility = View.VISIBLE
                    }
                } catch (e: Exception) {
                    Log.e("Vault", "Ошибка шифрования: ${e.message}")
                }
            }
        })

        findViewById<Button>(R.id.unlockVaultButton).setOnClickListener {
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }
 
    private fun getCipher(): Cipher {

        val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
        val secretKey = ks.getKey(keyAlias, null) as SecretKey
        return Cipher.getInstance("AES/GCM/NoPadding").apply {

            init(Cipher.ENCRYPT_MODE, secretKey)

        }

    }
    // ============
    // Остальной код
    // ============
    // …
}

iOS

В iOS для биометрической аутентификации используется фреймворк LocalAuthentication. Здесь, как и в Android, несмотря на видимую надежность биометрической аутентификации, существует распространенная ошибка реализации, связанная с аутентификацией «по событию».

Основной компонент фреймворка LocalAuthentication, обеспечивающий доступ по биометрии, — это метод evaluatePolicy. Он используется для запуска системного Face ID / Touch ID prompt и возвращает булевый результат в колбэке. На практике он применяется в двух вариантах: либо как самостоятельная проверка (evaluatePolicy -> success == true), либо как часть более безопасного доступа к данным через Keychain с SecAccessControl. Первый вариант — это просто подтверждение личности на уровне интерфейса, не связанное с криптографией. Второй вариант позволяет использовать биометрию как условие для дешифровки секрета, хранящегося в Keychain. Но и здесь возможна ошибка: если evaluatePolicy используется отдельно от Keychain, а секрет (аутентификационный токен) хранится напрямую в коде или извлекается без защиты, биометрия теряет смысл. Чтобы защита была реальной, биометрия должна не просто открывать доступ, а становиться необходимым условием криптографической операции — именно так работает Secure Enclave и связка Keychain + AccessControl.

Давайте рассмотрим пример уязвимого кода (хардкод секрета используется в демонстрационных целях). Листинг 3, Swift:


class VaultViewController: UIViewController {

    override func viewDidLoad() {
        // ============
        // Настройка UI
        // ============
        // ...
    }

    @objc func authenticateUser() {
        let context = LAContext()
        var error: NSError?

        let reason = "Подтвердите отпечаток для доступа к кошельку"

        if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) {
                     success, authError in DispatchQueue.main.async {
                    if success {
                        // Просто показываем приватный ключ без криптографической проверки
                        if let keyLabel = self.view.viewWithTag(101) as? UILabel {
                            keyLabel.text = "Приватный ключ:n4JRtcs3T...QTq"
                        }
                    } else {
                        print("Аутентификация не удалась: (authError?.localizedDescription ?? "Неизвестная ошибка")")
                    }
                }
            }
        } else {
            print("Биометрия недоступна: (error?.localizedDescription ?? "Неизвестная ошибка")")
        }
    }
}

Как исправить?

  • Использовать Keychain для хранения секретов.
  • Извлекать секрет только через Keychain, после успешной биометрии (kSecAccessControlBiometryCurrentSet).
  • Не отображать секрет напрямую по success == true из evaluatePolicy.

Пример того, как безопасно сохранять данные в Keychain для биометрической аутентификации (листинг 4, Swift):


var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(kCFAllocatorDefault,
                                                          kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
                                                          SecAccessControlCreateFlags.biometryCurrentSet,
                                                          &error) else {
    // не удалось создать объект AccessControl
    return
}
var query: [String: Any] = [:]
query[kSecClass as String] = kSecClassGenericPassword
query[kSecAttrLabel as String] = "label_for_auth_token" as CFString
query[kSecAttrAccount as String] = "App Account" as CFString
query[kSecValueData as String] = "here_goes_auth_token".data(using: .utf8)! as CFData 
// в реальном приложении хардкод токенов недопустим
query[kSecAttrAccessControl as String] = accessControl

let status = SecItemAdd(query as CFDictionary, nil)
if status == noErr {
    // успешно сохранено
} else {
    // ошибка при сохранении
}

Flutter

В Flutter на момент написания статьи ситуация еще более уязвимая: несмотря на поддержку биометрической аутентификации через плагин local_auth, большая часть реализаций ограничивается простым получением булевого результата (успех/неудача) после показа системного диалога. Это и есть типичная аутентификация «по событию» — когда факт прохождения биометрии никак не связан с криптографической операцией. Хуже того, в экосистеме Flutter нет полноценной поддержки Android Keystore или iOS Keychain с криптографической привязкой к биометрии на уровне самого Dart-кода. Библиотека flutter_secure_storage способна частично улучшить ситуацию на iOS, где позволяет хранить секреты в Keychain с требованием Face ID / Touch ID при доступе, но на Android она не защищает от атак инструментами вроде Frida.

Как исправить?

Без написания нативного кода с использованием BiometricPrompt и CryptoObject (Android) или SecAccessControl (iOS) добиться настоящей привязки биометрии к расшифровке секрета невозможно, а значит, защита остается визуальной, но не криптографической.

(Unauthenticated Access to Key) Доступ к ключу биометрии без аутентификации

Android (native)

При создании ключа для биометрии разработчики часто полагаются на стандартные настройки. В одном случае ключ остается доступным постоянно, что позволяет его использовать и вовсе без биометрии (аутентификации), через средства динамической инструментализации по типу Frida. В другом случае ключ доступен лишь несколько минут после аутентификации. Однако и это небезопасно, ведь, например, злоумышленник может вырвать из рук жертвы только что разблокированный смартфон и войти в приложение банка. В таких условиях биометрическая аутентификация теряет смысл: несмотря на видимость безопасности, она больше не обеспечивает реальную защиту данных.

Как исправить?

Для повышения надежности при генерации ключей следует в KeyGenParameterSpec.Builder(…) явно указывать setUserAuthenticationRequired(true) и setUserAuthenticationValidityDurationSeconds(-1). Дополнительно рекомендуется использовать setUnlockedDeviceRequired(true), чтобы ключи оставались доступными только при разблокированном устройстве — это снижает риск доступа при атаках через ADB или во время холодного старта. В примере из предыдущего сегмента (листинг 2) начало функции generateBiometricKey может выглядеть так (листинг 5, Kotlin):


private fun generateBiometricKey() {
    val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    if (ks.containsAlias(keyAlias)) return

    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    val builder = KeyGenParameterSpec.Builder(
        keyAlias,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setKeySize(256)
        .setUserAuthenticationRequired(true)
        .setUserAuthenticationValidityDurationSeconds(-1)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        builder.setUnlockedDeviceRequired(true).setIsStrongBoxBacked(true)
    }

    // ============
    // Остальной код
    // ============
    // ...
}

 

iOS

В iOS можно легко допустить критическую ошибку при сохранении биометрически защищенных данных в Keychain: при указании флагов kSecAttrAccessibleWhenUnlockedThisDeviceOnly или kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly данные остаются доступными, даже если на устройстве отсутствует пароль. Эти атрибуты не требуют установленного passcode и не очищают хранилище при его отключении. В результате биометрия становится формальной: на устройстве без кода доступа Keychain всегда открыт, а при его сбросе данные остаются, позволяя атакующему получить доступ без повторной аутентификации.

Как исправить?

Для надежной реализации биометрической защиты необходимо использовать флаг kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly — он требует, чтобы на устройстве был установлен пароль, и автоматически удаляет записи при его отключении (см. Листинг 4). Только в такой конфигурации LAContext и биометрия действительно становятся частью защиты, а не интерфейсным фильтром.

Flutter

Во Flutter большинство библиотек хранения данных (например, flutter_secure_storage) не проверяет наличие надежной блокировки экрана и не удаляет данные при ее отключении. Даже если используется Face ID или отпечаток, ключи могут остаться доступными, если пользователь снял защиту устройства или установил слабый ПИН-код. На Android и iOS поведение хранилищ по умолчанию не требует установленного пароля, и Flutter не дает возможности задать более строгие флаги, как это можно сделать в нативном коде.

Как исправить?

Без собственных нативных плагинов Flutter не позволяет гарантировать, что данные будут защищены в случае сброса экрана блокировки. Для критичных приложений (банки, кошельки, медицина) необходимо уходить на нативные реализации, где можно явно указать строгие параметры Keystore и Keychain.

(Enrollment Bypass) Доступ сохраняется после смены биометрических данных

Android (native)

На Android по умолчанию ключи в Keystore не инвалидируются при добавлении новых отпечатков пальцев. Это значит, что, если злоумышленник получит физический доступ к устройству и добавит свой отпечаток, он сможет пройти биометрию и расшифровать данные, даже если ключ был создан до этого. Без явной настройки система будет считать нового пользователя «доверенным».

Как исправить?

При генерации ключа в KeyGenParameterSpec.Builder(…) необходимо указать setInvalidatedByBiometricEnrollment(true). Это гарантирует, что при изменении биометрических данных ключ станет недействительным и потребует повторной аутентификации. Завершение примера из листингов 2 и 5 с корректной реализацией биометрии (листинг 6, Kotlin):


private fun generateBiometricKey() {
    // ============
    // Код из Листинга 5
    // ============
    // ...

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        builder.setInvalidatedByBiometricEnrollment(true)
    }

    keyGenerator.init(builder.build())
    keyGenerator.generateKey()
}

 

iOS

В iOS при хранении данных в Keychain с флагом biometryAny (по умолчанию) биометрия проверяется, но не привязывается к конкретному отпечатку или лицу. Добавил новое лицо — получил доступ. Это критично: злоумышленник с физическим доступом может добавить свои биометрические данные и расшифровать содержимое Keychain.

Как исправить?

Нужно использовать SecAccessControlCreateFlags.biometryCurrentSet при создании SecAccessControl (см. Листинг 4). В этом случае доступ будет возможен только с теми биометрическими данными, что были на устройстве в момент создания ключа. Любое изменение (даже добавление нового лица) автоматически инвалидирует доступ.

Flutter

Во Flutter отсутствует возможность задать флаг, аналогичный biometryCurrentSet в iOS или setInvalidatedByBiometricEnrollment(true) в Android. Это значит, что при добавлении нового отпечатка или лица доступ к ранее зашифрованным данным может сохраниться. Даже при использовании плагина flutter_secure_storage хранилище не инвалидируется автоматически при смене биометрии.

Существуют плагины вроде did_change_authlocal и более новый flutter_biometric_change_detector, которые позволяют отслеживать изменения биометрии и системного пароля. Однако обе эти реализации работают на уровне приложения (application level), а не системном, поэтому этот подход остается слабым: к моменту обнаружения изменения злоумышленник может уже получить доступ к данным.

Как исправить?

Полная защита от подмены биометрии во Flutter возможна только через собственные нативные плагины с использованием SecAccessControlCreateFlags.biometryCurrentSet (iOS) и setInvalidatedByBiometricEnrollment(true) (Android). Без них Flutter-приложения уязвимы к этой атаке.

Подводя итоги

В заключение при внедрении биометрии в мобильных приложениях важно соблюдать три ключевых принципа:

  1. Привязка к криптографии. Используйте BiometricPrompt + CryptoObject на Android и SecAccessControl с флагами биометрии на iOS, чтобы сама платформа разгружала секреты (ключи, токены) только после успешного скана.
  2. Правильная конфигурация ключей. Задавайте в Keystore setUserAuthenticationRequired(true), setUserAuthenticationValidityDurationSeconds(-1) и setInvalidatedByBiometricEnrollment(true), а в Keychain — kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly и biometryCurrentSet, чтобы ключи не оказались доступны без надлежащей аутентификации.
  3. Регулярная валидация. Интегрируйте в CI/CD автоматизированные проверки с эмуляцией обходов (например, через Frida), а также плановые пентесты и код‑ревью на предмет уязвимостей.

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

Что еще можно изучить/почитать по теме

Android-разработка

iOS-разработка

Flutter-разработка

О проверке безопасности

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники