Published on March 2nd, 2023 📆 | 1686 Views ⚑
0Keysight | Optiv
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:
Â
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:
Â
Figure 2: The second area
Â
Â
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:
Â
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:
Â
- 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.sojadx-out/resources/lib/armeabi-v7a/:
libnative-lib.sojadx-out/resources/lib/x86/:
libnative-lib.sojadx-out/resources/lib/x86_64/:
libnative-lib.soÂ
- 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");
}Â
- 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
Â
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.
Gloss