Introduction
Android applications often leverage native libraries (C/C++) via the Native Development Kit (NDK) for performance-critical operations, code obfuscation, or to reuse existing C/C++ codebases. Cryptographic functions are a prime candidate for NDK implementation, making their analysis challenging for penetration testers and security researchers. Understanding how these native crypto routines operate—identifying keys, IVs, plaintexts, and ciphertexts—is crucial for assessing an app’s security posture. This article delves into using Frida, a dynamic instrumentation toolkit, to hook and analyze native C/C++ cryptographic functions within Android NDK libraries.
Why Native Crypto?
Developers choose native implementations for several reasons:
- Performance: C/C++ often offers better performance than Java/Kotlin, especially for computationally intensive tasks like cryptography.
- Obfuscation: Native code is generally harder to decompile and reverse engineer than Dalvik bytecode, providing a layer of protection against analysis.
- Code Reusability: Existing C/C++ crypto libraries (e.g., OpenSSL, Libsodium) can be directly integrated, saving development time.
However, this obfuscation also means traditional Java-level hooking (e.g., Xposed) won’t suffice. This is where Frida’s ability to interact with native memory and functions shines.
Prerequisites
- An Android device or emulator with root access.
- ADB (Android Debug Bridge) installed and configured on your host machine.
- Frida server running on the Android device.
- Frida-tools installed on your host machine (`pip install frida-tools`).
- Basic knowledge of C/C++, JNI, and Android NDK build process.
- Reverse engineering tools like Ghidra or IDA Pro (recommended for identifying native functions).
Identifying Native Cryptographic Functions
Before hooking, you need to know what to hook. This involves reverse engineering the application’s native libraries.
1. Locate Native Libraries
Android NDK libraries are typically found in the app’s `lib` directory (e.g., `/data/app/com.example.app/lib/arm64`). You can pull them using ADB:
adb shell pm path com.example.app # Get APK path
adb pull /data/app/com.example.app-1/lib/arm64/libnativecrypto.so .
2. Analyze Library Symbols
Use tools like `nm` (from binutils, often part of an NDK toolchain) or disassemblers (Ghidra, IDA Pro) to identify exported functions. Look for function names that suggest cryptographic operations (e.g., `encryptData`, `decryptBuffer`, `AES_init_key`, `Java_com_example_app_NativeCrypto_encrypt`).
For JNI-exposed methods, the naming convention is `Java_package_name_ClassName_MethodName`.
nm -D libnativecrypto.so | grep Java_com_example_app
nm -D libnativecrypto.so | grep encrypt
This will list dynamically linked symbols. If a function is not exported, you’ll need to find its offset within the library’s base address, which is a more advanced technique requiring deeper reverse engineering.
Developing a Target: A Simple Native Crypto Example
Let’s consider a simplified native library with a JNI function that calls an internal C++ encryption routine. We’ll use a basic XOR cipher for demonstration.
Native C++ Code (`native_crypto.cpp`)
#include
#include
#include
#include
#define LOG_TAG "NativeCrypto"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
// Internal "encryption" function
std::vector internalXorEncrypt(const std::vector& data, const std::string& key) {
LOGI("internalXorEncrypt called. Data size: %zu, Key length: %zu", data.size(), key.length());
std::vector result(data.size());
for (size_t i = 0; i < data.size(); ++i) {
result[i] = data[i] ^ key[i % key.length()];
}
return result;
}
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_example_fridahookndk_NativeCrypto_encryptData(
JNIEnv* env,
jclass clazz,
jbyteArray data,
jstring key) {
// Convert jbyteArray to std::vector
jbyte* data_ptr = env->GetByteArrayElements(data, NULL);
jsize data_len = env->GetArrayLength(data);
std::vector plain_data(data_ptr, data_ptr + data_len);
env->ReleaseByteArrayElements(data, data_ptr, JNI_ABORT);
// Convert jstring to std::string
const char* key_cstr = env->GetStringUTFChars(key, NULL);
std::string encryption_key(key_cstr);
env->ReleaseStringUTFChars(key, key_cstr);
LOGI("JNI_encryptData: Plaintext size: %zu, Key: %s", plain_data.size(), encryption_key.c_str());
// Call internal encryption function
std::vector encrypted_data = internalXorEncrypt(plain_data, encryption_key);
// Convert std::vector to jbyteArray
jbyteArray j_encrypted_data = env->NewByteArray(encrypted_data.size());
env->SetByteArrayRegion(j_encrypted_data, 0, encrypted_data.size(), (jbyte*)encrypted_data.data());
return j_encrypted_data;
}
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_example_fridahookndk_NativeCrypto_decryptData(
JNIEnv* env,
jclass clazz,
jbyteArray data,
jstring key) {
// Similar conversion and calling internalXorEncrypt for decryption (XOR is symmetric)
// ... (omitted for brevity, assume similar logic to encryptData)
jbyte* data_ptr = env->GetByteArrayElements(data, NULL);
jsize data_len = env->GetArrayLength(data);
std::vector cipher_data(data_ptr, data_ptr + data_len);
env->ReleaseByteArrayElements(data, data_ptr, JNI_ABORT);
const char* key_cstr = env->GetStringUTFChars(key, NULL);
std::string encryption_key(key_cstr);
env->ReleaseStringUTFChars(key, key_cstr);
LOGI("JNI_decryptData: Ciphertext size: %zu, Key: %s", cipher_data.size(), encryption_key.c_str());
std::vector decrypted_data = internalXorEncrypt(cipher_data, encryption_key); // XOR is symmetric
jbyteArray j_decrypted_data = env->NewByteArray(decrypted_data.size());
env->SetByteArrayRegion(j_decrypted_data, 0, decrypted_data.size(), (jbyte*)decrypted_data.data());
return j_decrypted_data;
}
`CMakeLists.txt` for NDK Build
cmake_minimum_required(VERSION 3.4.1)
add_library( # Sets the name of the library.
native_crypto
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
native_crypto.cpp )
find_library( # Sets the name of the path variable.
log-lib # The name of the NDK library that
# you want CMake to locate.
log )
target_link_libraries( # Specifies the target library for which
# you want to link other libraries.
native_crypto
# Specifies the libraries to link.
${log-lib} )
Writing the Frida Hook Script
Now, let’s create a Frida script (`hook_crypto.js`) to intercept the JNI `encryptData` function and its internal `internalXorEncrypt` call.
setTimeout(function() {
Java.perform(function() {
console.log("[*] Frida script started.");
// --- Hooking JNI exposed method ---
// Target the NativeCrypto class and its encryptData method
var NativeCrypto = Java.use('com.example.fridahookndk.NativeCrypto');
NativeCrypto.encryptData.implementation = function(data, key) {
console.log("n[+] JNI Method Hook: NativeCrypto.encryptData Called!");
// Read the jbyteArray (data) argument
var plainByteArray = Java.array('byte', data);
var plaintext = '';
for (var i = 0; i < plainByteArray.length; i++) {
plaintext += String.fromCharCode(plainByteArray[i]);
}
console.log(" Plaintext (JNI): " + plaintext);
// Read the jstring (key) argument
var encryptionKey = key.toString();
console.log(" Key (JNI): " + encryptionKey);
// Call the original method
var result = this.encryptData(data, key);
// Read the jbyteArray (result) argument
var cipherByteArray = Java.array('byte', result);
var ciphertext = '';
for (var i = 0; i < cipherByteArray.length; i++) {
ciphertext += String.fromCharCode(cipherByteArray[i]);
}
console.log(" Ciphertext (JNI): " + ciphertext);
console.log("[+] JNI Method Hook: NativeCrypto.encryptData Returned!");
return result;
};
NativeCrypto.decryptData.implementation = function(data, key) {
console.log("n[+] JNI Method Hook: NativeCrypto.decryptData Called!");
var cipherByteArray = Java.array('byte', data);
var ciphertext = '';
for (var i = 0; i < cipherByteArray.length; i++) {
ciphertext += String.fromCharCode(cipherByteArray[i]);
}
console.log(" Ciphertext (JNI): " + ciphertext);
var decryptionKey = key.toString();
console.log(" Key (JNI): " + decryptionKey);
var result = this.decryptData(data, key);
var plainByteArray = Java.array('byte', result);
var plaintext = '';
for (var i = 0; i < plainByteArray.length; i++) {
plaintext += String.fromCharCode(plainByteArray[i]);
}
console.log(" Decrypted Plaintext (JNI): " + plaintext);
console.log("[+] JNI Method Hook: NativeCrypto.decryptData Returned!");
return result;
};
// --- Hooking Native (C++) function directly ---
// First, find the base address of the native library
var libNativeCrypto = Module.findExportByName("libnative_crypto.so", "Java_com_example_fridahookndk_NativeCrypto_encryptData");
if (!libNativeCrypto) {
console.error("[-] Could not find JNI encryptData export in libnative_crypto.so");
return;
}
// For internalXorEncrypt, it might not be exported directly.
// You would typically find its offset using Ghidra/IDA.
// Let's assume for this example, we found its symbol in exports or calculated its offset.
// If not exported, you'd need Module.base.add(offset).
// For demonstration, let's target the internalXorEncrypt function, assuming we have its symbol (unlikely for private functions, but possible if compiled with debug symbols or specific linker settings).
// If it's a private function, you'd calculate its offset relative to the library base.
// For simplicity, let's pretend internalXorEncrypt is also found by its full mangled name or a known offset.
// A more realistic scenario involves finding the call site in JNI_encryptData and jumping/hooking there.
// For this example, we'll try to find internalXorEncrypt. If it's not exported, Module.findExportByName will return null.
// In a real scenario, you'd manually find the address with Ghidra/IDA relative to libnative_crypto.so's base address.
// For our simple C++ code above, `internalXorEncrypt` is not `extern "C"` and thus will be name-mangled.
// We'd need the mangled name (e.g., `_Z18internalXorEncryptRKSt6vectorIhSaIhEERKSt12__cxx11string`) or an offset.
// Let's hook the JNI function directly as a more reliable approach for entry points.
// Let's refine the native hook to focus on a C-style exported function or a known offset.
// As our internalXorEncrypt is a C++ function not exported as `extern "C"`, it's complex to hook by name.
// A simpler native hook example might be: `Module.findExportByName("libc.so", "strlen");`
// We'll stick to JNI method hooking which is usually the entry point to native crypto.
// However, if we found an exported C function, this is how you'd hook it:
/*
var myNativeFunctionPtr = Module.findExportByName("libnative_crypto.so", "my_exported_c_function");
if (myNativeFunctionPtr) {
Interceptor.attach(myNativeFunctionPtr, {
onEnter: function(args) {
console.log("n[+] Native Hook: my_exported_c_function Called!");
// args[0], args[1]... represent function arguments
// Example: Read a pointer to data
var dataPtr = args[0];
var dataSize = args[1].toInt32(); // Assuming size is the second argument
console.log(" Data Pointer: " + dataPtr);
console.log(" Data Size: " + dataSize);
console.log(" Data: " + Memory.readByteArray(dataPtr, dataSize));
},
onLeave: function(retval) {
console.log(" Return Value: " + retval);
console.log("[+] Native Hook: my_exported_c_function Returned!");
}
});
}
*/
console.log("[*] Frida script configured.");
});
}, 0);
Executing the Frida Script
1. Ensure the Frida server is running on your Android device/emulator.
adb shell frida-server # Or run in background
2. Run the Frida script, targeting your application’s package name:
frida -U -f com.example.fridahookndk -l hook_crypto.js --no-pause
The `–no-pause` flag tells Frida to automatically resume the application after injection. If you omit it, the app will pause, and you’ll need to manually resume it using `%resume` in the Frida console.
Analyzing the Output
When the application calls `NativeCrypto.encryptData` or `NativeCrypto.decryptData`, your Frida script will intercept these calls. The console output will display:
- The plaintext data and the encryption key being passed to the JNI `encryptData` method.
- The resulting ciphertext after the encryption.
- Similarly for decryption, the ciphertext, key, and the resulting plaintext.
This allows you to observe the inputs and outputs of the cryptographic operations in real-time. If the encryption key or plaintext is sensitive, you’ve successfully identified where it’s being used and can log it for further analysis.
For example, you might see output like:
[*] Frida script started.
[*] Frida script configured.
[+] JNI Method Hook: NativeCrypto.encryptData Called!
Plaintext (JNI): Hello, World!
Key (JNI): mySecretKey
[+] JNI Method Hook: NativeCrypto.encryptData Returned!
[+] JNI Method Hook: NativeCrypto.decryptData Called!
Ciphertext (JNI):
Key (JNI): mySecretKey
Decrypted Plaintext (JNI): Hello, World!
(Note: The ciphertext for XOR will likely be non-printable characters, so printing `String.fromCharCode` might result in empty or garbled output depending on the content. For real crypto, you’d typically log hex representations of byte arrays).
Advanced Considerations
- Non-Exported Functions: If the target native function is not exported (e.g., an internal helper function within a C++ class), you cannot use `Module.findExportByName`. Instead, you’ll need to use a disassembler (Ghidra/IDA) to find its exact offset from the library’s base address and then hook it using `Module.base.add(offset)`.
- Name Mangling: C++ functions often have mangled names. Tools like `c++filt` can demangle them, but finding the exact mangled name for `Module.findExportByName` can be tricky. Using offsets or hooking the JNI entry point is often more reliable.
- Register Analysis: For more complex native functions, especially those that pass arguments in registers, you might need to analyze the `this.context.r0`, `r1`, `x0`, `x1` etc., depending on the architecture (ARM, ARM64) within `onEnter` and `onLeave`.
- Memory Structures: Often, cryptographic functions operate on custom C/C++ structs. You’ll need to define these structures in your Frida script using `Memory.alloc` and `read` functions, or `NativeStruct` to properly interpret the arguments.
Conclusion
Frida provides a powerful and flexible platform for deep analysis of Android applications, particularly when dealing with native code. By understanding how to identify, hook, and interpret calls to NDK-based cryptographic functions, security researchers can uncover sensitive data, bypass obfuscation, and gain critical insights into an app’s security mechanisms. This technique is invaluable for comprehensive penetration testing and reverse engineering of Android applications that rely on native crypto implementations.
Android Mobile Specs & Compare Directory
Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!
Compare Devices Specs →