Enhancing Android Security with Native Libraries: Implementation and Evasion Techniques
17 min read
October 13, 2024
Table of contents
Leveraging Native Libraries for Enhanced Security in Android Applications
Native libraries in Android are compiled pieces of code, typically written in languages like C or C++, that an app can use to perform specific tasks more efficiently or securely than in Java or Kotlin. These libraries are compiled for various architectures (such as ARM or x86) and are included within the APK of the app. They allow direct access to system-level resources and hardware, making them ideal for performance-intensive tasks like gaming, audio, or graphics processing.
In the context of application security, native libraries can also serve as a defense mechanism against certain advanced attacks. For example, tools like Frida, which are used to manipulate the runtime of an Android app, often target the app's Java layer. By implementing security checks within native libraries, developers can make it much harder for such tools to modify or analyze their app.
Native libraries can also help enhance the security of other mechanisms, such as SSL pinning or anti-root checks, by making it more difficult for attackers to bypass these defenses. Since native code operates closer to the system level and outside the managed runtime environment, it becomes more challenging to hook into or manipulate, providing stronger protection against tampering and reverse-engineering. This makes native libraries an essential part of an app’s security strategy, particularly for sensitive operations that require higher levels of protection.
In this chapter, we'll explore how to configure and create code in native libraries, as well as their use in Android code and how they can be easily bypassed using Frida. In the next chapter, we'll see how things can get significantly more complex if a developer changes the way the native function is used and implements it differently in their code.
Installing the NDK and CMake in Android Studio
To start using native libraries in your Android project, you first need to install the Android NDK and CMake. Follow these steps to set them up:
1. Install the Android NDK and CMake
- Open Android Studio and go to File > Settings (on macOS, it's Android Studio > Preferences).
- In the left-hand menu, navigate to Appearance & Behavior > System Settings > Android SDK.
- In the SDK Tools tab, scroll through the list and check the following boxes:
- NDK (Side by Side)
- CMake
- Click Apply and then OK to install both tools.
Creating the Necessary Files and Folders for Native Code
Once you have the NDK and CMake installed, the next step is to set up the required folders and files to start using native code in your Android project.
1. Create the cpp
Folder
You will need to create a new folder inside your project to store the native code:
- In Android Studio, navigate to the Project view on the left panel.
- Right-click on the
src/main
directory, and select New > Directory. - Name the new folder
cpp
. This folder will contain all your native C++ source files.
2. Create a .cpp
File
Now, create a C++ source file inside the cpp
folder:
- Right-click on the newly created
cpp
folder, select New > C/C++ Source File. - Name the file, for example,
native-lib.cpp
. - Inside this file, you can write a simple native function:
#include <jni.h>
#include <string>
#include <jni.h>
#include <string>
#include <dirent.h> // To scan directories
#include <unistd.h> // For access function
#include <fstream> // To check for Frida processes
#include <sys/types.h>
#include <sys/stat.h>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_localauth_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
This will serve as a simple test function to ensure your native code is set up correctly.
3. Create the CMakeLists.txt
File
To compile the native code, you need a CMakeLists.txt
file:
- Right-click on the
cpp
folder, select New > File. - Name it
CMakeLists.txt
and add the following content:
# Set the minimum required version of CMake
cmake_minimum_required(VERSION 3.4.1)
# Add the shared library, which will be named 'native-lib' and will be compiled from the file native-lib.cpp
add_library(
native-lib # Name of the library
SHARED # Specifies that it is a shared library
native-lib.cpp # Source file
)
# Find the log library, which is required for Android
find_library(
log-lib # Name of the variable where the library path will be stored
log # Name of the system library to search for
)
# Link the native library with the found libraries (in this case log-lib)
target_link_libraries(
native-lib # Native library we are linking
${log-lib} # Link the system library (log)
)
This configuration file tells CMake how to compile the native-lib.cpp
file and links it to the required libraries.
4. Folder Structure Overview
After these steps, your folder structure should look like this:
Updating the build.gradle.kts
File for Native Code
Now that we have created the necessary folders and files, you need to update your build.gradle.kts
file to properly integrate the native code into the Android project. You already have a well-structured build.gradle.kts
file, but let's add or modify the relevant sections to ensure your C++ files and CMake are properly linked.
Here’s what you need to check and modify:
1. Add NDK and CMake Configuration
In your existing build.gradle
file, you already have the necessary configurations, but ensure the following settings are properly applied:
externalNativeBuild
block: This block configures the native build system (in this case, CMake). You have this correctly set up, but here is a confirmation of what needs to be included:
externalNativeBuild {
cmake {
cppFlags.add("-std=c++11") // Ensure you have the proper C++ version
}
}
ndk
block: You already specify the ABIs (Application Binary Interfaces) for the native code, which is necessary to compile for different architectures. Here’s how it should look:
ndk {
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")) // Add supported ABIs
}
2. Include the CMake Path
The CMakeLists.txt
file you created needs to be referenced in your build.gradle
. You’ve already correctly added this section, but here’s a recap for clarity:
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt") // Path to your CMakeLists file
}
}
This line ensures that Gradle knows where to find your native code build script.
3. Check the NDK Version
It’s good practice to specify the NDK version explicitly in your build.gradle
. You already have this in place, but ensure it is correctly specified:
ndkVersion = "27.1.12297006"
4. Overall build.gradle.kts
Configuration Example
Here’s an example of your build.gradle.kts
file, which already includes all the necessary settings:
Build.gradle.kts code
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
}
android {
namespace = "com.example.localauth"
compileSdk = 34
defaultConfig {
applicationId = "com.example.localauth"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
externalNativeBuild {
cmake {
cppFlags.add("-std=c++11") // Set C++ version
}
}
ndk {
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")) // Add supported ABIs
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt") // Path to CMakeLists.txt
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
ndkVersion = "27.1.12297006"
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation("androidx.biometric:biometric:1.2.0-alpha05")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
Adding and Calling the Native Function in Your Application
Now that you have set up the native library, the next step is to call the native function from your Kotlin (or Java) code. Here’s how you can add the necessary code to connect the native C++ function to your Android application.
1. Load the Native Library
The first thing you need to do is load the native library into your Android app. This can be done using the System.loadLibrary()
method in your MainActivity.kt
(or any other activity where you want to use the native code).
In your MainActivity.kt
, add the following code:
class MainActivity : AppCompatActivity() {
private lateinit var cipher: Cipher
private lateinit var keyStore: KeyStore
private val keyAlias = "test"
val expectedData = "PASSWORD".toByteArray()
companion object {
init {
System.loadLibrary("native-lib")
}
}
...
2. Declare the Native Function
The external
keyword is used to declare the native function in Kotlin (or Java). This tells Android that the implementation of this function is in native code, which will be loaded from the native-lib.cpp
file.
// Declare the native function
external fun stringFromJNI(): String
3. Call the Native Function in Your UI
After loading the native library and declaring the native method, you can call the stringFromJNI()
function just like any other Kotlin (or Java) function.
class MainActivity : AppCompatActivity() {
private lateinit var cipher: Cipher
private lateinit var keyStore: KeyStore
private val keyAlias = "test"
val expectedData = "PASSWORD".toByteArray()
companion object {
init {
System.loadLibrary("native-lib")
}
}
external fun stringFromJNI(): String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val messageFromNative = stringFromJNI()
Log.d("JNI", "Mensaje desde C++: $messageFromNative")
4. Testing the Application
Once you have added the code above, you can run your application to ensure everything is working. If set up correctly, the native function stringFromJNI()
should return a string from the C++ code and display it in logcat.
MainActivity
package com.example.localauth
import android.content.Intent
import android.os.Bundle
import android.util.Base64
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import javax.crypto.spec.GCMParameterSpec
class MainActivity : AppCompatActivity() {
private lateinit var cipher: Cipher
private lateinit var keyStore: KeyStore
private val keyAlias = "test"
val expectedData = "PASSWORD".toByteArray()
companion object {
init {
System.loadLibrary("native-lib") // sin "lib" y sin ".so"
}
}
external fun stringFromJNI(): String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val messageFromNative = stringFromJNI()
Log.d("JNI", "Mensaje desde C++: $messageFromNative")
createKey()
val btnEncrypt: Button = findViewById(R.id.btn_encrypt)
btnEncrypt.setOnClickListener {
showBiometricPromptForEncryption("PASSWORD")
}
val btnReset: Button = findViewById(R.id.btn_reset)
btnReset.setOnClickListener {
resetEncryptedData()
}
val btnAuthenticate: Button = findViewById(R.id.btn_authenticate)
btnAuthenticate.setOnClickListener {
showBiometricPrompt()
}
}
// Create Key in Keystore
private fun createKey() {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.setUserAuthenticationValidityDurationSeconds(-1) // Require biometric every time
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
// Initialize the cipher for encryption/decryption
private fun initCipher(mode: Int, iv: ByteArray? = null): Boolean {
return try {
keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val key = keyStore.getKey(keyAlias, null) as SecretKey
cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}")
if (mode == Cipher.ENCRYPT_MODE) {
cipher.init(Cipher.ENCRYPT_MODE, key) // Generate new IV
} else if (mode == Cipher.DECRYPT_MODE && iv != null) {
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) // Use provided IV
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Show biometric prompt and tie it to the cipher object
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)
val cryptoObject = result.cryptoObject
if (cryptoObject != null && cryptoObject.cipher != null) {
try {
val decryptedData = decryptData(cryptoObject)
if (decryptedData == null || !isValidData(decryptedData)) {
Toast.makeText(this@MainActivity, "Decryption failed or invalid data!", Toast.LENGTH_SHORT).show()
} else {
showSuccess()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Decryption error!", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(this@MainActivity, "Authentication succeeded but CryptoObject is missing!", Toast.LENGTH_SHORT).show()
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
}
})
if (initCipher(Cipher.DECRYPT_MODE, retrieveStoredIV())) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your fingerprint")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
// Updated: Fixing how cipher is used after biometric authentication completes
private fun showBiometricPromptForEncryption(plainText: String) {
val executor = ContextCompat.getMainExecutor(this)
val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
val cryptoObject = result.cryptoObject
if (cryptoObject != null) {
try {
// Encrypt after biometric authentication
val encryptedData = cryptoObject.cipher?.doFinal(plainText.toByteArray())
if (encryptedData != null) {
val iv = cryptoObject.cipher?.iv // Get the generated IV
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv!!)
Toast.makeText(this@MainActivity, "Encryption successful!", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Encryption error!", Toast.LENGTH_SHORT).show()
}
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
}
})
if (initCipher(Cipher.ENCRYPT_MODE)) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication for Encryption")
.setSubtitle("Use your fingerprint to encrypt data")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
// Check if decrypted data is valid
private fun isValidData(decryptedData: ByteArray): Boolean {
return decryptedData.contentEquals(expectedData) // Example validation
}
// Decrypt data using the CryptoObject
private fun decryptData(cryptoObject: BiometricPrompt.CryptoObject): ByteArray? {
return try {
val encryptedData = Base64.decode(retrieveEncryptedData(), Base64.DEFAULT)
val iv = retrieveStoredIV() // Retrieve the stored IV
if (initCipher(Cipher.DECRYPT_MODE, iv)) { // Use the retrieved IV
val decryptedData = cryptoObject.cipher?.doFinal(encryptedData)
decryptedData
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun encryptAndStoreData(plainText: String) {
if (initCipher(Cipher.ENCRYPT_MODE)) {
try {
val encryptedData = cipher.doFinal(plainText.toByteArray())
val iv = cipher.iv // Get the generated IV
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "Encryption failed", Toast.LENGTH_SHORT).show()
}
}
}
// Simulate storing encrypted data and IV (replace with actual storage logic)
private fun storeEncryptedDataAndIV(encryptedData: String, iv: ByteArray) {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("encrypted_data", encryptedData)
editor.putString("iv", Base64.encodeToString(iv, Base64.DEFAULT)) // Store the IV as Base64 string
editor.apply()
}
// Retrieve encrypted data and IV
private fun retrieveEncryptedData(): String {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
return sharedPreferences.getString("encrypted_data", "") ?: ""
}
private fun retrieveStoredIV(): ByteArray {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val ivString = sharedPreferences.getString("iv", null)
return Base64.decode(ivString, Base64.DEFAULT)
}
private fun showSuccess() {
Toast.makeText(this, "Authentication successful!", Toast.LENGTH_SHORT).show()
val intent = Intent(this, SuccessActivity::class.java)
startActivity(intent)
finish() // Optionally finish MainActivity to prevent going back without re-authentication
}
private fun resetEncryptedData() {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.remove("encrypted_data") // Remove encrypted data
editor.remove("iv") // Remove IV
editor.apply()
Log.d("Reset", "Encrypted data and IV reset.")
Toast.makeText(this, "Encrypted data reset.", Toast.LENGTH_SHORT).show()
}
}
Implementing Frida Detection with a Native Library
Now that we've covered how native libraries can be used for security measures like Frida detection, let's look at a typical implementation developers often use to detect Frida in an Android app with native code. The following example shows a native function that performs common checks to identify suspicious libraries, processes, and network activity associated with Frida.
Native Code for Detecting Frida
#include <jni.h>
#include <string>
#include <jni.h>
#include <string>
#include <dirent.h> // To scan directories
#include <unistd.h> // For access function
#include <fstream> // To check for Frida processes
#include <sys/types.h>
#include <sys/stat.h>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_localauth_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_example_localauth_MainActivity_detectFrida(
JNIEnv* env,
jobject /* this */) {
// 1. Check for Frida-related libraries in the process
const char* suspiciousLibs[] = {
"frida-agent",
"frida-gadget",
"libfrida-gadget.so"
};
for (const char* lib : suspiciousLibs) {
// Check if the Frida library is loaded
if (access(lib, F_OK) != -1) {
return JNI_TRUE; // Frida detected
}
}
// 2. Check for Frida processes
std::ifstream procList("/proc/self/maps");
std::string line;
while (std::getline(procList, line)) {
if (line.find("frida") != std::string::npos) {
return JNI_TRUE; // Frida detected
}
}
// 3. Check if Frida server is running on common ports
std::ifstream netstat("/proc/net/tcp");
while (std::getline(netstat, line)) {
if (line.find("127.0.0.1:27042") != std::string::npos || // Default Frida port
line.find("127.0.0.1:27043") != std::string::npos) { // Alternative Frida port
return JNI_TRUE; // Frida detected
}
}
// If no detection was successful
return JNI_FALSE;
}
The detectFrida()
function performs several checks to detect if Frida is being used on the device:
- Checking for Suspicious Libraries: Frida often loads specific libraries like
frida-agent
orlibfrida-gadget.so
. The function checks if any of these libraries are present by scanning the process memory and filesystem. If found, it immediately returnsJNI_TRUE
, indicating Frida’s presence. - Scanning for Frida Processes: The function reads the
/proc/self/maps
file, which contains details of the memory mappings for the current process. If the string "frida" is found, it indicates that a Frida-related process is running. - Checking Network Ports: Frida typically uses specific TCP ports (such as
27042
and27043
) to communicate with its server. The function checks the/proc/net/tcp
file, which lists all open TCP connections, for any indication that Frida is running on these ports.
If any of these checks succeed, the function returns JNI_TRUE
, signaling that Frida is detected. Otherwise, it returns JNI_FALSE
.
How to Use the detectFrida()
Function in Kotlin
After implementing the native function in C++, the next step is to call it from your Kotlin (or Java) code to utilize the Frida detection mechanism.
1. Declare the Native Function
In your MainActivity.kt
, declare the detectFrida()
function using the external
keyword:
external fun detectFrida(): Boolean
2. Call the Frida Detection Function
You can now call the detectFrida()
function at any point in your app to check if Frida is present:
if (detectFrida()) {
Log.d("Security", "Frida detected!")
showFridaDetectedDialog()
} else {
Log.d("Security", "No Frida detected.")
}
MainActivity
package com.example.localauth
import android.content.Intent
import android.app.AlertDialog
import android.content.DialogInterface
import android.os.Bundle
import android.util.Base64
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import javax.crypto.spec.GCMParameterSpec
class MainActivity : AppCompatActivity() {
private lateinit var cipher: Cipher
private lateinit var keyStore: KeyStore
private val keyAlias = "test"
val expectedData = "PASSWORD".toByteArray()
companion object {
init {
System.loadLibrary("native-lib") // sin "lib" y sin ".so"
}
}
external fun stringFromJNI(): String
external fun detectFrida(): Boolean
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val messageFromNative = stringFromJNI()
Log.d("JNI", "Message from C++: $messageFromNative")
if (detectFrida()) {
Log.d("Security", "Frida detected!")
showFridaDetectedDialog()
} else {
Log.d("Security", "No Frida detected.")
}
createKey()
val btnEncrypt: Button = findViewById(R.id.btn_encrypt)
btnEncrypt.setOnClickListener {
showBiometricPromptForEncryption("PASSWORD")
}
val btnReset: Button = findViewById(R.id.btn_reset)
btnReset.setOnClickListener {
resetEncryptedData()
}
val btnAuthenticate: Button = findViewById(R.id.btn_authenticate)
btnAuthenticate.setOnClickListener {
showBiometricPrompt()
}
}
// Create Key in Keystore
private fun createKey() {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.setUserAuthenticationValidityDurationSeconds(-1) // Require biometric every time
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
// Initialize the cipher for encryption/decryption
private fun initCipher(mode: Int, iv: ByteArray? = null): Boolean {
return try {
keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val key = keyStore.getKey(keyAlias, null) as SecretKey
cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}")
if (mode == Cipher.ENCRYPT_MODE) {
cipher.init(Cipher.ENCRYPT_MODE, key) // Generate new IV
} else if (mode == Cipher.DECRYPT_MODE && iv != null) {
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) // Use provided IV
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Show biometric prompt and tie it to the cipher object
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)
val cryptoObject = result.cryptoObject
if (cryptoObject != null && cryptoObject.cipher != null) {
try {
val decryptedData = decryptData(cryptoObject)
if (decryptedData == null || !isValidData(decryptedData)) {
Toast.makeText(this@MainActivity, "Decryption failed or invalid data!", Toast.LENGTH_SHORT).show()
} else {
showSuccess()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Decryption error!", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(this@MainActivity, "Authentication succeeded but CryptoObject is missing!", Toast.LENGTH_SHORT).show()
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
}
})
if (initCipher(Cipher.DECRYPT_MODE, retrieveStoredIV())) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your fingerprint")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
// Updated: Fixing how cipher is used after biometric authentication completes
private fun showBiometricPromptForEncryption(plainText: String) {
val executor = ContextCompat.getMainExecutor(this)
val biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
val cryptoObject = result.cryptoObject
if (cryptoObject != null) {
try {
// Encrypt after biometric authentication
val encryptedData = cryptoObject.cipher?.doFinal(plainText.toByteArray())
if (encryptedData != null) {
val iv = cryptoObject.cipher?.iv // Get the generated IV
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv!!)
Toast.makeText(this@MainActivity, "Encryption successful!", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@MainActivity, "Encryption error!", Toast.LENGTH_SHORT).show()
}
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(this@MainActivity, "Authentication failed", Toast.LENGTH_SHORT).show()
}
})
if (initCipher(Cipher.ENCRYPT_MODE)) {
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication for Encryption")
.setSubtitle("Use your fingerprint to encrypt data")
.setNegativeButtonText("Use password")
.build()
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
}
// Check if decrypted data is valid
private fun isValidData(decryptedData: ByteArray): Boolean {
return decryptedData.contentEquals(expectedData) // Example validation
}
// Decrypt data using the CryptoObject
private fun decryptData(cryptoObject: BiometricPrompt.CryptoObject): ByteArray? {
return try {
val encryptedData = Base64.decode(retrieveEncryptedData(), Base64.DEFAULT)
val iv = retrieveStoredIV() // Retrieve the stored IV
if (initCipher(Cipher.DECRYPT_MODE, iv)) { // Use the retrieved IV
val decryptedData = cryptoObject.cipher?.doFinal(encryptedData)
decryptedData
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun encryptAndStoreData(plainText: String) {
if (initCipher(Cipher.ENCRYPT_MODE)) {
try {
val encryptedData = cipher.doFinal(plainText.toByteArray())
val iv = cipher.iv // Get the generated IV
storeEncryptedDataAndIV(Base64.encodeToString(encryptedData, Base64.DEFAULT), iv)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "Encryption failed", Toast.LENGTH_SHORT).show()
}
}
}
// Simulate storing encrypted data and IV (replace with actual storage logic)
private fun storeEncryptedDataAndIV(encryptedData: String, iv: ByteArray) {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("encrypted_data", encryptedData)
editor.putString("iv", Base64.encodeToString(iv, Base64.DEFAULT)) // Store the IV as Base64 string
editor.apply()
}
// Retrieve encrypted data and IV
private fun retrieveEncryptedData(): String {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
return sharedPreferences.getString("encrypted_data", "") ?: ""
}
private fun retrieveStoredIV(): ByteArray {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val ivString = sharedPreferences.getString("iv", null)
return Base64.decode(ivString, Base64.DEFAULT)
}
private fun showSuccess() {
Toast.makeText(this, "Authentication successful!", Toast.LENGTH_SHORT).show()
val intent = Intent(this, SuccessActivity::class.java)
startActivity(intent)
finish() // Optionally finish MainActivity to prevent going back without re-authentication
}
private fun resetEncryptedData() {
val sharedPreferences = getSharedPreferences("biometric_prefs", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.remove("encrypted_data") // Remove encrypted data
editor.remove("iv") // Remove IV
editor.apply()
Log.d("Reset", "Encrypted data and IV reset.")
Toast.makeText(this, "Encrypted data reset.", Toast.LENGTH_SHORT).show()
}
// Show a message to the user when Frida is detected and close the app
private fun showFridaDetectedDialog() {
val builder = AlertDialog.Builder(this)
builder.setTitle("Security Warning")
builder.setMessage("Frida or another tampering tool has been detected. The app will now close for security reasons.")
builder.setCancelable(false)
builder.setPositiveButton("OK") { dialog: DialogInterface, _: Int ->
dialog.dismiss()
closeApp()
}
val dialog: AlertDialog = builder.create()
dialog.show()
}
// Method to close the app
private fun closeApp() {
Toast.makeText(this, "Closing app...", Toast.LENGTH_SHORT).show()
finishAffinity() // Close the app completely
}
}
Limitations and Evasion of Native Frida Detection
Although this native Frida detection is a useful technique to enhance security, it is relatively easy to bypass. Whether using Frida itself or through reverse engineering with tools like JADX, attackers can effectively neutralize these detection mechanisms with minimal effort.
One of the simplest ways to bypass native detection mechanisms is to use Frida itself. By hooking into the app's runtime, you can modify the behavior of the detection function and force it to return a benign result. Here’s an example of how you can bypass the detectFrida()
function using a Frida script:
Java.perform(function() {
// Hook a method in an Android app (replace the class and method names)
var MainActivity = Java.use('com.example.localauth.MainActivity');
// Intercept the method and modify its return value
MainActivity.detectFrida.implementation = function() {
console.log("Bypassing detectFrida method...");
return false; // Always return false to bypass Frida detection
};
});
This script works by hooking into the MainActivity
class and replacing the implementation of the detectFrida()
function. Instead of executing the original detection logic, the script ensures that the function always returns false
, indicating that no Frida processes are detected, effectively bypassing the security check.
Java.use('com.example.localauth.MainActivity')
: Hooks into theMainActivity
class, where thedetectFrida()
function is defined.- Modifying the Function: The original implementation of
detectFrida()
is replaced, and it now always returnsfalse
, preventing the app from detecting Frida.
This approach is particularly effective because Frida operates at the Java layer, making it easy to manipulate the execution of methods without needing to modify the underlying native code directly.
Conclusions
In conclusion, using native libraries in Android applications can greatly improve security by making it harder to manipulate the app's runtime or bypass defenses. Since native code operates outside the managed Android environment, it provides a stronger layer of protection against tampering, reverse engineering, and dynamic analysis tools like Frida. This deeper level of integration helps reinforce existing security mechanisms, such as SSL pinning or root detection, by adding additional barriers that operate at the system level.
However, even with these protections in place, it’s important to understand that skilled adversaries can still find ways to bypass native defenses. Tools like Frida can be used to hook into the app and disable security checks, highlighting the need for a more comprehensive security approach that anticipates these potential bypasses.
In the next chapter, we’ll explore more advanced scenarios where circumventing protections becomes significantly more challenging. We’ll discuss techniques that make it difficult to rely on tools like Frida and may require deeper methods, such as reverse engineering the native library, to successfully overcome security measures. This will help uncover the strategies needed to navigate more complex security setups.
Chapters
Previous chapter
Next chapter