Introduction: Beyond Basic Obfuscation
While tools like ProGuard and DexGuard provide robust obfuscation for Android applications, sophisticated attackers often find ways to circumvent their default protections. A common target for reverse engineers is the extraction of sensitive strings—API keys, URLs, cryptographic secrets—which are often protected by custom encryption routines designed to evade straightforward static analysis. This article dives into advanced techniques for reverse engineering custom string encryption in Android apps, moving beyond simple string literals to dynamically decrypted content.
The Challenge: Identifying Custom Decryption Routaries
Basic string obfuscation typically involves encrypting strings at compile time and decrypting them at runtime using a predefined method. However, advanced techniques might incorporate multiple layers of encryption, leverage native libraries (JNI), or employ dynamic key generation. Our goal is to locate the decryption routine and understand its logic to recover the original strings.
Static Analysis with Jadx and Ghidra
The first step in reverse engineering is often static analysis. Tools like Jadx and Ghidra are indispensable for decompiling and disassembling Android applications.
- Decompiling the APK: Start by loading the APK into Jadx. Search for common patterns related to string manipulation. Look for methods that take an encrypted byte array or string as input and return a String.
- Identifying Decryption Methods: Custom decryption methods often have descriptive names (e.g.,
decryptString,decodeData) or appear in classes associated with security or utility functions. If names are obfuscated, look for methods with unusual control flow or heavy bitwise operations, XORs, or look-up tables. - Tracing Method Calls: Once a potential decryption method is identified, trace its call sites to understand where and how encrypted strings are passed to it. Pay attention to static initializers (
<clinit>) or constructors, as decryption often happens early in the app lifecycle.
Consider a typical Java-based custom decryption example:
public class StringDecryptor { private static final byte[] KEY = {0x12, 0x34, 0x56, 0x78, /* ... */}; // Example key public static String decrypt(byte[] encryptedBytes) { byte[] decryptedBytes = new byte[encryptedBytes.length]; for (int i = 0; i < encryptedBytes.length; i++) { decryptedBytes[i] = (byte) (encryptedBytes[i] ^ KEY[i % KEY.length]); } return new String(decryptedBytes, java.nio.charset.StandardCharsets.UTF_8); } public static String getSecret(int index) { byte[][] obfuscatedStrings = { {(byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF}, // "secret1" obfuscated {(byte)0xCA, (byte)0xFE, (byte)0xBA, (byte)0xBE} // "secret2" obfuscated }; return decrypt(obfuscatedStrings[index]); }}
In this scenario, you’d look for calls to StringDecryptor.getSecret(int) or directly to decrypt(byte[]), noting the byte arrays passed as arguments.
Dynamic Analysis with Frida
Static analysis can be challenging due to heavy obfuscation, dynamic key generation, or reflection. Dynamic analysis, particularly with Frida, allows us to hook into the application at runtime and observe the decryption process directly.
- Frida Setup: Ensure Frida server is running on your Android device and Frida tools are installed on your host machine.
- Identify Target Method: From your static analysis, pinpoint the exact class and method responsible for decryption (e.g.,
com.example.app.StringDecryptor.decrypt). - Hooking the Decryption Method: Write a Frida script to hook this method. The script can log the arguments passed to the decryption function (the encrypted string/byte array) and its return value (the decrypted string).
Example Frida script (decrypt_hook.js):
Java.perform(function () { var StringDecryptor = Java.use("com.example.app.StringDecryptor"); StringDecryptor.decrypt.overload('[B').implementation = function (encryptedBytes) { var decryptedString = this.decrypt(encryptedBytes); console.log("Decrypted String: " + decryptedString + " from encrypted bytes: " + encryptedBytes); return decryptedString; }; // If strings are fetched via an index-based method: StringDecryptor.getSecret.overload('int').implementation = function (index) { var secret = this.getSecret(index); console.log("Secret String (index " + index + "): " + secret); return secret; };});
Execute the script:
frida -U -l decrypt_hook.js -f com.example.app --no-pause
This command injects the script into the target application (com.example.app) and will print decrypted strings as they are processed by the application.
Native String Encryption (JNI)
A more advanced technique involves decrypting strings within native libraries (C/C++ via JNI). This adds another layer of complexity as you’ll be dealing with compiled machine code.
- Identifying JNI Calls: In Jadx, look for calls to
System.loadLibrary()or methods annotated with@Keepthat call native functions. These methods often retrieve strings from native code. - Native Library Analysis with Ghidra: Load the native shared library (e.g.,
libnative-lib.so) into Ghidra. Navigate to theexportssection to find functions likeJava_com_example_app_NativeLib_getString. - Reverse Engineering Native Decryption:
- Examine the decompiled C code in Ghidra for the native function. Look for patterns indicative of decryption: XOR loops, AES/DES calls (if libraries like OpenSSL are linked), or custom byte array manipulations.
- Identify where the encrypted string data resides (often in read-only data sections like
.rodataor embedded directly in the function). - Understand the decryption algorithm and extract the key. This may involve emulating portions of the code or carefully analyzing assembly instructions.
A simplified native C decryption example:
#include <jni.h>#include <string>// Example encrypted data (XORed with 'K')const char encrypted_data[] = {0x14, 0x1a, 0x1d, 0x1a, 0x01, 0x06, 0x16, 0x1e, 0x01, 0x0b, 0x06, 0x1b, 0x16, 0x0d, 0x00}; // Original: "HelloWorldSecret" XOR 'K'const char decryption_key = 'K';extern "C" JNIEXPORT jstring JNICALLJava_com_example_app_NativeLib_getSecretString(JNIEnv* env, jobject /* this */) { std::string decrypted_string; for (size_t i = 0; i < sizeof(encrypted_data); ++i) { decrypted_string += (char)(encrypted_data[i] ^ decryption_key); } return env->NewStringUTF(decrypted_string.c_str());}
In Ghidra, you’d find Java_com_example_app_NativeLib_getSecretString, identify the encrypted_data array and the decryption_key variable, and replicate the XOR loop.
Conclusion
Reverse engineering advanced Android string encryption requires a combination of static and dynamic analysis techniques. By systematically identifying decryption routines through code patterns, tracing execution flows, and leveraging runtime instrumentation with tools like Frida, even complex obfuscation schemes can be deconstructed. Native code analysis with tools like Ghidra extends this capability to JNI-based string protection, revealing secrets hidden within compiled machine code. As obfuscation techniques evolve, so too must the methods employed by reverse engineers, demanding a continuous learning curve in this intricate domain.
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 →