Cracking Android Biometric Authentication with Frida

12 min read

September 15, 2024

Securing Android: An In-Depth Exploration
Cracking Android Biometric Authentication with Frida

Table of contents

Introduction

In this chapter of my Android pentesting series, I’ll take a closer look at local authentication—a critical security feature in modern apps. We’ll develop a small application to give you a hands-on understanding of how local authentication works using the BiometricPrompt API. After building the authentication layer, I’ll demonstrate how attackers can bypass it using Frida on a rooted emulator.

This practical approach will help you not only understand how to implement authentication but also reveal where its vulnerabilities lie and how they can be exploited.

In this chapter, I’ll cover:

  • Setting up the Android Emulator for biometric testing.
  • Developing and testing a basic BiometricPrompt authentication app.
  • Bypassing authentication using Frida.
  • Identifying common weaknesses in basic authentication setups.

By the end, you’ll have a solid grasp of how local authentication works, its limitations, and how to protect against potential bypass attacks. In future chapters, we’ll dive deeper into securing the authentication flow and leveraging cryptographic operations to strengthen your app’s security.

Using Android Studio Emulator for Biometric Testing

To accurately test biometric features such as fingerprint authentication, it's recommended to use the Android Emulator from Android Studio rather than third-party virtual machines. The Android Emulator provides built-in support for fingerprint sensors, making it ideal for testing biometric features. Here's a brief setup guide:

  1. Install Android Studio: First, download and install Android Studio, then open your project or create a new one.
  2. Set Up the Emulator: Go to Tools > AVD Manager, create a virtual device running Android 9.0 or higher (required for biometric support), and start the emulator.
  3. Install ADB (Android Debug Bridge): Ensure you have ADB installed on your host machine. ADB is crucial for interacting with the emulator from the command line and for debugging your app. You can install ADB by following this guide.

For detailed steps on each process, refer to the following tutorials:

With the emulator ready, it’s time to explore how Android manages local authentication. BiometricPrompt simplifies integrating biometric features like fingerprints and facial recognition into your app. Let’s delve into the key concepts of local authentication and how BiometricPrompt ensures secure user verification.

Understanding BiometricPrompt and Local Authentication in Android

When developing secure Android applications, authentication is a critical aspect, and Android provides several methods to verify users locally—without the need for online services. One of the most important tools for local authentication today is BiometricPrompt, an API introduced in Android 9 (Pie) that simplifies the integration of biometric security features like fingerprints or facial recognition into your app.

What is Local Authentication?

Local authentication is the process of verifying a user's identity directly on the device. Unlike remote authentication, which checks credentials on a server, local authentication ensures that access to certain data or features is controlled strictly within the app or the device itself.

Traditional methods of local authentication include:

  • PINs or passwords, where users manually enter a passcode.
  • Pattern unlocks, a method familiar to most Android users.

While these methods are still widely used, biometric authentication has become increasingly popular due to its convenience and higher level of security.

Introduction to BiometricPrompt

BiometricPrompt is Android’s modern framework for managing biometric authentication. It offers a unified way to prompt users for biometric data, handle the sensitive information securely, and provide developers with a simple API to integrate this into apps.

Why BiometricPrompt Matters

In earlier Android versions, developers used different APIs for each biometric type (e.g., FingerprintManager). This led to inconsistencies and security risks since developers had to handle more complexity themselves. BiometricPrompt solves this by:

  • Standardizing biometric access: Whether the user has a fingerprint scanner or face unlock, the same API manages it.
  • Improving security: Biometric data is handled inside the device’s Trusted Execution Environment (TEE) or Secure Hardware, which means neither the operating system nor any apps can directly access the biometric data.
  • User experience: It ensures a consistent and familiar authentication prompt across all apps, which helps users feel more secure.

How BiometricPrompt Works

When you use BiometricPrompt in your app, here’s what happens under the hood:

  1. The system displays a secure prompt asking the user to authenticate using a registered biometric (e.g., fingerprint, face).
  2. Biometric data is captured and processed entirely in secure hardware, meaning the app never has direct access to the raw data.
  3. If authentication is successful, BiometricPrompt triggers a callback in the app, which can be used to unlock sensitive features or data.

The BiometricPrompt API also allows for secure cryptographic operations through its CryptoObject class. This feature binds a cryptographic operation (like signing or encrypting data) to successful biometric authentication, ensuring the app’s sensitive operations can only proceed when the user’s identity is confirmed. In the following chapters, we will delve deeper into how to leverage these cryptographic operations effectively, and how to secure them against potential bypass techniques.

Code Explanation: Implementing Biometric Authentication in Android

Now that we’ve covered the theory behind BiometricPrompt, let's see how to implement it in practice. The following code example walks through setting up biometric authentication in an Android app, from verifying if the device supports biometrics to handling successful authentication.

MainActivity: Setting Up the Interface

The MainActivity is the entry point of the app, and the user interface is set up in the onCreate() method. The layout of the activity is defined in activity_main.xml, which includes a button (btn_authenticate). When the button is clicked, the app triggers the method validateBiometricSupportAndAuthenticate() to initiate the biometric authentication flow.

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

    val btnAuthenticate: Button = findViewById(R.id.btn_authenticate)
    btnAuthenticate.setOnClickListener {
        validateBiometricSupportAndAuthenticate()
    }
}

The setOnClickListener() sets up a listener for the button, which calls the method to check for biometric support when pressed.

Checking for Biometric Support

The validateBiometricSupportAndAuthenticate() method uses the BiometricManager class to check if the device supports biometric authentication. The method evaluates several conditions, providing feedback using Toast messages to inform the user about the status of the biometric hardware and whether credentials are enrolled.

private fun validateBiometricSupportAndAuthenticate() {
    val biometricManager = BiometricManager.from(this)
    when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
        BiometricManager.BIOMETRIC_SUCCESS -> {
            // The device supports biometric authentication
            showBiometricPrompt()
        }
        BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
            // The device does not have biometric hardware
            Toast.makeText(this, "No biometric hardware available", Toast.LENGTH_SHORT).show()
        }
        BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
            // The biometric hardware is currently unavailable
            Toast.makeText(this, "Biometric hardware currently unavailable", Toast.LENGTH_SHORT).show()
        }
        BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
            // No biometric credentials are enrolled on the device
            Toast.makeText(this, "No biometric credentials enrolled", Toast.LENGTH_SHORT).show()
        }
    }
}

If the device supports biometric authentication (BIOMETRIC_SUCCESS), the app calls showBiometricPrompt() to proceed with the authentication process. If the device doesn't support biometrics or lacks enrolled credentials, appropriate error messages are displayed using Toast.

Displaying the Biometric Prompt

The showBiometricPrompt() method configures and displays the biometric authentication dialog. It sets up an Executor to manage the callbacks and ensure the authentication process runs smoothly on the main UI thread. The BiometricPrompt instance is created with an AuthenticationCallback that listens for success or failure of the authentication attempt.

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)
            showSuccess()
        }

        override fun onAuthenticationFailed() {
            super.onAuthenticationFailed()
            Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
        }
    })

  • onAuthenticationSucceeded() is triggered when the user successfully authenticates, and it calls showSuccess().
  • onAuthenticationFailed() handles authentication failures and informs the user via a Toast.

The biometric prompt is then configured using BiometricPrompt.PromptInfo.Builder(), where you define the title, subtitle, and a fallback option for users who choose not to use biometrics.

val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your fingerprint")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo)

The PromptInfo dialog informs the user that they need to authenticate using their fingerprint. If the user prefers, they can select the "Use password" option to authenticate using a different method.

Handling Successful Authentication

When the user successfully authenticates, the showSuccess() method is invoked. This method displays a success message using Toast and navigates the user to a new activity (SuccessActivity) using an Intent. Optionally, finish() is called to close the MainActivity, preventing the user from navigating back to it without re-authenticating.

private fun showSuccess() {
    Toast.makeText(this, "Authentication successful!", Toast.LENGTH_SHORT).show()
    // Navigate to the SuccessActivity
    val intent = Intent(this, SuccessActivity::class.java)
    startActivity(intent)
    finish() // Optionally finish MainActivity to prevent going back without re-authentication
}

The transition to SuccessActivity completes the authentication process, showing that the user has been successfully authenticated.

package com.example.localauth

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class SuccessActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_success)
    }
}

All code
package com.example.localauth

import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import android.content.Intent

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val btnAuthenticate: Button = findViewById(R.id.btn_authenticate)
        btnAuthenticate.setOnClickListener {
            validateBiometricSupportAndAuthenticate()
        }
    }

    private fun validateBiometricSupportAndAuthenticate() {
        val biometricManager = BiometricManager.from(this)
        when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
            BiometricManager.BIOMETRIC_SUCCESS -> {
                // The device supports biometric authentication
                showBiometricPrompt()
            }
            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
                // The device does not have biometric hardware
                Toast.makeText(this, "No biometric hardware available", Toast.LENGTH_SHORT).show()
            }
            BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
                // The biometric hardware is currently unavailable
                Toast.makeText(this, "Biometric hardware currently unavailable", Toast.LENGTH_SHORT).show()
            }
            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
                // No biometric credentials are enrolled on the device
                Toast.makeText(this, "No biometric credentials enrolled", Toast.LENGTH_SHORT).show()
            }
        }
    }

    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)
                showSuccess()
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
            }
        })

        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Biometric Authentication")
            .setSubtitle("Log in using your fingerprint")
            .setNegativeButtonText("Use password")
            .build()

        biometricPrompt.authenticate(promptInfo)
    }

    private fun showSuccess() {
        Toast.makeText(this, "Authentication successful!", Toast.LENGTH_SHORT).show()
        // Navigate to the SuccessActivity
        val intent = Intent(this, SuccessActivity::class.java)
        startActivity(intent)
        finish() // Optionally finish MainActivity to prevent going back without re-authentication
    }
}

Layouts Explanation

The user interface for the app consists of two key layouts: one for the main activity where the user initiates authentication, and another for the success screen.

MainActivity Layout (activity_main.xml)

This layout defines a simple user interface using a LinearLayout that centers a button on the screen. The button is used to initiate biometric authentication.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center">

    <Button
        android:id="@+id/btn_authenticate"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Biometric Authentication" />
</LinearLayout>
  • The LinearLayout ensures that the button is vertically centered on the screen.
  • The button, with id="btn_authenticate", displays the text "Biometric Authentication" and is linked to the MainActivity via the findViewById() method. When clicked, it initiates the authentication process.

SuccessActivity Layout (activity_success.xml)

This layout is for the success screen that the user sees after successful authentication. It contains a TextView displaying a success message and an ImageView for visual feedback.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center"
    android:padding="16dp">

    <TextView
        android:id="@+id/success_message"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Access Granted!"
        android:textSize="24sp"
        android:textColor="@android:color/black"
        android:layout_marginBottom="16dp"/>

    <ImageView
        android:id="@+id/success_image"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/charmander_victory"
        android:contentDescription="Charmander showing victory" />

</LinearLayout>
  • The TextView displays the message "Access Granted!" with a large text size (24sp) and a bottom margin of 16dp to provide spacing between the text and the image.
  • The ImageView displays an image (charmander_victory) that visually reinforces the success message. The image is centered below the text and uses the drawable resource @drawable/charmander_victory.

Testing the App in the Android Studio Emulator

To begin testing the app, we first need to set up fingerprint authentication in the emulator. Start by navigating to the Security settings in the emulator and look for the Pixel Imprint option.

Security Settings

Once inside the Pixel Imprint section, follow the instructions to register a fingerprint. You’ll be prompted to touch the sensor multiple times until the fingerprint is fully registered, as shown in the image below:

Setting up the fingerprint

After successfully registering the fingerprint, you should see it listed under Pixel Imprint in the security settings.

Saved fingerprint

With the fingerprint configured, it’s time to run the app. In Android Studio, simply press the Run button to deploy the application to the emulator.

Running the application

If everything is set up correctly, the app’s user interface (UI) will appear in the emulator. As previously developed, the main layout contains a single button. By pressing this button, the app will prompt you to authenticate using the fingerprint you just set up.

Accessing to the second screen with local authentication

If the correct fingerprint is provided, you’ll successfully authenticate and be taken to the second screen, which features a cheerful Charmander! 😆

Cool Charmander

Bypassing local authentication

Now that we have the application installed, it’s time to demonstrate how to bypass local authentication using a rooted emulator.

Rooting the Emulator

The first step is to root the Android Studio emulator. To make this process simpler, we’ll use a script that automates everything for us. Follow these steps:

git clone https://gitlab.com/newbit/rootAVD.git
./rootAVD.sh ListAllAVDs

After running the script for the first time, it will provide instructions on how to root the device.

Command to root the emulator

Simply copy and paste the command provided by the script into your terminal. The script will then handle the entire rooting process for you.

Rooting the emulator

Once the emulator is rooted, you can verify root access by running the following commands:

adb shell
su 

At this point, a dialog will appear in the emulator asking for permission to grant root access. Press Grant to confirm. You should now have root access to the emulator.

Accessing the rooted device

Installing frida server

To install Frida Server, we’ll use a Magisk module that automates the process. You can download the module from the link below:

GitHub - ViRb3/magisk-frida: 🔐 Run frida-server on boot with Magisk, always up-to-date
🔐 Run frida-server on boot with Magisk, always up-to-date - ViRb3/magisk-frida

Installing the Module

First, transfer the .zip file from the module’s GitHub release page to the emulator using ADB:

adb push module.zip /sdcard/Download

Once the module is copied, open Magisk on the emulator and click Install From Storage. Then, select the .zip file you just transferred.

Modules of Magisk

After the module is installed, enable it and restart the emulator.

Installed Magisk Module

Verifying the Installation

If everything is set up correctly, you should see the Frida server running on port 27042. To verify this, run the following command:

netstat -tupln | grep "27042" 

You should see the port listed as active.

Frida server running

Bypass using frida

Now that everything is set up, we’ll use Frida to bypass the local authentication in the app. This process is straightforward and allows you to test whether the application is vulnerable to this type of attack.

Download the Bypass Script

First, download the script from the following link:

android-keystore-audit/frida-scripts/fingerprint-bypass.js at master · WithSecureLabs/android-keystore-audit
Contribute to WithSecureLabs/android-keystore-audit development by creating an account on GitHub.

This script performs several checks and, depending on the defenses implemented by the application, it will attempt different methods to bypass the authentication.

Running the Bypass Script

To use the script, launch the application with Frida by running the following command in your terminal:

frida -U -f com.example.localauth -l global-bypass.js
Running the Frida script to bypass local authentication

Once the app is running, navigate to the main screen. When you press the button to authenticate, the Frida script will automatically bypass the authentication, granting you access to the second screen of the app.

Accessing the second screen

Conclusion

In this chapter of the Android pentesting series, we implemented a basic local authentication using BiometricPrompt and demonstrated how it can be bypassed using Frida on a rooted emulator.

Key insights:

  • The BiometricPrompt API provides a standard way to handle biometric authentication securely.
  • Rooted devices and tools like Frida expose vulnerabilities in basic authentication setups.
  • Strengthening code is crucial to preventing bypass attacks.

In the next chapter, we’ll focus on making the authentication process more robust and resistant to these kinds of attacks.

Resources

Chapters

Botón Anterior
Linking with Confidence: Securing Deep Links in Android Applications

Previous chapter

Securing Biometric Authentication: Defending Against Frida Bypass Attacks

Next chapter