JNI Obfuscation, Reverse Engineering, and Android Security

Written by sergeich | Published 2025/08/25
Tech Story Tags: android-app-development | android-security

TLDRThe Java Native Interface (JNI) lets Android apps tap into native C/C++ code for performance and interoperability, but it’s no security shield. Native binaries can be reverse-engineered just as easily as Java code. This article explores why obfuscation matters, how attackers analyze JNI libraries, and practical strategies—like using RegisterNatives, renaming methods, and applying C/C++ obfuscators—that raise the bar for reverse engineers. Ultimately, JNI should be treated as a performance tool, not a way to “hide” secrets.via the TL;DR App

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?



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 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.

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.


Further reading


Written by sergeich | Android Software Developer
Published by HackerNoon on 2025/08/25