When building Android applications, most developers live entirely in the world of Java or Kotlin. It’s safe, portable, and the tooling is excellent. But sometimes, you need to step outside of that comfort zone, whether it’s to access hardware acceleration, tap into platform-specific APIs, or reuse existing native libraries. That’s where the Java Native Interface (JNI) comes in.
JNI is powerful, but it comes at a cost. It bypasses many of the safety guarantees of the JVM, and if misused, it can expose your application to serious security risks. Worse, many developers assume that “moving code to native” will protect it from reverse engineering, only to discover that native libraries are just as vulnerable, sometimes even more so. JNI is often used in an attempt to ‘hide’ sensitive logic or keys, but this is a misconception; native binaries can be reverse-engineered, too.
In this article, I’ll walk you through:
- Why obfuscation matters (and what tools like ProGuard and R8 actually do),
- How reverse engineering of JNI code works in practice, and
- Several strategies to make life harder for attackers, along with their limitations.
What is JNI?
Java Native Interface (JNI) is a programming framework that allows Java code running in the JVM to call—and be called by—native applications and libraries written in C, C++, or other languages.
Throughout this article, I’ll refer to C/C++ code simply as native code.
First and foremost, you shouldn’t use JNI unless you know exactly what you’re doing. It offers little protection against reverse engineering, and improper use can introduce serious security vulnerabilities. There are quite a few articles on this topic and even papers, e.g. this and this.
Why Obfuscate Code?
Obfuscation isn’t a substitute for secure design. It only raises the cost of reverse engineering; flawed logic or hardcoded secrets remain just as vulnerable.
- To hinder reverse engineering. As a security researcher, every time I encounter non-obfuscated code, I’m silently thankful, it makes my job easier. For the developer, though, it’s a missed opportunity to make life harder for attackers.
- To optimize code. Tools like ProGuard (or more commonly today, R8) don’t just obfuscate. They also shrink and optimize your code by removing unused classes, fields, and methods. They use control-flow and data-flow analysis to eliminate redundancies and find shorter execution paths.
- To clean up dead code. Developers often leave unused methods or classes in projects. R8 strips them out automatically.
So obfuscation is not only about hiding intent; it also produces cleaner, faster, smaller applications.
A Simple JNI Example
Here’s a minimal setup using JNI:
MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
…
Log.d("MainActivity", "native call result: ${NativeLib().superSecretMethod()}")
}
}
NativeLib.kt
class NativeLib {
external fun superSecretMethod(): String
companion object {
init {
System.loadLibrary("nativelib")
}
}
}
nativelib.cpp
extern "C" JNIEXPORT jstring JNICALL
Java_me_sergeich_nativelib_NativeLib_superSecretMethod(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
This example simply loads a native library and calls superSecretMethod() implemented in C++.
Reverse Engineering with Static Analysis
Let’s assume you’ve built the APK and applied ProGuard (or R8). It can be decompiled using tools such as JADX. What do we see?
Most Java classes are renamed to something meaningless. However, we still see MainActivity and the NativeLib class with the method superSecretMethod() intact. Why?
- Activity names must be preserved so the OS can launch them.
- JNI requires original method names to link Java to native code. ProGuard and R8 explicitly preserve these, as documented here: https://www.guardsquare.com/manual/configuration/examples#native
Static analysis of the .so library is also straightforward. Running nm on the shared object or loading it into a disassembler (e.g., Hopper) immediately reveals function names and string literals such as "Hello from C++". Obfuscation helps, but only to a point.
Even worse, dynamic analysis tools like Frida or Xposed can intercept and hook JNI calls at runtime, bypassing static defenses entirely.
Mitigation Strategies
So, how do we make reverse engineering harder?
Solution 1: Register Natives
There are two ways that the runtime can find your native methods. You can either explicitly register them withRegisterNatives, or you can let the runtime look them up dynamically withdlsym. The advantages ofRegisterNativesare that you get up-front checking that the symbols exist, plus you can have smaller and faster shared libraries by not exporting anything butJNI_OnLoad. The advantage of letting the runtime discover your functions is that it's slightly less code to write.
Source: https://developer.android.com/training/articles/perf-jni#native-libraries
Instead of relying on long, descriptive JNI method names as Java_me_sergeich_nativelib_NativeLib_superSecretMethod
you can use RegisterNatives() to explicitly map Java methods to C/C++ implementations via function pointers. This avoids exporting the method names directly, and the C/C++ compiler can omit them.
The code in nativelib.cpp will look like this:
jstring superSecretMethod(JNIEnv* env) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
static JNINativeMethod methods[] = {
{"superSecretMethod", "()Ljava/lang/String;", (void *) superSecretMethod},
};
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
jclass c = env->FindClass("me/sergeich/nativelib/NativeLib");
if (c == nullptr) {
return JNI_ERR;
}
int rc = env->RegisterNatives(c, methods, sizeof(methods) / sizeof(JNINativeMethod));
if (rc != JNI_OK) return rc;
return JNI_VERSION_1_6;
}
Upsides
- Removes obvious exported method names from the binary.
- Prevents linkage errors if libraries aren’t loaded in order.
Downsides
- Replaces one obvious structure (symbol names) with another (string table of class names and signatures).
- Reverse engineers can still trace the
RegisterNatives()call with a debugger to see the mapping. - Class names and signatures are still present in the shared library unless further obfuscated or encrypted.
- Attackers can still hook Java calls to these native methods at runtime.
Solution 2: Manually Renaming Classes
You could rename JNI methods to meaningless symbols (a, b, z), so they don’t leak intent.
Upsides
- Reduces clues for static analysis (attackers don’t see descriptive names like
superSecretMethod).
Downsides
- Developers must maintain obfuscated names in both Java and C/C++, which is a maintenance nightmare.
- Refactoring becomes painful: every change requires edits in multiple places.
- Only hides calls from Java to C, not C back to Java.
- Over time, developer productivity and code readability suffer significantly.
This is hardly viable outside of pet projects; it’s essentially doing the job of a manual obfuscator here.
Solution 3: Native Obfuscator
There are commercial and open-source C/C++ obfuscators. They complicate the reverse engineering of native binaries. It’s not a silver bullet (especially in the age of powerful decompilers and even LLM-based tools), but it adds another barrier.
Upsides
- Makes static analysis of the compiled library harder (e.g., control flow flattening, string encryption).
- Raises the bar for reverse engineers, especially inexperienced ones.
Downsides
- Not a silver bullet, determined analysts can still reverse engineer given time and the right tools.
- In the age of LLM-based code assistance, de-obfuscation is gradually getting easier.
- Adds complexity to your build pipeline.
The review of available obfuscators is out of scope of this article.
Practical Suggestions
ProGuard/R8 does quite a good job obfuscating Java/Kotlin code but it doesn’t help at all with native C/C++ code.
- So suggestions are:
- Don’t use JNI unless absolutely necessary.
- If you must, obfuscate both your Java/Kotlin and C/C++ code.
- Prefer
RegisterNatives()over default JNI naming. - Use C/C++ obfuscators to further protect sensitive logic.
- Never hardcode cryptographic keys or credentials in JNI libraries.
- Use Android Keystore or other secure storage mechanisms instead.
JNI is powerful, but with power comes responsibility. Used carelessly, it can do more harm than good.
Ultimately, JNI should be used for performance or interoperability—not as a security boundary. While obfuscation, RegisterNatives(), and native obfuscators can raise the bar for attackers, they cannot replace sound architecture, secure coding practices, or proper use of platform security features. Treat JNI as a tool for capability, not a shield against reverse engineering.
