Secrets in Android Part 1
Many of the Android apps require some secrets like API keys to work. In most cases these keys are for Google services and they can be protected based on the app signing key (this can be configured in the Google Cloud Console) so it’s not a problem if they are leaked. However, sometimes we have to add keys or other secrets that are not protected and need to be hidden. In these articles I’m going to show ways to do it.
There are 2 approaches for the client secrets:
- Bundle secrets in the app binary
- Fetch them from remote storage (server)
In this article I will demonstrate the first type.
Static field approach
The first solution that most of the developers use is just some static filed in the code that holds the key.
The key can also be added during compile time in BuildConfig or resources (as google-services plugin does) with the following build.gradle configuration.
android {
defaultConfig {
buildConfigField("String", "API_KEY", "\"SECRET_API_KEY\"")
resValue("string", "api_key", "\"SECRET_API_KEY\"")
}
buildFeatures {
buildConfig = true
}
}
Then these keys can be accessed in the following way:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Log.e("TEST", "Static field: $API_KEY")
Log.e("TEST", "BuildConfig field: ${BuildConfig.API_KEY}")
Log.e("TEST", "Res field: ${getString(R.string.api_key)}")
}
companion object {
const val API_KEY = "SECRET_API_KEY"
}
}
The main problem with this approach is that these values can be easily discovered in the result app.
During the compilation R8 optimizes usages of static strings concatenation so it became a single string. That’s why in the compiled code the value got inserted to the log messages.
Resource value can be also easily discovered in the result app.
This approach can be improved a bit by splitting the key in several parts and put them in different parts of the application. Then these parts can be combined during runtime. The parts can be also processed somehow. For example, they can be XORed with some some constant and it will make the process of discovering the actual key harder for the attacker, but still possible.
NDK approach
The first approach hides the keys in the Kotlin/Java source code. We can go on the lower level and hide the keys in the native code (C/C++) which then got compiled into .so library.
Lets start with .so library build. The following configuration needs to be added to the module build.gradle.
android {
externalNativeBuild {
cmake {
path = file("CMakeLists.txt")
}
}
}
CMake configuration ({module}/CMakeLists.txt) is the following:
project(secrets)
cmake_minimum_required(VERSION 3.4.1)
add_library( # Specifies the name of the library.
secrets
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
src/main/cpp/secrets.h
src/main/cpp/secrets.cpp
)
The source code of the library is represented as .cpp and .h files:
#include "jni.h"
#include <string>
#define API_KEY "SECRET_API_KEY"
#define API_KEY_LENGTH strlen(API_KEY)
void getApiKey(char* buffer);
extern "C" JNIEXPORT jstring JNICALL
Java_com_kurantsov_NativeSecrets_getApiKeyFromNative(
JNIEnv *env,
jobject thiz
) {
char key_buffer[API_KEY_LENGTH + 1] ;
key_buffer[API_KEY_LENGTH] = '\0';
getApiKey(key_buffer);
return env->NewStringUTF(key_buffer);
}
#include "secrets.h"
void getApiKey(char* buffer) {
for (int i = 0; i < API_KEY_LENGTH; i++) {
buffer[i] = API_KEY[i];
}
}
To get data from the native library we can use JNI (Java native interface).
Lets define Kotlin class that will call native library using JNI.
package com.kurantsov
object NativeSecrets {
init {
System.loadLibrary("secrets")
}
external fun getApiKeyFromNative(): String
}
In the snippet above we define an object which loads native library in the initialization block and has method getApiKeyFromNative marked with external. This means that the implementation of this method is in the native code.
The linkage between Kotlin method and native function is performed by following special naming convention. The native function should have a name in the following format Java_{fully qualified name of the method where ‘.’ replaced with ‘_’}.
This approach makes it a bit harder for the attacker to discover the actual value of the secret but it still possible. The string can be easily discovered if you open .so file in the hex editor.
Bonus
We can make native library a bit more “secure” and dynamic by performing some modifications on the initial secrets and implementing the logic to reverse the modifications in the native code. In the following example, I’ve modified the initial string using XOR operation on each byte and then performed the same operation in the native code.
To achieve this the following modifications need to be added to build.gradle:
android {
defaultConfig {
val xorValue = 0xAA
val API_KEY = "SECRET_API_KEY"
val apiKeyBytes = API_KEY.toByteArray().map {
it.toInt() xor xorValue
}
val apiKeyDefinitionString =
apiKeyBytes.joinToString(prefix = "[${apiKeyBytes.size}]{", postfix = "}")
externalNativeBuild {
cmake {
cppFlags(
"-DAPI_KEY_LENGTH=${apiKeyBytes.size}",
"-DAPI_KEY_BYTES_DEFINITION=\"$apiKeyDefinitionString\"",
"-DXOR_VALUE=$xorValue",
)
}
}
}
}
The header stays pretty much the same except that original definitions are removed:
#include "jni.h"
void getApiKey(char* buffer);
extern "C" JNIEXPORT jstring JNICALL
Java_com_kurantsov_NativeSecrets_getApiKeyFromNative(
JNIEnv *env,
jobject thiz
) {
char key_buffer[API_KEY_LENGTH + 1] ;
key_buffer[API_KEY_LENGTH] = '\0';
getApiKey(key_buffer);
return env->NewStringUTF(key_buffer);
}
The cpp file is the following:
#include "secrets.h"
void getApiKey(char* buffer) {
int api_key_bytes API_KEY_BYTES_DEFINITION;
for (int i = 0; i < API_KEY_LENGTH; i++) {
buffer[i] = api_key_bytes[i] ^ XOR_VALUE;
}
}
Links
Complete example can be found in my GitHub — https://github.com/ArtsemKurantsou/Secrets-in-Android
Part 2 — https://medium.com/@artsemkurantsou/secrets-in-android-part-2-64e3872f41f5