Attacking the JNI Boundary with Frida

Optiv is seeing an increasing number of mobile developers turning to native code for security-sensitive areas of their Android applications, such as encryption and secure data storage. This can present a daunting challenge to the unprepared attacker; where Java decompilers are free and effective, decompiling native binaries frequently involves expensive, complex tooling and extensive practice.

 

While moving functionality from Java to native implementations doesn’t actually increase security, it does increase obscurity. Correctly applied, this can serve to discourage less-advanced attackers from penetrating critical functionality of the app. However, all native code running on Android has one common weak point, which is the predictable structure of the Java Native Interface (JNI) itself. The boundary between native and Java code is a great place to focus for reverse-engineering.

 

 

NDK Crackme

For the purposes of demonstrating this style of attack, I have created a simple app called NDK Crackme (com.optiv.ndkcrackme). This app is not an especially difficult challenge to reverse-engineer but will help in demonstrating the topics being discussed. Full source code to the app, as well as a compiled APK, can be found here.

 

There are three main functional areas of the NDK Crackme application.

 

The first TextView is displaying a string that is pulled from the native library at runtime. Developers sometimes use an approach like this to conceal an encryption key:

 

attacking_the_jni_img1
Figure 1: The first area

 

 

The second TextView displays the results from a password check. When you press the CHECK PASSWORD button, whatever string is entered into the password field is submitted to the native library for validation against a hardcoded value. A developer might do this to conceal hardcoded credentials (always a bad idea) from casual inspection:

 

attacking_the_jni_img2
Figure 2: The second area

 

 

attacking_the_jni_img3
Figure 3: Demonstration of wrong password entry

 

 

The third TextView displays the results of reading and writing from a data store that’s being managed by the native library. If you enter a key and value and press the WRITE button, the value is stored under that key, and the value field is cleared. If you enter a key and press the READ button, the value field will be overwritten with whatever was read from the store. An empty string is treated as a “not found” entry:

 

attacking_the_jni_img4
Figure 4: The third area

 

 

In order to decompile the NDK Crackme app and follow along with the instructions below, you will need to use a Java decompiler that understands the APK format. When preparing this article, I used a decompiler called JADX, with the following command:

 

jadx -d jadx-out com.optiv.ndkcrackme.apk

 

 

What is JNI?

JNI is a standard programming interface for calling native functions from Java code or calling Java functions from native code. It can be used on any platform where Java can be used. On the Android platform, this is used almost exclusively to call C/C++ functions from Java. This gives JNI-enabled apps three notable identifiers, which can be used to target their native functions for further analysis:

 

  1. Library files.

     

    If an APK contains native code, this code will be compiled and packaged into a Shared Object file, with the extension .so. These files can be found in the lib folder of a typical decompiler output. The lib folder will have subfolders named after different Android Binary Interface (ABI) packages, which are the processor architectures that Android supports. Each folder will contain a copy of Shared Object file compiled for that specific ABI.

     

    These are the Shared Object library files that are included in NDK Crackme:

     

    % ls jadx-out/resources/lib/*/
    jadx-out/resources/lib/arm64-v8a/:
    libnative-lib.so

    jadx-out/resources/lib/armeabi-v7a/:
    libnative-lib.so

    jadx-out/resources/lib/x86/:
    libnative-lib.so

    jadx-out/resources/lib/x86_64/:
    libnative-lib.so

     

  2. System.loadLibrary

     

    In order to access the .so file at runtime, an Android application must call the function System.loadLibrary with the name of the library to load. This name will be prefixed with “lib” and postfixed with “.so,” so that System.loadLibrary("foo") will load a native library named “libfoo.so.”. These System.loadLibrary calls will typically be found in the static initializer of a Java class that references native code.

     

    The only System.loadLibrary call that can be found in NDK Crackme is in MainActivity.java:

     

    static {
    System.loadLibrary("native-lib");
    }

     

  3. Native function declarations

     

    In order to form function calls to the native library, some class must declare native functions. These function declarations have no body and have the keyword native in their declaration. JNI uses a prescribed naming format to map these function declarations to their implementations in the native libraries. Because the names of the functions in the native binary must be chosen very precisely based on the Java function that they map to, the names of JNI functions are typically ignored by code obfuscation utilities.

     

    These are all the native function declarations present in NDK Crackme:

     

    public native String a();
    public native boolean b(String str);
    public native void c();
    public native String d(String str);
    public native void e(String str, String str2);

     

    You can also use a tool like strings to dump all the string data from the library file. Working from the JADX decompile command earlier, this command will dump the strings from one version of the native library:

     

    strings jadx-out/resources/lib/x86/libnative-lib.so

     

    The output from strings is verbose, but if we search for the word Java, it’s easy to spot the names of the C/C++ implementation names:

     

    Java_com_optiv_ndkcrackme_MainActivity_a
    Java_com_optiv_ndkcrackme_MainActivity_b
    Java_com_optiv_ndkcrackme_MainActivity_c
    Java_com_optiv_ndkcrackme_MainActivity_d
    Java_com_optiv_ndkcrackme_MainActivity_e

 

Based on the information above, we can say that in this app there are five native functions, all of them in the MainActivity class. It’s not yet clear what each of these functions does, although you may have some guesses based on what the app does and what arguments each function takes. Reading the decompiled MainActivity.java could provide further clues, but it may not always be obvious on inspection how and why a specific function gets called. We can gather a little more information if we do some basic instrumentation on the five native functions to develop some context on what they’re for and how they work.

 

 

Initial Instrumentation

Even though the JNI functions aren’t implemented in Java, they’re still part of Java classes, and Frida treats them about the same. In order to get a better picture of how the native functionality works, I usually start with a very simple Frida script that hooks all the JNI entry points, prints out their arguments and return values, but otherwise doesn’t interfere with the app’s functionality:

 

Java.perform(function(){
var MainActivity = Java.use("com.optiv.ndkcrackme.MainActivity")

//public native String a();
MainActivity.a.overload(
).implementation = function() {
console.log("[+] a:")

var retval = this.a()
console.log("[*] retval: " + retval)
return retval
}

//public native boolean b(String str);
MainActivity.b.overload(
'java.lang.String'
).implementation = function(p0) {
console.log("[+] b:")
console.log("[-] p0: " + p0)

var retval = this.b(p0)
console.log("[*] retval: " + retval)
return retval
}

//public native void c();
MainActivity.c.overload(
).implementation = function() {
console.log("[+] c:")

this.c()
}

//public native String d(String str);
MainActivity.d.overload(
'java.lang.String'
).implementation = function(p0) {
console.log("[+] d:")
console.log("[-] p0: " + p0)

var retval = this.d(p0)
console.log("[*] retval: " + retval)
return retval
}

//public native void e(String str, String str2);
MainActivity.e.overload(
'java.lang.String', 'java.lang.String'
).implementation = function(p0, p1) {
console.log("[+] e:")
console.log("[-] p0: " + p0)
console.log("[-] p1: " + p1)

this.e(p0, p1)
}
})

 

Saving the above as frida.js, I then start the application with Frida:

 

% frida -U -f com.optiv.ndkcrackme --no-pause -l frida.js
____
/ _ | Frida 12.8.20 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://www.frida.re/docs/home/
Spawned `com.optiv.ndkcrackme`. Resuming main thread!
[Pixel 3a::com.optiv.ndkcrackme]-> [+] a:
[*] retval: This string retrieved from C++!
[+] c:

 

Based on the above, I now know that a and c are both called when the application first starts, and I know exactly what a returns. If I press buttons in the app, I can get some more information about how the app is using the native library.

 

  • Trying out the password check:

 

// Tried with a blank value
[+] b:
[-] p0:
[*] retval: false
// Tried with "testpass"
[+] b:
[-] p0: testpass
[*] retval: false

 

  • Trying out the data store:

 

// Read with a blank key
[+] d:
[-] p0:
[*] retval:
// Wrote with a blank key
[+] e:
[-] p0:
[-] p1:
// Read with a blank key again
[+] d:
[-] p0:
[*] retval:
// Read with "testkey"
[+] d:
[-] p0: testkey
[*] retval:
// Wrote with "testkey" and a blank value
[+] e:
[-] p0: testkey
[-] p1:
// Read with "testkey" again
[+] d:
[-] p0: testkey
[*] retval:
// Wrote with "testkey" and "testvalue"
[+] e:
[-] p0: testkey
[-] p1: testvalue
// Read with "testkey" again
[+] d:
[-] p0: testkey
[*] retval: testvalue

 

At this point, we have a pretty good picture what most of the functions are for:

 

  • a retrieves a string from the native library exactly once during startup. It takes no arguments and seems to return the same value every time. We want to investigate that to be sure.

  • b is handling the password checks. Thus far it is always returning false, it seems likely that it returns true when the password is accepted.

  • d is used to read the data store, and e is used to write the data store.

 

It’s not clear what c is for, but we have enough information to break our attack down along the three functional areas of the app.

 

 

Area 1: The Retrieved String

One thing we’d like to establish about a is whether it returns the same string every time, even if called repeatedly without restarting the app. Does it have internal state like a PRNG, or is it just returning a static string? We can establish this pretty easily by using Frida:

 

function stringFromJNI() {
var result = "ERROR"

Java.perform(function() {
Java.choose("com.optiv.ndkcrackme.MainActivity", {
onMatch: function(instance) {
result = instance.a()
},
onComplete: function() {}
})
})

return result
}

 

It’s worth taking a moment to talk about how this script works. Since our functions are non-static methods of the MainActivity class, we need to use Java.choose to locate the active instance of the MainActivity class in memory. If the JNI function was part of a class that we expected there to be more than one of, we might need to revise this approach. It’s also fairly common to see JNI functions declared as static; in those cases, you would use Java.use to locate the class and call them directly.

 

In any case, running the above function helps us answer our question from before:

 

% frida -U -f com.optiv.ndkcrackme --no-pause -l frida.js
____
/ _ | Frida 12.8.20 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://www.frida.re/docs/home/
Spawned `com.optiv.ndkcrackme`. Resuming main thread!
[Pixel 3a::com.optiv.ndkcrackme]-> [+] a:
[*] retval: This string retrieved from C++!
[+] c:
[Pixel 3a::com.optiv.ndkcrackme]->
[Pixel 3a::com.optiv.ndkcrackme]-> stringFromJNI()
[+] a:
[*] retval: This string retrieved from C++!
"This string retrieved from C++!"
[Pixel 3a::com.optiv.ndkcrackme]-> stringFromJNI()
[+] a:
[*] retval: This string retrieved from C++!
"This string retrieved from C++!"
[Pixel 3a::com.optiv.ndkcrackme]-> stringFromJNI()
[+] a:
[*] retval: This string retrieved from C++!

 

a does seem to return the same string every time it’s called. No matter how complex and obfuscated the logic inside the library, once something is returned through JNI, it’s easy to intercept and inspect.

 

Area 2: The Password Check

For the password check, what we’d really like is to be able to determine the correct password. As I mentioned before, this application isn’t especially hardened or obfuscated so it’s pretty easy to extract the right password from the library with a tool like strings. Brute-forcing is also an option; we can construct a Frida script like the one above and use it to repeatedly run the password check function until we find a value that works.

 

But if all we need is to have the app accept our password, that’s just a matter of modifying the hook script to bypass the call to the real implementation of b:

 

//public native boolean b(String str);
MainActivity.b.overload(
'java.lang.String'
).implementation = function(p0) {
console.log("[+] b:")
console.log("[-] p0: " + p0)

//var retval = this.b(p0)
var retval = true
console.log("[*] retval: " + retval)
return retval
}

 

Now, the app doesn’t care if we know the right password or not, it will always accept the provided password:

 

[Pixel 3a::com.optiv.ndkcrackme]-> [+] b:
[-] p0:
[*] retval: true

 

attacking_the_jni_img5
Figure 5: Demonstrating password check bypass with a blank password

 

 

Whatever functionality was locked behind this password check is now fully available to us to explore.

 

Area 3: The Data Store

Let’s start this area by setting ourselves up with easy control over the data store from Frida:

 

function readData(key) {
var result = "ERROR"

Java.perform(function() {
Java.choose("com.optiv.ndkcrackme.MainActivity", {
onMatch: function(instance) {
result = instance.d(key)
},
onComplete: function() {}
})
})
return result
}

function writeData(key, value) {
Java.perform(function() {
Java.choose("com.optiv.ndkcrackme.MainActivity", {
onMatch: function(instance) {
instance.e(key, value)
},
onComplete: function() {}
})
})
}

 

Normally a data store like this won’t be exposed to the user for arbitrary reads and writes; it will only be exposed to the back end of the application for storing values out of reach of a reverse engineer. This means that just achieving read/write access over it is potentially a huge win! A data store of this nature may be stored in encrypted format on the device’s storage; if it’s implemented with strong encryption through the Android System Keystore, that data may be very difficult to decrypt at rest. But since the function that reads from the data store is exposed by its JNI signature, it’s easy to target it and have the app read back whatever key you want.

 

Limitations and Next Steps

Even for an app as simple as this one, there’s some very meaningful questions that can’t be answered by this kind of attack, and it’s worth thinking about how else to answer these questions without reverse-engineering the library itself:

 

  • What’s the password that the password check is expecting?
    Getting the real password might provide clues to other passwords in the same system, or let you succeed at the password check on an uncompromised device. This one is easy to work out by dumping all the strings out of the SO file. C/C++ tends to leave a lot of useless strings in the binary, but if you look for strings that you know are in the binary already, you can narrow things down quite a bit.

  • What does function c do?
    Given that the application calls it only once during the initialization of MainActivity, it probably does initialization for the native library. Since it’s called after a, it probably doesn’t have to do with setting up the retrieved string. Determining what this function does via reverse-engineering could be difficult – the vagaries of how C++ compiles certain very ordinary-looking lines of code makes this one very hard to read.

  • What keys are already in the data store?
    If we could make a list of all the stored keys, we could dump the whole data store on a whim. We can approximate this with instrumentation and grepping, but we’d prefer to be able to do it directly. There is one key and value stored in the app when it first starts. The key, oddly, doesn’t make it into a strings dump of the file, but the value does.

 

Conclusions

It bears repeating that nothing about native code is inherently more secure than Java code and moving functionality into native code is not a substitute for improving security. On the Android platform, Optiv always recommends not storing any sensitive data at all on the user’s device; if sensitive data must be stored, then Optiv always recommends that it be encrypted using the Android System Keystore. Rather than relying on the difficulty of reverse-engineering native code, this method relies on the difficulty of extracting the key material from the Android OS, which is designed to resist such attacks.

 

That said, it’s still worth thinking about where exactly the boundary between the native code and Java code is. Anything passed as an argument to a native function, or returned by a native function, can be inspected trivially, and an attacker will usually do so. Passing any kind of sensitive data this way is obviously a huge risk. Any data the native code manages internally is still subject to memory dumping attacks or conventional reverse-engineering, but modern code-obfuscation techniques can greatly raise the difficulty of attacking the native library directly.

Senior Security Consultant | Optiv
Hudson Bloom is a Senior Security Consultant in Optiv's Threat Management Team (Application Security practice). Hudson specializes in mobile and thick-client reverse engineering, especially against old or esoteric technologies. Hudson's primary role is to deliver client projects.