Introduction: Navigating the Native Labyrinth of Android Games
Android games often leverage native code (C/C++) for performance-critical sections, graphics rendering, physics engines, and crucial game logic. This makes them significantly harder to reverse engineer than purely Java-based applications, as traditional decompilers like Jadx or Bytecode Viewer fall short when faced with compiled native libraries. For security researchers, game modders, or anyone seeking to understand the inner workings of these applications, dynamic instrumentation frameworks like Frida become indispensable.
This case study delves into using Frida to effectively reverse engineer Android native games, specifically focusing on the Java Native Interface (JNI). JNI acts as the crucial bridge, enabling Java code to interact with native libraries and vice-versa. By hooking into these JNI calls, we can inspect, modify, and even bypass core game logic implemented at the native level, without needing extensive static analysis or recompilation.
Understanding JNI in Android Native Games
The Java Native Interface (JNI) is a programming framework that allows Java code running in a Java Virtual Machine (JVM) to call and be called by native applications (libraries or programs specific to a hardware and operating system platform) written in other languages, such as C, C++, and assembly. In Android, this means your Java/Kotlin app can invoke functions within an .so (shared object) library.
Native methods are typically declared in Java with the native keyword and then implemented in a corresponding C/C++ file. The linking between Java and native is often done through specific naming conventions (e.g., Java_com_package_name_ClassName_methodName) or by explicitly registering native methods using RegisterNatives within the JNI_OnLoad function.
Prerequisites for Frida JNI Hooking
- Rooted Android Device or Emulator: Necessary for running
frida-server. - ADB (Android Debug Bridge): For interacting with the device and pushing
frida-server. - Frida & Frida-tools: Installed on your host machine (
pip install frida-tools). - Target Android Game/Application: An APK with native libraries.
- Basic Understanding of C/C++ and Java: To comprehend the JNI function signatures.
- Optional: IDA Pro or Ghidra for static analysis (useful for identifying native functions, but not strictly required for direct JNI hooking if you can trace calls).
Setting Up Frida for Android
- Push
frida-serverto Device: Download the correctfrida-serverfor your device’s architecture (e.g.,arm64,x86_64) from Frida releases. Then:adb push frida-server /data/local/tmp/frida-server
- Set Permissions and Run:
adb shellchmod 755 /data/local/tmp/frida-server/data/local/tmp/frida-server &
- Verify Frida Connection: On your host machine:
You should see a list of running processes on your Android device.frida-ps -U
Identifying Native Targets and JNI Methods
Before hooking, we need to know what to hook. For JNI functions, the common pattern is Java_<package>_<class>_<method>.
Method 1: Using objection to Enumerate
Objection, built on Frida, can quickly list loaded classes and methods, including native ones.
objection --gadget 'com.example.game' explore
Once inside the objection console, you can use commands like android hooking list classes, android hooking search classes <keyword>, or android hooking list class_methods <fully.qualified.ClassName> to identify potential targets. Native methods will often be clearly marked.
Method 2: Using frida-trace
frida-trace can trace exported functions from native libraries. While not always directly JNI method names, it can reveal important functions.
frida-trace -U -f com.example.game -i 'exports:libgame.so!*'
This will hook all exported functions in libgame.so. Look for suspicious function calls related to game logic.
Method 3: Code Inspection (via Decompiler)
If you have access to a decompiler like Ghidra or IDA Pro, you can analyze the .so files directly. Search for JNI_OnLoad to find where native methods are registered or look for functions matching the JNI naming convention. This gives you exact addresses and signatures.
Case Study: Bypassing a Native License Check
Let’s assume we have an Android game, com.example.game, that performs a license check using a native function. Through observation (or static analysis), we’ve identified a native method called checkLicense within the NativeGameLib class that resides in libgame.so. Its JNI signature might look like this in C++:
extern "C" JNIEXPORT jboolean JNICALL Java_com_example_game_NativeGameLib_checkLicense(JNIEnv* env, jobject thiz, jstring licenseKey)
Our goal is to always make this function return true, effectively bypassing the license check.
Frida Script for JNI Hooking
Here’s a Frida script (hook_license.js) to achieve this:
Java.perform(function() { // Load the native library if not already loaded (optional, but good practice) // If the game loads it dynamically, you might need to hook `dlopen` or wait. var nativeLibrary = Module.findExportByName('libgame.so', 'Java_com_example_game_NativeGameLib_checkLicense'); if (!nativeLibrary) { console.log('[-] Could not find Java_com_example_game_NativeGameLib_checkLicense. Trying to find the library first.'); // Try to find the base address of the library itself var libgame_base = Module.findBaseAddress('libgame.so'); if (libgame_base) { console.log('[+] libgame.so loaded at:', libgame_base); // Now we can try to find the offset if we know it from static analysis // Or more robustly, enumerate exports again. // For demonstration, let's assume direct export lookup works after a short delay. // In a real scenario, you might need to use `Interceptor.attach` on `JNI_OnLoad` // to know when the library is fully initialized and its exports are available. } else { console.log('[-] libgame.so not found. Make sure it is loaded by the target process.'); return; } } // Re-attempt to find the function, maybe it loads later nativeLibrary = Module.findExportByName('libgame.so', 'Java_com_example_game_NativeGameLib_checkLicense'); if (!nativeLibrary) { console.log('[-] Still could not find Java_com_example_game_NativeGameLib_checkLicense after library check.'); return; } console.log('[+] Found JNI native method: Java_com_example_game_NativeGameLib_checkLicense at', nativeLibrary); Interceptor.attach(nativeLibrary, { onEnter: function(args) { console.log('[*] Entering Java_com_example_game_NativeGameLib_checkLicense'); // args[0] is JNIEnv* // args[1] is jobject (the 'this' reference for static methods or instance for non-static) // args[2] is jstring licenseKey // To read the jstring, we need JNIEnv.GetStringUTFChars var env = args[0]; var jniEnv = Java.vm.get === undefined ? Java.vm.getEnv() : Java.vm.getEnv(); // Handle different Frida versions if (jniEnv) { var licenseKeyPtr = args[2]; var licenseKey = jniEnv.getStringUtfChars(licenseKeyPtr, null).readUtf8String(); console.log('[+] Original license key argument:', licenseKey); // You could modify args[2] here if you wanted to change the input key } else { console.log('[-] JNIEnv not available to read jstring.'); } }, onLeave: function(retval) { console.log('[*] Original return value:', retval); // Modify the return value to always be true (JNI jboolean is 0 for false, 1 for true) retval.replace(1); // Set return value to 1 (true) console.log('[+] Bypassed! Modified return value to:', retval); } }); console.log('[+] Hooked Java_com_example_game_NativeGameLib_checkLicense successfully!');});
Executing the Frida Script
To run this script against your target game:
frida -U -l hook_license.js -f com.example.game --no-pause
-U specifies the USB device, -l loads the script, -f spawns the process (restarting if already running), and --no-pause immediately resumes the spawned process.
When the game attempts to call checkLicense, you’ll see output in your console similar to:
[*] Entering Java_com_example_game_NativeGameLib_checkLicense[+] Original license key argument: YOUR_INVALID_LICENSE_KEY_HERE[*] Original return value: 0x0 (false)[+] Bypassed! Modified return value to: 0x1 (true)
The game should now proceed as if a valid license was provided.
Interacting with JNI Types in Frida
When hooking JNI functions, understanding how to interact with JNI types (jint, jstring, jobject, etc.) is crucial.
- Primitive Types (
jint,jboolean,jfloat, etc.): These are direct numerical values. You can read them directly fromargs[N](e.g.,args[N].toInt32(),args[N].readU8()) or modify them usingretval.replace(newValue). jstring: To read ajstring, you need to use theJNIEnvpointer. As shown in the example,jniEnv.getStringUtfChars(jstringPtr, null).readUtf8String()is the way to go. To create a newjstring, you’d usejniEnv.newStringUtf(
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 →