Feb 10, 2026·5 min read·9 visits
The Android biometric implementation trusted the 'Yes, it's me' signal without checking the math. Attackers with local access (or malware) could use Frida to invoke the 'Success' callback directly, bypassing the fingerprint/FaceID check entirely. Fixed by binding the auth to a hardware-backed crypto object.
A critical flaw in the `@capgo/capacitor-native-biometric` Android implementation allowed attackers to bypass biometric authentication using simple instrumentation tools. By failing to bind the authentication event to a cryptographic operation, the plugin trusted the Java-layer callback blindly—a mechanism easily spoofed by frameworks like Frida.
Biometric authentication in mobile apps often falls into a classic trap: confusing the User Interface of security with the cryptography of security. When you tap your finger on a sensor, you assume the app is doing some complex handshake with the Secure Enclave. But in the hybrid app world—specifically within the @capgo/capacitor-native-biometric plugin—that wasn't quite happening.
This plugin acts as a bridge between the JavaScript world of Capacitor and the native Android BiometricPrompt API. It's supposed to be the bouncer, ensuring that the person holding the phone is actually the owner before unlocking sensitive data or authorized sessions.
However, the implementation on Android had a fatal flaw. It relied on what we call "Unbound Authentication." It asked the operating system, "Hey, did the user pass the test?" and blindly trusted the answer. In the hostile environment of a compromised or rooted device, the operating system's answer can be forged, and the messenger—the callback function—can be bribed.
The vulnerability (CWE-287) stems from how the Android BiometricPrompt API was utilized. There are two ways to implement this API:
Crypto-Bound (Secure): You create a cryptographic object (like a Cipher) initialized with a key that lives in the hardware Keystore. You pass this object to the biometric prompt. If the biometric match succeeds, the hardware unlocks the key, and you can perform an encryption/decryption operation. The proof of authentication is the successful math.
Unbound (Insecure): You just pop the prompt. If the user scans a finger, the OS calls onAuthenticationSucceeded. There is no cryptography involved. The proof is just a method call.
The vulnerable versions of @capgo/capacitor-native-biometric chose option 2. They effectively said, "Show the fingerprint dialog, and if the Java callback says 'OK', let them in." This is a logic check, not a cryptographic one. Logic checks in Java land are mutable. If I can run Frida or Xposed on the device, I can hook that callback and invoke it myself, completely bypassing the actual hardware sensor.
Let's look at the difference between the vulnerable code and the hardened patch. In the original implementation, the authentication call was naked.
Vulnerable Implementation:
// AuthActivity.java (simplified)
biometricPrompt = new BiometricPrompt(this, executor, new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
// TRUST: We just assume this means the finger was scanned.
finishActivity("success");
}
});
// The fatal mistake: Authenticating without a CryptoObject
biometricPrompt.authenticate(promptInfo);Because authenticate was called with only promptInfo, no hardware security module was engaged to verify the result.
The Fix (Commit 1254602):
The patch introduces the CryptoObject. Now, the app generates a key that requires user authentication (setUserAuthenticationRequired(true)). It initializes a Cipher with this key and passes it to the prompt.
// Fixed Implementation
Cipher cipher = getCipher(); // Cipher initialized with a hardware-backed key
BiometricPrompt.CryptoObject cryptoObject = new BiometricPrompt.CryptoObject(cipher);
// The Fix: Binding the auth to the crypto object
biometricPrompt.authenticate(promptInfo, cryptoObject);Crucially, inside the success callback, the code now uses that object:
@Override
public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
try {
// VERIFY: Attempt to use the key. If the biometric step was skipped via hook,
// the key remains locked in hardware, and this throws an exception.
result.getCryptoObject().getCipher().doFinal(new byte[] { 1 });
finishActivity("success");
} catch (Exception e) {
finishActivity("error");
}
}Exploiting this on a rooted device or within a repackaged app is trivially easy with Frida. Since the application logic relies entirely on the onAuthenticationSucceeded callback, we don't need to defeat the biometrics; we just need to tell the app we did.
Here is a conceptual Frida script that bypasses the check in the vulnerable version:
Java.perform(function() {
var BiometricPrompt = Java.use("androidx.biometric.BiometricPrompt");
// Hook the authenticate method
BiometricPrompt.authenticate.overload('androidx.biometric.BiometricPrompt$PromptInfo').implementation = function(info) {
console.log("[+] BiometricPrompt.authenticate called!");
// Access the callback stored in the instance (this is simplified)
// In reality, we often hook the callback class creation or the onAuthenticationSucceeded method directly.
// Simulate success immediately
var result = ...; // Mock an AuthenticationResult
this.authenticationCallback.onAuthenticationSucceeded(result);
console.log("[+] Bypassed biometrics!");
// We don't even call the original method, so the UI might not even show up.
};
});By running this, the moment the app tries to show the fingerprint prompt, our hook intercepts the call and immediately fires the "Success" signal. The app unlocks, thinking the user has just pressed their thumb to the sensor.
The mitigation strategy employed in version 8.3.7 is the industry standard for Android biometrics: Crypto-Binding.
By forcing the authenticate method to carry a CryptoObject, the security model shifts from the Application Layer (Java) to the Hardware Layer (TEE/SE). The AES key generated by the patch is flagged with setUserAuthenticationRequired(true). This means the Android Keystore refuses to use this key for encryption or decryption unless a biometric signal has just been received by the secure hardware.
Even if an attacker uses Frida to call onAuthenticationSucceeded manually, they cannot forge the state of the hardware keystore. When the patched code calls .doFinal() to encrypt a dummy byte, the hardware says, "I haven't seen a fingerprint recently," and throws a GeneralSecurityException. The application catches this exception and denies access. The logic gap is closed.
CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
@capgo/capacitor-native-biometric Capgo | < 8.3.7 | 8.3.7 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-287 |
| Attack Vector | Local / Physical |
| Impact | Authentication Bypass |
| Exploit Status | POC Available (Trivial) |
| Platform | Android |
| Technology | Capacitor / Java |
Improper Authentication