Securing Biometric Authentication: Defending Against Frida Bypass Attacks
17 min read
September 29, 2024
Table of contents
Introduction
In the previous chapter, I focused on the basic concepts of local authentication and its vulnerabilities. In this chapter, I'll delve deeper into the system, explaining how attackers can bypass biometric authentication using Frida and, more importantly, how developers can secure their implementations against these types of attacks.
Here’s what I’ll cover:
- How the Bypass Works: I’ll explain the mechanisms behind how attackers use Frida to bypass biometric authentication, altering key functions to simulate successful authentication.
- How the Android Keystore and CryptoObject Work: A deep dive into the role of the Android Keystore and CryptoObject in securing biometric authentication and why they are critical for protecting sensitive operations.
- Implementing Changes to Our Insecure App: I’ll walk through the steps to strengthen our previously insecure application by making necessary changes to improve biometric security.
- Testing the Changes: Finally, I’ll demonstrate how to test these improvements and verify that the app is now more resilient to bypass attempts.
The goal of this chapter is to dive deep into the inner workings of Frida, understand how these attacks are carried out, and explore the necessary steps to defend against them. While Frida can be a powerful tool for attackers, with the right defense mechanisms, it’s possible to significantly reduce the risk of these bypass attempts.
Bypassing Biometric Authentication with Frida: How it Works
The Frida script is designed to bypass biometric authentication in Android applications by manipulating the flow of biometric prompts at runtime. Frida, a powerful dynamic instrumentation toolkit, allows attackers to hook into application methods and modify their behavior on the fly. In this case, the script overrides how biometric authentication operates, forcing the app to treat any authentication attempt as successful, even if no real biometric data is provided.
Hooking into Biometric Authentication Methods
The first step the script takes is hooking into Android's biometric authentication methods. The script supports different Android APIs, from the modern BiometricPrompt.authenticate()
method introduced in Android 9 (API 28) to the older FingerprintManager.authenticate()
for pre-Android 9 versions.
var biometricPrompt = Java.use('android.hardware.biometrics.BiometricPrompt')['authenticate'].overload('android.os.CancellationSignal', 'java.util.concurrent.Executor', 'android.hardware.biometrics.BiometricPrompt$AuthenticationCallback');
console.log("Hooking BiometricPrompt.authenticate()...");
biometricPrompt.implementation = function (cancellationSignal, executor, callback) {
console.log("[BiometricPrompt.BiometricPrompt()]: Hooked!");
var authenticationResultInst = getBiometricPromptAuthResult();
callback.onAuthenticationSucceeded(authenticationResultInst); // Force success
}
In the code above, the script hooks into the BiometricPrompt.authenticate()
method and replaces its implementation. Normally, this method would invoke biometric authentication and wait for user input (such as fingerprint recognition). However, the script intercepts the call and directly creates a fake authentication result. It then invokes the onAuthenticationSucceeded()
callback, making the app believe that biometric authentication was successful.
Forcing onAuthenticationSucceeded()
The key part of this script is the forced invocation of onAuthenticationSucceeded()
. This callback is designed to be triggered only after the user has successfully authenticated. However, the script manipulates it by passing in a fabricated AuthenticationResult
object that simulates success. Here's how this is done:
function getBiometricPromptAuthResult() {
var sweet_cipher = null; // Null cipher
var cryptoObj = Java.use('android.hardware.biometrics.BiometricPrompt$CryptoObject');
var cryptoInst = cryptoObj.$new(sweet_cipher); // Create a CryptoObject with null cipher
var authenticationResultObj = Java.use('android.hardware.biometrics.BiometricPrompt$AuthenticationResult');
var authenticationResultInst = getAuthResult(authenticationResultObj, cryptoInst);
return authenticationResultInst;
}
In this part of the script, a CryptoObject
is created with a null cipher (sweet_cipher = null
). The CryptoObject
usually contains a valid Cipher
, used to perform encryption or decryption. However, in this case, the cipher is set to null, which could allow the app to bypass cryptographic checks if it doesn't properly validate the CryptoObject
. After creating this manipulated CryptoObject
, the script wraps it in an AuthenticationResult
object and passes it to the app, which accepts it as if it came from a valid authentication flow.
Handling Different Android API Versions
To maximize compatibility across different Android versions, the script handles various biometric APIs. It first attempts to hook into the BiometricPrompt
API introduced in Android 9, but it also includes fallback mechanisms for older APIs, like FingerprintManager
:
try { hookBiometricPrompt_authenticate(); }
catch (error) { console.log("hookBiometricPrompt_authenticate not supported on this android version") }
try { hookFingerprintManagerCompat_authenticate(); }
catch (error) { console.log("hookFingerprintManagerCompat_authenticate failed"); }
If the device uses an older version of Android, the script tries to hook into the FingerprintManagerCompat
API, ensuring that the attack works on a broader range of devices. This adaptability is one of the script’s strengths, as it can bypass biometric authentication on both newer and older Android versions.
Why the Attack Works
The success of this attack depends largely on whether the application properly validates the CryptoObject
and its associated Cipher
. If the app simply checks whether onAuthenticationSucceeded()
was called without further validation, the null cipher will bypass both biometric authentication and cryptographic protections. The app might proceed to decrypt sensitive data or grant access to protected areas based on this manipulated authentication flow.
Here’s an example of how to prevent this kind of attack by ensuring that the CryptoObject
and its cipher are properly validated:
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cryptoObject = result.cryptoObject
if (cryptoObject != null && cryptoObject.cipher != null) {
// Perform secure operations only if the cipher is valid
val decryptedData = cryptoObject.cipher?.doFinal(encryptedData)
// Process decrypted data
} else {
Toast.makeText(this, "Invalid CryptoObject or Cipher!", Toast.LENGTH_SHORT).show()
}
}
In the example above, we check if both the CryptoObject
and its Cipher
are non-null before performing any sensitive operations. This would prevent the Frida script from bypassing biometric authentication with a null cipher.
Understanding the Keystore and CryptoObject in Android
Now that we've explored how the Frida script works, it's time to take a closer look at two critical components: the Keystore and CryptoObject. These play a central role in securing local authentication and protecting sensitive data, so let's dive deeper into how they function and why they're essential.
The Android Keystore System
The Android Keystore is a system designed to securely store cryptographic keys, ensuring that sensitive information is protected even if the app is compromised. Keys stored in the Keystore are hardware-backed (if the device supports it), which means that even with root access, extracting these keys directly is extremely difficult. This is crucial for protecting encryption keys, which in turn secure user data.
When generating a key in the Keystore, you can specify various security properties, such as requiring user authentication to use the key. This is done through methods like setUserAuthenticationRequired(true)
and setInvalidatedByBiometricEnrollment(true)
. These settings tie the key's usage to biometric authentication, ensuring that only the authenticated user can perform cryptographic operations like encryption and decryption.
By configuring the Keystore properly, you can ensure that:
- Keys are protected from unauthorized access.
- Keys are invalidated if the biometric enrollment changes, adding another layer of security.
- Keys can only be used after successful biometric authentication, making them inaccessible without the user's consent.
The Role of the CryptoObject
The CryptoObject is a wrapper around cryptographic operations, such as encryption and decryption, and is tightly linked to the biometric authentication process. It works as the bridge between the Android biometric prompt and the cipher created using the Keystore key.
Here’s how the CryptoObject
fits into the authentication flow:
- Tied to the Cipher: When you create a
CryptoObject
, it’s usually tied to aCipher
object that handles encryption and decryption. This means that cryptographic operations are locked behind biometric authentication. - Used in Biometric Authentication: When the user triggers biometric authentication (such as fingerprint scanning), the system verifies the user and, upon successful authentication, returns the
CryptoObject
. At this point, the cipher is “unlocked” and ready to perform the required cryptographic operation. - Ensures Secure Data Handling: By linking the
CryptoObject
to biometric authentication, sensitive data like session keys or tokens can be encrypted and decrypted only when the user successfully authenticates. Without this step, even if an attacker gains access to encrypted data, they cannot decrypt it without biometric authentication.
For example, in the case of encrypting and decrypting sensitive data, the CryptoObject
ensures that the cipher used to handle the encryption or decryption cannot be accessed without the user's biometric credentials.
Steps to Create Secure Local Authentication
Building on our understanding of the Keystore and CryptoObject, we can now focus on defending against the attack. To create secure local authentication in Android, several key elements must be implemented. Here's what you need to consider:
- Generate the Keystore Key: Use the Android Keystore API to create a cryptographic key with the following settings:
setUserAuthenticationRequired(true)
: Ensures that the key can only be used after biometric authentication.setInvalidatedByBiometricEnrollment(true)
: Invalidates the key if the user enrolls a new biometric credential (such as a fingerprint).setUserAuthenticationValidityDurationSeconds(-1)
: This forces the user to authenticate every time the key is used.
- Initialize the Cipher Object: Once the key is generated, initialize a
Cipher
object using the key from the Keystore. This cipher will handle the encryption and decryption operations. - Create the CryptoObject: Use the
Cipher
object to create aBiometricPrompt.CryptoObject
. This object ties the biometric authentication to cryptographic operations. - Handle Authentication Success: Implement the
BiometricPrompt.AuthenticationCallback.onAuthenticationSucceeded()
method. In this callback, retrieve theCipher
object from theCryptoObject
and use it to decrypt critical data, such as a session key or another symmetric key that will be used to access your application’s encrypted data. - Trigger Authentication: Call the
BiometricPrompt.authenticate()
function, passing in theCryptoObject
and the callback defined in the previous steps. This ensures that biometric authentication is required before any decryption occurs.
In a previous post, we used some of these functionalities to create local authentication for a simple application. The main improvement in this approach is the use of the Android Keystore and CryptoObject
to securely tie biometric authentication to cryptographic processes, enhancing overall security.
Understanding the Code: Biometric Authentication with Encryption in Android
Now that we have a solid understanding of the different components and a plan in place, it’s time to start implementing everything into our code. In the next section, we’ll see how the Android Keystore and CryptoObject work together with AES encryption to secure sensitive data, ensuring that only an authenticated user can access it. Let’s dive into the implementation.
We’ll walk through each part of the code, explaining the functions and how they contribute to the overall security of the application. The concepts we just explored—secure key storage in the Keystore and cryptographic operations with the CryptoObject
—are implemented in this code to provide a robust local authentication mechanism.
Let’s dive into the code and see how it all works in practice.
onCreate()
– The Entry Point
The onCreate()
method is where the app starts when the user opens this activity. Here, we set up the layout and define what happens when the buttons are clicked. There are three main actions the user can trigger: encrypt data, authenticate with biometrics, or reset encrypted data.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
createKey()
val btnEncrypt: Button = findViewById(R.id.btn_encrypt)
btnEncrypt.setOnClickListener {
showBiometricPromptForEncryption("PASSWORD")
}
val btnReset: Button = findViewById(R.id.btn_reset)
btnReset.setOnClickListener {
resetEncryptedData()
}
val btnAuthenticate: Button = findViewById(R.id.btn_authenticate)
btnAuthenticate.setOnClickListener {
showBiometricPrompt()
}
}
createKey()
: This function is called right away to generate a cryptographic key in the Android Keystore, which is required for encryption and decryption.- Button Click Listeners: Each button is linked to a function that handles encrypting, authenticating, or resetting data. Clicking the Encrypt button triggers biometric authentication before encrypting the data, while the Authenticate button prompts the user to authenticate in order to decrypt the stored data.
createKey()
– Generating a Secure Key
This function creates a secure key in the Android Keystore. The key is used for both encryption and decryption, and it’s configured with specific security settings, such as requiring biometric authentication every time it’s used.
private fun createKey() {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.setUserAuthenticationValidityDurationSeconds(-1)
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
KeyProperties.KEY_ALGORITHM_AES
: Specifies that the AES encryption algorithm will be used.KeyGenParameterSpec
: This defines the key's properties, like requiring biometric authentication and using the GCM block mode (which allows encryption without padding).setUserAuthenticationRequired(true)
: Ensures that the key can only be used if the user has authenticated via biometrics.
initCipher()
– Preparing for Encryption or Decryption
The initCipher()
function sets up the cryptographic cipher. A cipher is an algorithm that performs encryption or decryption, and in this case, it’s using AES with GCM mode. This function initializes the cipher depending on whether we want to encrypt or decrypt data.
private fun initCipher(mode: Int, iv: ByteArray? = null): Boolean {
return try {
keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val key = keyStore.getKey(keyAlias, null) as SecretKey
cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}")
if (mode == Cipher.ENCRYPT_MODE) {
cipher.init(Cipher.ENCRYPT_MODE, key) // Generate new IV
} else if (mode == Cipher.DECRYPT_MODE && iv != null) {
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) // Use provided IV for decryption
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
- Encryption Mode: If the mode is
ENCRYPT_MODE
, the cipher is initialized to encrypt data, and a new IV (initialization vector) is generated. - Decryption Mode: If the mode is
DECRYPT_MODE
, the cipher is initialized with the existing IV (retrieved from storage) to decrypt data. The IV is critical for successful decryption.
showBiometricPromptForEncryption()
– Encrypting After Biometric Authentication
This function prompts the user to authenticate with biometrics before encrypting the data. After a successful authentication, the cipher is used to encrypt the provided plaintext data.
private fun showBiometricPromptForEncryption(plainText: String) {
val executor = ContextCompat.getMainExecutor(this)
val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cryptoObject = result.cryptoObject
if (cryptoObject != null && cryptoObject.cipher != null) {
try {
val encryptedData = cryptoObject.cipher?.doFinal(plainText.toByteArray())
if (encryptedData != null) {
val iv = cryptoObject.cipher?.iv
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv!!)
Toast.makeText(this@MainActivity, "Encryption successful!", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Encryption error!", Toast.LENGTH_SHORT).show()
}
}
}
})
if (initCipher(Cipher.ENCRYPT_MODE)) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication for Encryption")
.setSubtitle("Use your fingerprint to encrypt data")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
- Biometric Prompt: The app displays a biometric authentication prompt. When the user successfully authenticates, the cipher is used to encrypt the plaintext (
"PASSWORD"
). - Cipher Execution: The
cipher.doFinal()
method encrypts the data and generates an encrypted byte array. This is then stored along with the IV (required for decryption).
showBiometricPrompt()
– Decrypting After Biometric Authentication
This function works similarly to the encryption process but focuses on decryption. After the user authenticates, the cipher decrypts the stored data.
private fun showBiometricPrompt() {
val executor = ContextCompat.getMainExecutor(this)
val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cryptoObject = result.cryptoObject
if (cryptoObject != null) {
val decryptedData = decryptData(cryptoObject)
if (decryptedData != null && isValidData(decryptedData)) {
showSuccess()
} else {
Toast.makeText(this@MainActivity, "Decryption failed or invalid data!", Toast.LENGTH_SHORT).show()
}
}
}
})
if (initCipher(Cipher.DECRYPT_MODE, retrieveStoredIV())) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your fingerprint")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
- Decrypting Data: The cipher decrypts the previously encrypted data using the stored IV. The
isValidData()
function checks if the decrypted data matches the expected value ("PASSWORD"
).
resetEncryptedData()
– Clearing Stored Data
This function allows users to clear the encrypted data and IV from SharedPreferences
. This is useful for resetting the app or logging out.
private fun resetEncryptedData() {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.remove("encrypted_data")
editor.remove("iv")
editor.apply()
Toast.makeText(this, "Encrypted data reset.", Toast.LENGTH_SHORT).show()
}
All code
package com.example.localauth
import android.content.Intent
import android.os.Bundle
import android.util.Base64
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import javax.crypto.spec.GCMParameterSpec
class MainActivity : AppCompatActivity() {
private lateinit var cipher: Cipher
private lateinit var keyStore: KeyStore
private val keyAlias = "test"
val expectedData = "PASSWORD".toByteArray()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
createKey()
val btnEncrypt: Button = findViewById(R.id.btn_encrypt)
btnEncrypt.setOnClickListener {
showBiometricPromptForEncryption("PASSWORD")
}
val btnReset: Button = findViewById(R.id.btn_reset)
btnReset.setOnClickListener {
resetEncryptedData()
}
val btnAuthenticate: Button = findViewById(R.id.btn_authenticate)
btnAuthenticate.setOnClickListener {
showBiometricPrompt()
}
}
// Create Key in Keystore
private fun createKey() {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.setUserAuthenticationValidityDurationSeconds(-1) // Require biometric every time
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
// Initialize the cipher for encryption/decryption
private fun initCipher(mode: Int, iv: ByteArray? = null): Boolean {
return try {
keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val key = keyStore.getKey(keyAlias, null) as SecretKey
cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}")
if (mode == Cipher.ENCRYPT_MODE) {
cipher.init(Cipher.ENCRYPT_MODE, key) // Generate new IV
} else if (mode == Cipher.DECRYPT_MODE && iv != null) {
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) // Use provided IV
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Show biometric prompt and tie it to the cipher object
private fun showBiometricPrompt() {
val executor = ContextCompat.getMainExecutor(this)
val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
val cryptoObject = result.cryptoObject
if (cryptoObject != null && cryptoObject.cipher != null) {
try {
val decryptedData = decryptData(cryptoObject)
if (decryptedData == null || !isValidData(decryptedData)) {
Toast.makeText(this@MainActivity, "Decryption failed or invalid data!", Toast.LENGTH_SHORT).show()
} else {
showSuccess()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Decryption error!", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(this@MainActivity, "Authentication succeeded but CryptoObject is missing!", Toast.LENGTH_SHORT).show()
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
}
})
if (initCipher(Cipher.DECRYPT_MODE, retrieveStoredIV())) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your fingerprint")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
// Updated: Fixing how cipher is used after biometric authentication completes
private fun showBiometricPromptForEncryption(plainText: String) {
val executor = ContextCompat.getMainExecutor(this)
val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
val cryptoObject = result.cryptoObject
if (cryptoObject != null) {
try {
// Encrypt after biometric authentication
val encryptedData = cryptoObject.cipher?.doFinal(plainText.toByteArray())
if (encryptedData != null) {
val iv = cryptoObject.cipher?.iv // Get the generated IV
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv!!)
Toast.makeText(this@MainActivity, "Encryption successful!", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Encryption error!", Toast.LENGTH_SHORT).show()
}
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
}
})
if (initCipher(Cipher.ENCRYPT_MODE)) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication for Encryption")
.setSubtitle("Use your fingerprint to encrypt data")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
// Check if decrypted data is valid
private fun isValidData(decryptedData: ByteArray): Boolean {
return decryptedData.contentEquals(expectedData) // Example validation
}
// Decrypt data using the CryptoObject
private fun decryptData(cryptoObject: BiometricPrompt.CryptoObject): ByteArray? {
return try {
val encryptedData = Base64.decode(retrieveEncryptedData(), Base64.DEFAULT)
val iv = retrieveStoredIV() // Retrieve the stored IV
if (initCipher(Cipher.DECRYPT_MODE, iv)) { // Use the retrieved IV
val decryptedData = cryptoObject.cipher?.doFinal(encryptedData)
decryptedData
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun encryptAndStoreData(plainText: String) {
if (initCipher(Cipher.ENCRYPT_MODE)) {
try {
val encryptedData = cipher.doFinal(plainText.toByteArray())
val iv = cipher.iv // Get the generated IV
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "Encryption failed", Toast.LENGTH_SHORT).show()
}
}
}
// Simulate storing encrypted data and IV (replace with actual storage logic)
private fun storeEncryptedDataAndIV(encryptedData: String, iv: ByteArray) {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("encrypted_data", encryptedData)
editor.putString("iv", Base64.encodeToString(iv, Base64.DEFAULT)) // Store the IV as Base64 string
editor.apply()
}
// Retrieve encrypted data and IV
private fun retrieveEncryptedData(): String {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
return sharedPreferences.getString("encrypted_data", "") ?: ""
}
private fun retrieveStoredIV(): ByteArray {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val ivString = sharedPreferences.getString("iv", null)
return Base64.decode(ivString, Base64.DEFAULT)
}
private fun showSuccess() {
Toast.makeText(this, "Authentication successful!", Toast.LENGTH_SHORT).show()
val intent = Intent(this, SuccessActivity::class.java)
startActivity(intent)
finish() // Optionally finish MainActivity to prevent going back without re-authentication
}
private fun resetEncryptedData() {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.remove("encrypted_data") // Remove encrypted data
editor.remove("iv") // Remove IV
editor.apply()
Log.d("Reset", "Encrypted data and IV reset.")
Toast.makeText(this, "Encrypted data reset.", Toast.LENGTH_SHORT).show()
}
}
Testing the application
Once you have the code, you should be able to run it in Android Studio using an emulator without any issues. In a more "realistic" scenario, the application would typically include an input field where the user can enter a password, and the app would verify if it's correct. After that, the user would have the option to set up local authentication, so they wouldn’t need to enter the password every time they log in.
However, due to time constraints, I won’t be creating a full application with all these features. Instead, we’ll assume that the user has successfully set up local authentication using a simple "store password" button.
After this step, biometric authentication button should be used to access the app.
Now, if we repeat the process using Objection, after a successful authentication, we should be able to interact with the command-line interface (CLI). The first thing we notice is that the Keystore has successfully generated a symmetric key with the alias "test." Thanks to how the Keystore operates, an attacker cannot retrieve the actual key value.
On the other hand, if we navigate to the application's shared preferences directory, we can see the encrypted data along with the initialization vector (IV). Even though an attacker could access this data, if the user’s password is strong (unlike in this case, where the password is simply "password"), it would be difficult for an attacker to crack the encryption.
Bypassing local authentication
In the previous chapter, we successfully bypassed the local authentication of the application using a Frida script. Now, we will attempt to do the same with the current version of the application.
The process remains the same as before. We start the application using Frida and load the script designed to bypass local authentication.
frida -U -f com.example.localauth -l global-bypass.js
After launching the script and pressing the "Biometric Authentication" button, we encounter the following error:
This error occurs because the script forces a successful authentication, but the CryptoObject
's value is null, preventing the attacker from progressing to the second screen. This demonstrates that the application is now more secure, and bypassing the authentication process is no longer possible using the Frida script.
Conclusions
In this chapter, we explored effective methods for securing biometric authentication in Android, focusing on the CryptoObject and the Android Keystore. These technologies, when used correctly, provide robust security, but it is essential to recognize that no system is completely impervious to attack. Skilled attackers, particularly those using tools like Frida, can potentially bypass local authentication mechanisms by hooking into the application and manipulating the authentication flow in real-time.
Our approach introduces a strong first line of defense by linking biometric authentication to cryptographic operations managed through the Android Keystore. This ensures that sensitive operations, such as encryption and decryption, are only performed after a valid biometric authentication. By enforcing proper validation of the CryptoObject and cipher, we mitigate attacks that attempt to exploit vulnerabilities like bypassing authentication with null or manipulated cryptographic objects.
However, even with these protections, attackers with sufficient expertise in reverse engineering can still target the validation logic itself. They might attempt to modify or replace the checks that validate the CryptoObject and its associated cryptographic operations. This underscores the necessity of implementing additional layers of security.
To further enhance protection, implementing Frida detection techniques can help identify and block runtime tampering attempts. Although Frida detection is not a definitive solution, it raises the difficulty level for attackers, forcing them to invest more time and resources into bypassing both the biometric authentication and the tamper-detection mechanisms.
When applied to a REST API architecture, these security practices become even more effective. By ensuring that API access is contingent upon a valid biometrically-signed token and that server-side validation is in place, attackers are faced with an additional layer of security that is much harder to breach. Integrating these practices into the API flow increases the overall complexity of any attack, making it significantly more difficult for attackers to bypass protections, especially when secure token management and authentication processes are employed on both the client and server sides.
In conclusion, while no security measure is completely invulnerable, particularly when attackers have physical access to the device or employ advanced tools like Frida, layering multiple defense mechanisms significantly increases the effort required to exploit the system. By combining biometric authentication with cryptographic security, tamper detection, and secure REST API practices, we can create a far more resilient security architecture, making it much harder for attackers to succeed.
Resources
- Biometric Authentication with BiometricPrompt. "Android Developers." Available at: https://developer.android.com/training/sign-in/biometric-auth
- Frida Script to bypass Local Authentication. Available at: https://github.com/WithSecureLABS/android-keystore-audit/blob/master/frida-scripts/fingerprint-bypass.js
- Frida - Dynamic Instrumentation Toolkit. "Frida." Available at: https://frida.re
- Android Keystore System. "Android Developers." Available at: https://developer.android.com/training/articles/keystore
- How Secure is Your Android Keystore Authentication? "WithSecure Labs." Available at: https://labs.withsecure.com/publications/how-secure-is-your-android-keystore-authentication
- OWASP Mobile Application Security Testing Guide (MASTG) - Biometric Authentication Testing. "OWASP." Available at: https://mas.owasp.org/MASTG/chapters/0x06b-Testing-Authentication-and-Session-Management
Chapters
Previous chapter