Introduction to Native Cryptography Interception
Modern Android applications frequently leverage native libraries (written in C/C++) for performance-critical operations, including cryptography. This often involves using popular libraries like OpenSSL, BoringSSL, or even custom implementations. When conducting penetration tests or reverse engineering, intercepting cryptographic operations at the native layer is crucial for understanding how data is secured, uncovering keys, or bypassing encryption. Frida, a dynamic instrumentation toolkit, provides unparalleled capabilities for hooking into native functions on ARM and ARM64 architectures, making it an indispensable tool for this task.
This article will guide you through the process of using Frida to intercept AES and RSA cryptographic functions within Android native libraries. We’ll cover identifying target functions through static analysis, crafting Frida scripts to hook into these functions, and extracting sensitive cryptographic parameters.
Prerequisites
- Rooted Android device or emulator with Frida server running.
- ADB (Android Debug Bridge) installed and configured on your host machine.
- Frida client installed on your host machine (
pip install frida-tools). - Static analysis tools like Ghidra or IDA Pro for examining native libraries.
- Basic understanding of ARM/ARM64 assembly and C/C++ calling conventions.
Identifying Cryptographic Functions in Native Libraries
The first step is to locate the native library (.so file) and the specific cryptographic functions you want to hook. This typically involves static analysis.
1. Extracting the Native Library
Use ADB to pull the application’s native libraries from the device. They are usually found in /data/app/your.package.name/lib/arm64 or /data/app/your.package.name/lib/arm.
adb shell pm path your.package.name # Get package path like /data/app/~~.../your.package.name-XYZ==/base.apk
adb pull /data/app/~~.../your.package.name-XYZ==/lib/arm64/libyourlib.so
2. Static Analysis with Ghidra/IDA Pro
Load the extracted .so file into Ghidra or IDA Pro. Search for common cryptographic function names. Libraries like OpenSSL and BoringSSL have well-known function prefixes:
- AES:
AES_set_encrypt_key,AES_encrypt,AES_decrypt,EVP_EncryptInit_ex,EVP_EncryptUpdate,EVP_EncryptFinal_ex - RSA:
RSA_public_encrypt,RSA_private_decrypt,RSA_sign,RSA_verify,EVP_PKEY_encrypt_init,EVP_PKEY_encrypt
Look for cross-references to these functions from the application’s Java Native Interface (JNI) methods or other internal functions. Pay attention to the function signatures, especially arguments for keys, IVs, input/output buffers, and lengths.
If the symbols are stripped, you might need to identify functions by their unique byte patterns (signatures) or by observing their calls from other known functions.
Frida Hooking for ARM/ARM64 Functions
Frida allows you to attach to a process and inject JavaScript code to modify its behavior. For native functions, we’ll use Module.findExportByName() for exported functions or Module.base.add(offset) for internal functions found via static analysis.
Basic Hooking Structure
Java.perform(function() {
var moduleName = "libyourlib.so"; // Or libcrypto.so
var targetModule = Module.findBaseAddress(moduleName);
if (targetModule) {
console.log("[*] Module '" + moduleName + "' loaded at: " + targetModule);
// Example: Hooking an exported function by name
var funcPtr = Module.findExportByName(moduleName, "AES_set_encrypt_key");
// Example: Hooking an unexported function by offset
// var funcOffset = 0x12345; // Get this from Ghidra/IDA
// var funcPtr = targetModule.add(funcOffset);
if (funcPtr) {
console.log("[*] Found target function at: " + funcPtr);
Interceptor.attach(funcPtr, {
onEnter: function(args) {
console.log("[+] Entering function at " + funcPtr);
// ... process arguments ...
},
onLeave: function(retval) {
console.log("[-] Leaving function at " + funcPtr + ", retval: " + retval);
// ... process return value ...
}
});
} else {
console.error("[-] Target function not found in '" + moduleName + "'");
}
} else {
console.error("[-] Module '" + moduleName + "' not found");
}
});
Important: Pay attention to the calling convention. For ARM/ARM64, arguments are typically passed in registers (x0-x7 on ARM64, r0-r3 on ARM) first, then on the stack. Frida’s args array in onEnter reflects this.
Example 1: Intercepting AES Key and Data
Let’s assume an application uses AES_set_encrypt_key from libcrypto.so to initialize an AES encryption key and then AES_encrypt for encryption. We will focus on capturing the key and the plaintext/ciphertext.
Target Function: AES_set_encrypt_key
Signature (OpenSSL): int AES_set_encrypt_key(const unsigned char *userKey, const int bits, AES_KEY *key);
We want to capture userKey and bits.
Java.perform(function() {
var libcrypto = Module.findExportByName(null, "AES_set_encrypt_key");
if (!libcrypto) {
// Try to find in a specific module if not globally exported
var specificLib = Module.findBaseAddress("libssl.so") || Module.findBaseAddress("libcrypto.so");
if(specificLib) {
// You might need to find the offset if it's not exported by name
// For demonstration, let's assume it's directly in libcrypto or another module
libcrypto = specificLib.findExportByName("AES_set_encrypt_key");
}
}
if (libcrypto) {
console.log("[+] Hooking AES_set_encrypt_key at: " + libcrypto);
Interceptor.attach(libcrypto, {
onEnter: function(args) {
this.userKeyPtr = args[0];
this.bits = args[1].toInt32();
console.log("[*] AES_set_encrypt_key called!");
console.log(" Key Length (bits): " + this.bits);
console.log(" User Key Pointer: " + this.userKeyPtr);
console.log(" User Key (Hex): " + this.userKeyPtr.readByteArray(this.bits / 8).hexlify());
},
onLeave: function(retval) {
console.log("[*] AES_set_encrypt_key returned: " + retval);
}
});
} else {
console.error("[-] AES_set_encrypt_key not found. Check if the app uses OpenSSL/BoringSSL or a custom implementation.");
}
// Hook AES_encrypt to get plaintext/ciphertext
var aesEncrypt = Module.findExportByName(null, "AES_encrypt");
if (!aesEncrypt) {
// Similar specificLib search as above if needed
var specificLib = Module.findBaseAddress("libssl.so") || Module.findBaseAddress("libcrypto.so");
if(specificLib) {
aesEncrypt = specificLib.findExportByName("AES_encrypt");
}
}
if (aesEncrypt) {
console.log("[+] Hooking AES_encrypt at: " + aesEncrypt);
Interceptor.attach(aesEncrypt, {
onEnter: function(args) {
this.inPtr = args[0]; // input buffer (plaintext)
this.outPtr = args[1]; // output buffer (ciphertext)
// args[2] is AES_KEY*
console.log("[*] AES_encrypt called!");
// Assuming AES block size is 16 bytes for this example
console.log(" Input (Plaintext) Hex: " + this.inPtr.readByteArray(16).hexlify());
// The output buffer might not be filled yet, we'll read it onLeave
},
onLeave: function(retval) {
console.log(" Output (Ciphertext) Hex: " + this.outPtr.readByteArray(16).hexlify());
console.log("[*] AES_encrypt returned.");
}
});
} else {
console.error("[-] AES_encrypt not found. Cannot intercept AES data.");
}
});
Example 2: Intercepting RSA Public Key Encryption
For RSA, we might want to intercept functions like RSA_public_encrypt or RSA_private_decrypt to obtain the plaintext or ciphertext, and potentially the public/private key parameters.
Target Function: RSA_public_encrypt
Signature (OpenSSL): int RSA_public_encrypt(int flen, const unsigned char *from, unsigned char *to, RSA *rsa, int padding);
We’ll capture flen (plaintext length), from (plaintext buffer), and to (ciphertext buffer).
Java.perform(function() {
var rsaPublicEncrypt = Module.findExportByName(null, "RSA_public_encrypt");
if (!rsaPublicEncrypt) {
var specificLib = Module.findBaseAddress("libssl.so") || Module.findBaseAddress("libcrypto.so");
if(specificLib) {
rsaPublicEncrypt = specificLib.findExportByName("RSA_public_encrypt");
}
}
if (rsaPublicEncrypt) {
console.log("[+] Hooking RSA_public_encrypt at: " + rsaPublicEncrypt);
Interceptor.attach(rsaPublicEncrypt, {
onEnter: function(args) {
this.flen = args[0].toInt32(); // Input data length
this.fromPtr = args[1]; // Plaintext buffer
this.toPtr = args[2]; // Ciphertext buffer
this.rsaPtr = args[3]; // RSA key structure
this.padding = args[4].toInt32(); // Padding mode
console.log("[*] RSA_public_encrypt called!");
console.log(" Plaintext Length: " + this.flen);
console.log(" Plaintext (Hex): " + this.fromPtr.readByteArray(this.flen).hexlify());
console.log(" Padding Mode: " + this.padding);
// Optionally, dump RSA key details (modulus, exponent)
// This requires parsing the RSA struct, which is more complex and library-specific.
// For OpenSSL, you might need to hook functions like RSA_get0_key.
},
onLeave: function(retval) {
// The output length (ciphertext) depends on the RSA key size and padding.
// A common RSA key size is 2048 bits = 256 bytes.
// Assuming 256 bytes for output ciphertext length for a 2048-bit RSA key.
var ciphertextLength = 256; // Adjust based on key size
console.log(" Ciphertext (Hex): " + this.toPtr.readByteArray(ciphertextLength).hexlify());
console.log("[*] RSA_public_encrypt returned: " + retval);
}
});
} else {
console.error("[-] RSA_public_encrypt not found.");
}
});
Running Your Frida Script
Save your Frida script as hook_crypto.js and run it against your target application:
frida -U -f your.package.name -l hook_crypto.js --no-pause
This command will spawn the application, attach Frida, load your script, and then pause until the application’s code is executed. The --no-pause option is often useful if you want the app to start immediately.
Challenges and Advanced Tips
- Stripped Binaries: If symbols are stripped, you’ll rely heavily on static analysis (Ghidra/IDA) to find function entry points by their disassembly signature or by tracing calls from JNI methods.
- Inlined Functions: Cryptographic operations might be inlined by the compiler, making direct hooking of a named function impossible. In such cases, you might need to hook calling functions or use Frida’s Stalker to trace execution.
- Custom Implementations: Some applications implement their own crypto. You’ll need to reverse engineer these to understand their logic and identify where keys/data are handled.
- Anti-Frida Measures: Apps can detect Frida by checking for Frida server processes, mapped memory regions, or hooked functions. Bypassing these requires more advanced Frida techniques, often involving custom loaders or modifications to the Frida client/server.
- Memory Management: When reading buffers, ensure you read the correct length to avoid crashes or incomplete data.
Conclusion
Frida is an exceptionally powerful tool for dynamic analysis of native Android applications, especially when dealing with cryptographic functions. By combining static analysis with Frida’s dynamic instrumentation capabilities, security researchers and reverse engineers can effectively penetrate the native layer to understand, intercept, and manipulate critical security operations. Mastering these techniques opens up a new realm of possibilities for dissecting complex Android applications and uncovering their hidden secrets.
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 →