(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-приложения уязвимы к этой атаке.
Подводя итоги
В заключение при внедрении биометрии в мобильных приложениях важно соблюдать три ключевых принципа:
- Привязка к криптографии. Используйте BiometricPrompt + CryptoObject на Android и SecAccessControl с флагами биометрии на iOS, чтобы сама платформа разгружала секреты (ключи, токены) только после успешного скана.
- Правильная конфигурация ключей. Задавайте в Keystore setUserAuthenticationRequired(true), setUserAuthenticationValidityDurationSeconds(-1) и setInvalidatedByBiometricEnrollment(true), а в Keychain — kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly и biometryCurrentSet, чтобы ключи не оказались доступны без надлежащей аутентификации.
- Регулярная валидация. Интегрируйте в CI/CD автоматизированные проверки с эмуляцией обходов (например, через Frida), а также плановые пентесты и код‑ревью на предмет уязвимостей.
Следуя этим рекомендациям, вы превратите биометрию в полноценный криптозащитный механизм. Если у вас остались вопросы — пишите в Телеграм @nomardt, буду рад пообщаться.
Что еще можно изучить/почитать по теме
Android-разработка
- Android Keystore system | Security
- How Secure is your Android Keystore Authentication? | WithSecure™ Labs
- Android keystore audit
- BiometricPrompt | API reference | Android Developers
- KeyguardManager | API reference | Android Developers
- KeyGenParameterSpec.Builder | API reference | Android Developers
- Mobile Pentesting 101 — Bypassing Biometric Authentication — Security Café
iOS-разработка
- Keychain services | Apple Developer Documentation
- Implementing Secure Biometric Authentication on Mobile Applications
- Local Authentication | Apple Developer Documentation
- Logging a User into Your App with Face ID or Touch ID | Apple Developer Documentation
- Using the iOS Keychain with Biometrics • Andy Ibanez
- Secure Enclave
Flutter-разработка
- flutter_secure_storage | Flutter package
- local_auth | Flutter package
- did_change_authlocal | Flutter package
- Detect when there is a biometric change in local_auth Flutter — Stack Overflow
- flutter_biometric_change_detector | Flutter package
О проверке безопасности
- Репозиторий Frida для анализа безопасности приложений
- Стандарт проверки безопасности приложений OWASP