Android Security Misconceptions: Storing Keys & Secrets in Native Code
When it comes to securing keys and secrets within a mobile application developers might be inclined to believe a single technique will achieve resilience, and in many cases it’s not enough. In a previous blog post we covered the obfuscation realities of R8 and why it cannot be relied upon for security. In this blog post we’ll dive deeper into why you need to take a multi-layered approach to protecting your app and why taking the easiest step is not necessarily the most effective.
Myth: R8 will secure my secrets
It’s common knowledge in the Android community that compiled Java / Kotlin code is easy to decompile with free, widely available tools. The first (small) step towards making Android apps more difficult to understand is usually to use the default Android Shrinker, R8, which will shrink, optimize and rename all your classes, methods and fields.
But if you have secrets stored in strings R8 won’t help you at all: consider the following code:
class MySecretClass {
fun getMySecret(): String {
return "My Secret String";
}
}
Reality
R8 will rename the class and method but the sensitive string will still be clearly visible using a tool such as jadx, as shown in the screenshot below:
Often, the next logical (and easiest) step is to move the string into native (C/C++) code but don’t believe everything you read; let’s see how easy it is to find the secret after it’s moved to native code!
Myth: Secrets stored in native code are more secure
We’ll move the string “My Secret String” into native code in a similar method as suggested by this Medium blogpost. First, we need to define the `getMySecret` function as external and load our native library:
class MySecretClass {
external fun getMySecret(): String
companion object {
init {
System.loadLibrary("myapplication")
}
}
}
We can then implement the function in native code as follows:
#include <jni.h>
#include <string>
extern "C" jstring Java_com_example_myapplication_MySecretClass_getMySecret(
JNIEnv* env,
jobject /* this */) {
std::string s = "My Secret String";
return env->NewStringUTF(s.c_str());
}
The functionality has not changed but the location of the string has changed. If we open the APK in jadx now we’ll see a few differences:
There is no string since we moved it to native code; we also can no longer change the name of the class or method since these need to match the names in the native code (according to the JNI naming convention).
But the string can still easily be found…
Reality
Simply extract the contents of the APK and run the strings command (which finds and prints strings of printable characters in any file) and you’ll see the string:
$ unzip -p app-release.apk lib/x86/libmyapplication.so | strings | grep "My Secret String"
My Secret String
Myth: Custom encryption in native code will really hide my string
If the string can still be found so easily, then surely the next logical step would be to encrypt the string in the native code. For example, as shown in the following snippet with basic string encryption:
#include <jni.h>
#include <string>
extern "C" jstring Java_com_example_myapplication_MySecretClass_getMySecret(
JNIEnv* env,
jobject /* this */) {
const std::string encrypted = "Nz!Tfdsfu!Tusjoh";
const std::string decrypted = decrypt(encrypted);
return env->NewStringUTF(decrypted.c_str());
}
Of course, the exact string is now no longer visible using the strings command!
Reality
The string still needs to be decrypted at runtime so the original string will be visible via dynamic analysis and can then be found during such dynamic attacks. A tool such as Frida can be used to dynamically intercept calls to methods; for example the following simple Frida script will be able to capture the decrypted string at runtime:
Java.perform(function x(){
Java.use("com.example.MySecretClass").getMySecret.implementation = function() {
var original = this.getMySecret();
console.log(original);
return original;
}
})
The script can be executed with a simple command and the decrypted string will be printed in the console when getMySecret
is executed.
$ frida -U -l script.js --no-pause -f 'com.example.secrets’
Spawned `com.example.secrets`. Resuming main thread!
[Android Emulator 5554::com.example.secrets]-> My Secret String
Conclusion
Keys and secrets such as passwords, cryptographic keys, and API tokens, play a critical role in securing sensitive information. As illustrated above, seemingly logical steps like hiding them in native code and using encryption can result in insufficient protection that is easily bypassed. So, what do you do? The most effective approach is to avoid secrets being found on the device at all. If they aren’t included in the binary or retrieved dynamically from the backend, there is nothing to leak, full stop. Instead, you can generate and securely store the secret on the server, have the application register itself and rely upon an authorization grant flow like OAuth 2.0.
Sometimes this best practice isn’t feasible or easily implemented, and secrets are required to be retrieved dynamically from the backend. In this case you need to apply a multi-layered approach to ensure the application is as secure as possible. For that you must shift your approach to limit the impact of compromised static data. Instead of using a static secret as the local storage key you can derive the key using your static secret and other parameters such as a device key stored in a hardware backed Android KeyStore, or user password.
Taking it another step further, tie this secret to a single user instance, so even if an attacker recovers it, it can’t be used in a scaled attack. In addition you will need to prevent recovery of the mixed key using several mobile app security techniques: string encryption to hide the secrets, multiple layers of code obfuscation to hide the key derivation logic, and most importantly: anti-hooking and anti-debugger protections to thwart meddling at runtime.