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, andSeveral strategies to make life harder for attackers, along with their limitations. 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. 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. this 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. 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 MainActivity.kt class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) … Log.d("MainActivity", "native call result: ${NativeLib().superSecretMethod()}") } } 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") } } } 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()); } 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++. superSecretMethod() 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? superSecretMethod() 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 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 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. .so nm 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 with RegisterNatives, or you can let the runtime look them up dynamically with dlsym. The advantages of RegisterNatives are that you get up-front checking that the symbols exist, plus you can have smaller and faster shared libraries by not exporting anything but JNI_OnLoad. The advantage of letting the runtime discover your functions is that it's slightly less code to write. RegisterNatives dlsym RegisterNatives JNI_OnLoad Source: https://developer.android.com/training/articles/perf-jni#native-libraries 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 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. RegisterNatives() The code in nativelib.cpp will look like this: 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; } 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 Upsides Removes obvious exported method names from the binary.Prevents linkage errors if libraries aren’t loaded in order. Removes obvious exported method names from the binary. Prevents linkage errors if libraries aren’t loaded in order. Downsides 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. 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. RegisterNatives() 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. a b z Upsides Upsides Reduces clues for static analysis (attackers don’t see descriptive names like superSecretMethod). Reduces clues for static analysis (attackers don’t see descriptive names like superSecretMethod). superSecretMethod Downsides 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. 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 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. 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 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. 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. 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. RegisterNatives() 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. RegisterNatives() Further reading https://developer.android.com/training/articles/perf-jni#native-librarieshttps://developer.android.com/ndk/guides/symbol-visibility https://developer.android.com/training/articles/perf-jni#native-libraries https://developer.android.com/training/articles/perf-jni#native-libraries https://developer.android.com/ndk/guides/symbol-visibility https://developer.android.com/ndk/guides/symbol-visibility