A Random and Simple Tip: Advanced Analysis of JNI Methods Using Frida

Thiago Peixoto
6 min readJust now

--

In this article, I will share a tip for those interested in performing a more detailed analysis of the behavior of native methods, with a specific focus on the use of function pointers within the JNIEnv structure. It is important to highlight that these methods are executed natively, rather than being interpreted by the Android Runtime, providing significantly better performance in computation-intensive operations. Since this involves native code — compiled directly for the hardware architecture of an Android device and executed without the need for an interpreter or virtual machine — the analysis becomes more complex and is often overlooked by security analysts. This leads to the failure to identify critical vulnerabilities related to low-level code. Furthermore, Android malware and RASP (Runtime Application Self-Protection) solutions are typically implemented in native code, requiring an even higher level of analytical capability from security analysts.

The use of native methods in Android applications is essential when performing computation-intensive operations that the Android Runtime (ART) cannot efficiently optimize. Processes such as encryption, physics simulations, and interactions with game engines require performance beyond what Just-In-Time (JIT) compilation can provide. By integrating native code written in languages like C, C++, or Assembly, developers can execute these operations more efficiently, significantly improving the application’s performance. In Android applications, native code is stored in dynamic libraries located in the lib directory within the APK package. Communication between the Java layer and native code is facilitated by the system library libnativehelper.so, which implements the Java Native Interface (JNI). JNI acts as a bridge, allowing Java code to access and utilize services written in native code.

In the Java layer code, native methods are identified by the native keyword before their declaration in the Java or Kotlin class. In the C/C++ layer, the signature of the native method must follow a specific format, which includes the prefix Java, the package name, the class name, and the method name, all separated by underscores (_).

package com.example.jni;

public class HelloJni {
static {
System.loadLibrary(“hello-jni”);
}

public native String stringFromJNI();

}
JNIEXPORT jstring JNICALL
Java_com_example_jni_HelloJni_stringFromJNI(
JNIEnv* env,
jobject thiz
)
{
return (*env)->NewStringUTF(env, "Hello from JNI!");
}

In the code above, we can observe two parameters: the first is mandatory, while the second depends on whether the method is static or not. These parameters consist of a pointer to the JNIEnv structure and the Java object jobject (or jclass) to which the method is associated. The JNIEnv structure acts as a function address table for JNI, allowing the native code to access elements of the Java layer. The image below, taken from the Java documentation, illustrates this structure (the JNI interface pointer is the JNIEnv structure itself). Additionally, as stated in the documentation, there is an instance of the JNIEnv structure for each thread.

The Frida API provides a method within the Java.vm object called getEnv(), which allows access to the JNIEnv structure of the current thread. It is important to note that since Frida injects itself into the application on any running thread, it is crucial to ensure that the thread is properly bound to the Java VM, or an exception will occur. The code in the Frida script below displays the address of the JNIEnv structure on the current thread:

try {
const env = Java.vm.getEnv();
const env_address = env.handle.readPointer();
console.log('JNIEnv address: ', env_address);
} catch (error) {
console.error('Error accessing the JNIEnv structure:', error);
}

When analyzing the JNIEnv structure in the jni.h file, we can see that the first element of the structure is a pointer to a structure called JNINativeInterface_.

The JNINativeInterface_ structure ultimately consists of an array of function pointers for JNI. The function pointers within the JNIEnv structure are used to provide access to JNI functions that allow native code to interact with the Java environment. Each pointer in the array points to a specific function that performs a task, such as invoking Java methods, accessing fields, handling exceptions, or interacting with Java objects, enabling communication between native code and the Java Virtual Machine (JVM).

We can then modify our Frida script to print the address of the function pointer array and use the Interceptor.attach method from Frida’s JavaScript API to hook a function within that array. For this example, we chose the NewString function, whose prototype is described below. There are two important considerations: first, as mentioned earlier, the address of the function pointer array can be obtained from the first element of the JNIEnv structure. Second, each element in the array has the size of a pointer, which varies depending on the processor architecture. On 32-bit systems, pointers are typically 4 bytes, while on 64-bit systems, they are 8 bytes. Therefore, to hook a function at index “n”, we need to calculate the address of the corresponding pointer using the following formula: base address of the structure + (index * pointer size). After performing this calculation, we will read the contents of the address, which will contain the actual address of the NewString function.

jstring (JNICALL *NewString)(JNIEnv *env, const jchar *unicode, jsize len);

I manually calculated the offset for the NewString function, but you can pass the structure to ChatGPT and ask it to perform the offset calculations easily. Just remember to take the pointer size into account. With that said, our final Frida script will look as follows:

try {
const env = Java.vm.getEnv();
const env_address = env.handle.readPointer();
console.log('JNIEnv address: ', env_address);

const func_table_address = Memory.readPointer(env);
console.log('Address of the function pointer table in JNIEnv: ', func_table_address)

const newstring_offset_bytes = 163 * Process.pointerSize;
const newstring_address = ptr(env_address.add(newstring_offset_bytes)).readPointer();

// jstring (*NewString)(JNIEnv*, const jchar*, jsize);
Interceptor.attach(newstring_address, {
onEnter(args) {
console.log('\x1b[3' + '6;01' + 'm', JSON.stringify({
env: args[0],
string: args[1].readUtf16String(),
size: args[2]
}));
}
});
} catch (error) {
console.error('Error accessing the JNIEnv structure:', error);
}

Frida allows certain JNI methods to be accessed directly via Java.vm.getEnv(), as demonstrated in its source code. If needed in a specific scenario, you can leverage these methods, as shown in the following example. In the case below, the message ‘Hello, World!’ will be displayed.

try {
const env = Java.vm.getEnv();
let result = env.newStringUtf('Hello, World!');
console.log(env.getStringUtfChars(result, null).readCString());
} catch (error) {
console.error('Error accessing the JNIEnv structure:', error);
}

Conclusion

In conclusion, native code is often overlooked by security analysts due to its complexity. However, this oversight can lead to the failure to identify critical vulnerabilities in low-level code. Tools like Frida are invaluable in such scenarios, as they allow for a deeper understanding of an application’s behavior. By enabling real-time interaction and analysis of both Java and native code, Frida empowers analysts to gain insights that are otherwise difficult to obtain, ultimately enhancing the security assessment process.

References

--

--

Thiago Peixoto
Thiago Peixoto

Written by Thiago Peixoto

Reverse Engineer | Malware Analyst | Offensive Security Engineer | Information Security Analyst | Speaker

No responses yet