1. Android Keystore Architecture & TEE
The Android Keystore system lets developers store cryptographic keys in a container to make them more difficult to extract from the device. When keys are created with the setKeyStoreProvider flag, key material is generated directly inside a Hardware-Backed Keystore, such as a Trusted Execution Environment (TEE) or StrongBox Keymaster.
Even if an attacker gains root access to the Linux kernel running on the application processor, the raw private key material never enters host RAM. However, what attackers cannot steal at rest, they often intercept during runtime memory operations.
2. Identifying Cryptographic Targets with JADX
Before injecting runtime hooks, we decompile the APK using JADX to locate where javax.crypto.Cipher is initialized and invoked. We search for references to Cipher.getInstance('AES/GCM/NoPadding') and examine the KeyGenParameterSpec configurations.
Pay close attention to user authentication requirements. If setUserAuthenticationRequired(false) was specified during key generation, the key can be used by any process context without user presence prompts.
// Target function identified in decompiled APK
public byte[] decryptAuthToken(byte[] encryptedData, byte[] iv) {
try {
KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
keyStore.load(null);
SecretKey key = (SecretKey) keyStore.getKey("user_session_key", null);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
return cipher.doFinal(encryptedData); // Target for runtime hooking
} catch (Exception e) {
Log.e("Security", "Decryption failed", e);
return null;
}
}3. Writing the Frida Hook for Cipher.doFinal
Because the hardware TEE returns the decrypted bytes back to the Java runtime through the JNI bridge, we can hook javax.crypto.Cipher.doFinal() right when plaintext is returned to the app.
Below is the JavaScript snippet we inject into the running process using Frida. It intercepts calls to doFinal, prints the input ciphertext, and displays the plaintext result in hex and UTF-8 strings.
Java.perform(function () {
const Cipher = Java.use('javax.crypto.Cipher');
// Hook Cipher.doFinal(byte[])
Cipher.doFinal.overload('[B').implementation = function (inputBytes) {
console.log('[*] Cipher.doFinal([B) intercepted!');
console.log('[*] Input bytes length: ' + inputBytes.length);
// Execute original method to obtain decrypted bytes
const result = this.doFinal(inputBytes);
// Convert decrypted output to string
const ByteString = Java.use('java.lang.String');
const plaintext = ByteString.$new(result);
console.log('[✓] Decrypted Plaintext: ' + plaintext);
return result;
};
});4. Circumventing CryptoObject Biometrics
Many developers make the mistake of using BiometricPrompt only as a visual gate, evaluating onAuthenticationSucceeded() as a boolean flag without binding the Cipher to the BiometricPrompt.CryptoObject.
When crypto objects are unbound, an attacker can simply hook the callback class and invoke onAuthenticationSucceeded(null), completely bypassing fingerprint or face unlock barriers.
5. Developer Hardening & Countermeasures
To harden your Android app against dynamic instrumentation and runtime keystore interception, adopt defense-in-depth measures:
- Mandate StrongBox Keymaster backed key generation for high-value secrets.
- Implement active Frida and ptrace anti-debugging checks in native C/C++ libraries.
- Always bind BiometricPrompt with a valid CryptoObject.
- Leverage SafetyNet / Play Integrity API to detect compromised, rooted, or emulated runtimes.
- Ensure sensitive tokens in memory are zeroized (Arrays.fill(bytes, (byte) 0)) immediately after use.
