Enhancing Android Security with Native Libraries: Implementation and Evasion Techniques

17 min read

October 13, 2024

Securing Android: An In-Depth Exploration
Enhancing Android Security with Native Libraries: Implementation and Evasion Techniques

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

  1. Open Android Studio and go to File > Settings (on macOS, it's Android Studio > Preferences).
  2. In the left-hand menu, navigate to Appearance & Behavior > System Settings > Android SDK.
  3. In the SDK Tools tab, scroll through the list and check the following boxes:
    • NDK (Side by Side)
    • CMake
  4. Click Apply and then OK to install both tools.
Installing NDK and CMake

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:

  1. In Android Studio, navigate to the Project view on the left panel.
  2. Right-click on the src/main directory, and select New > Directory.
  3. 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:

  1. Right-click on the newly created cpp folder, select New > C/C++ Source File.
  2. Name the file, for example, native-lib.cpp.
  3. 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:

  1. Right-click on the cpp folder, select New > File.
  2. 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:

Folder structure overview

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.

Working example
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:

  1. Checking for Suspicious Libraries: Frida often loads specific libraries like frida-agent or libfrida-gadget.so. The function checks if any of these libraries are present by scanning the process memory and filesystem. If found, it immediately returns JNI_TRUE, indicating Frida’s presence.
  2. 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.
  3. Checking Network Ports: Frida typically uses specific TCP ports (such as 27042 and 27043) 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.")
    }
Detection of frida
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.

Detection of the function to detect Frida

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
    };
});
Frida detection bypass

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 the MainActivity class, where the detectFrida() function is defined.
  • Modifying the Function: The original implementation of detectFrida() is replaced, and it now always returns false, 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

Botón Anterior
Securing Biometric Authentication: Defending Against Frida Bypass Attacks

Previous chapter

Patching Native Libraries for Frida Detection Bypass

Next chapter